From cda2280d635777042b4370f1ba687eb775a3ea9c Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:50:12 -0700 Subject: [PATCH 001/152] Show all automations (#16532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add all-host automations with scoped ownership and multi-authority suppo Enable automations to run on multiple hosts (SSH targets and local) with owner-fenced mutations, scoped list queries per host, and conflict resolution. Introduces desktop and runtime authorities as distinct automation storage owners, with per-host caching, invalidation, and retry scheduling on the renderer. Captures registration generations for SSH hosts to survive re-adoption. Adds CLI support for destination selection and conflict recovery. * Filter automation create projects by destination host Only offer projects available on the selected destination, preventing the mismatches that would fail at submit time. Auto-adjust the project selection if it becomes unavailable when the destination changes. * Add runtime storage authority support for automations - Support both runtime and desktop as automation storage authorities - Make owner preconditions optional for legacy-client compatibility - Cache automation list projections to improve performance - Add per-row repo/worktree resolution for cross-authority collisions - Extend automation.list RPC to always include owner metadata * Replace child_process.execFile with runProcess for external automations - Migrate external-manager to use cross-platform runProcess wrapper per child-process safety policy - Abstract electron app/ipcMain APIs in orca-runtime via environment accessors - Install fake app environment in automation tests for consistent setup - Reorganize imports to use specific module paths (ssh-target-registry, agent-detection, browser-error) - Remove external-manager from child-process import allowlists (no longer violates direct import) * Unify desktop automation CRUD onto the local runtime RPC surface The desktop authority now speaks the same automation.* RPC contract as remote runtimes, via callRuntimeRpc({kind:'local'}) -> runtime:call -> the shared RpcDispatcher. The automations:list/listRuns/create/update/ delete/runNow IPC arms, their preload members, and every renderer desktop-vs-runtime transport fork are retired; the runtime methods are the single implementation of scoped lists, owner fencing, and change publication for both transports (mobile clients already exercised them). The desktop probe scheduler's priority lease survives the move as an AutomationService hook the IPC registration installs and the runtime methods take, so Orca's own automation traffic still parks queued external-manager probes. External-manager scope arms and dispatch-loop plumbing stay on IPC by design; automation change events keep their existing channels (renderer ingestion already converges them by authority). * Remove automation ghost SSH tombstone scanning This functionality for synthesizing tombstones for automation-referenced SSH targets is no longer needed as part of the automation system refactoring. * Refuse orphan automations at dispatch time, not migration time Remove migration-time disabling of orphan automations and the `enabledDecidedBy` field. Dispatch now refuses orphans at runtime instead, simplifying state management and UI. Orphans are left unstamped and enabled; dispatch refuses to run them via `resolveAutomationRunTarget`. * Show all automations in flat table with unified filter menu - Replace host picker component with comprehensive Filters menu supporting status, last run, agent, and host filters - Flatten automation list layout to single table instead of host-grouped sections - Add Host column to display execution host for each automation - Display active filters as removable pills below toolbar - Delete unused AutomationHostPicker* components * Add automation owner fencing and destination validation - New AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY for owner preconditions; legacy clients get owner metadata snapshotted at RPC boundary for compatibility - Editor captures and revalidates automation destination before save, preventing silent retargeting if SSH infrastructure changes mid-edit - SSH target types now isolate renderer-authored fields; generation is server-owned and stripped by IPC handlers * Route automation recovery actions to the origin host When an automation action fails due to owner fencing, recovery verbs ("Update server", "Reconnect") must run on the host where the refusal originated: the row's captured owner for row operations, or the destination the create dialog captured, not the list's filtered host. * Remove external manager scope limitation notices Consolidate create destination eligibility checks with a unified predicate and fix the bug where desktop repo IDs could be sent to runtime hosts where they cannot resolve. * Persist only store-derived automation contexts, not client-perspective o Store contexts must never be based on client-provided runContext or sourceContext values—clients speak a different perspective (e.g., 'runtime:' for host IDs they assign), and persisting those makes the store projection orphan automations it actually owns. Derived contexts now take precedence in create and update paths, with explicit null still honored to clear a value. Tests verify this by simulating drift after storage and confirming that moves re-derive while toggles preserve. --- .gitignore | 1 + config/localization-coverage-allowlist.json | 14 + docs/automations-all-hosts-design.md | 530 ++++++ src/cli/automation-destination.ts | 77 + src/cli/automation-format.test.ts | 109 ++ src/cli/automation-format.ts | 146 ++ ...automation-owner-conflict-recovery.test.ts | 114 ++ src/cli/automation-owner-conflict-recovery.ts | 40 + src/cli/format.ts | 48 +- .../automation-destination-fencing.test.ts | 234 +++ .../handlers/automation-owner-fencing.test.ts | 93 ++ src/cli/handlers/automations.ts | 94 +- src/cli/index-automation-identifiers.test.ts | 14 +- .../index-automation-session-reuse.test.ts | 15 +- .../index-automation-source-context.test.ts | 8 +- src/cli/index-automation-target.test.ts | 39 +- src/cli/runtime/websocket-transport.test.ts | 4 +- src/cli/test-fixtures.ts | 16 + src/cli/workspace-format.ts | 100 -- .../automation-dispatch-host-fence.test.ts | 358 ++++ .../automation-owner-fencing.test.ts | 312 ++++ .../automation-owner-migration.test.ts | 428 +++++ .../automations/automation-owner-migration.ts | 236 +++ .../automation-run-terminal-surface.test.ts | 90 + .../automations/automation-run-writer.test.ts | 88 + src/main/automations/automation-run-writer.ts | 47 + .../automation-skip-coalescing.test.ts | 302 ++++ ...utomation-ssh-readoption-migration.test.ts | 169 ++ .../automation-ssh-readoption-migration.ts | 131 ++ .../automation-update-host-retarget.test.ts | 248 +++ ...omation-workspace-host-attribution.test.ts | 260 +++ src/main/automations/dispatch-refusal.ts | 53 + .../external-automation-manager-cache.test.ts | 131 ++ .../external-automation-manager-cache.ts | 148 ++ .../external-automation-owner-guard.test.ts | 168 ++ .../external-automation-owner-guard.ts | 92 ++ ...xternal-automation-probe-scheduler.test.ts | 142 ++ .../external-automation-probe-scheduler.ts | 150 ++ .../external-manager-scoped.test.ts | 268 +++ src/main/automations/external-manager.test.ts | 205 ++- src/main/automations/external-manager.ts | 549 +++--- .../automations/headless-dispatch-runner.ts | 94 ++ .../automations/refused-manual-run.test.ts | 167 ++ src/main/automations/refused-manual-run.ts | 42 + .../retained-run-reconciliation.test.ts | 252 +++ .../retained-run-reconciliation.ts | 103 ++ .../run-completion-watcher.test.ts | 383 +++++ .../automations/run-completion-watcher.ts | 182 ++ .../automations/run-target-resolution.test.ts | 63 +- src/main/automations/run-target-resolution.ts | 43 + .../runtime-terminal-run-observer.test.ts | 195 +++ .../runtime-terminal-run-observer.ts | 198 +++ src/main/automations/service-precheck.test.ts | 64 +- src/main/automations/service.test.ts | 44 +- src/main/automations/service.ts | 208 ++- src/main/index.ts | 8 +- .../ipc/automations-external-scope.test.ts | 230 +++ src/main/ipc/automations.ts | 155 +- src/main/ipc/ssh-target-crud-handlers.ts | 25 +- src/main/ipc/ssh-target-registry.test.ts | 29 + src/main/orcad/orcad-entry.ts | 4 +- src/main/persistence-automations.test.ts | 66 + ...sh-readoption-automation-migration.test.ts | 373 +++++ ...-workspace-pinned-automation-fence.test.ts | 316 ++++ ...e-workspace-repin-automation-owner.test.ts | 216 +++ .../ssh-target-reassignment.ts | 32 + .../leasing-ssh-ptys/ssh-target-state.ts | 43 +- .../loading-store/automation-persistence.ts | 98 +- .../loading-store/loaded-state-parsing.ts | 21 + .../loading-store/ssh-profile-operations.ts | 13 + .../loading-store/store-runtime-state.ts | 12 +- src/main/persistence/loading-store/store.ts | 4 +- .../loading-store/write-flush-barriers.ts | 2 + .../loading-store/write-scheduling.ts | 2 + .../automation-context-migration.ts | 9 +- .../automation-definition-operations.ts | 142 +- .../automation-owner-projection.ts | 264 +++ .../automation-run-operations.ts | 57 +- .../automation-schedule-operations.ts | 3 +- .../automation-change-publication.test.ts | 180 ++ .../runtime/orca-runtime-automations.test.ts | 21 +- src/main/runtime/orca-runtime.ts | 169 +- src/main/runtime/rpc/errors.test.ts | 22 + src/main/runtime/rpc/errors.ts | 6 +- ...ent-skill-sharing-capability-grant.test.ts | 2 +- .../artifact-sharing-capability-grant.test.ts | 2 +- .../runtime/rpc/methods/automation-schemas.ts | 190 +++ .../automation-scoped-list-methods.test.ts | 234 +++ .../runtime/rpc/methods/automations.test.ts | 24 +- src/main/runtime/rpc/methods/automations.ts | 202 +-- .../rpc/methods/client-settings-schemas.ts | 82 + .../client-ui-pairing-local-fields.test.ts | 1 + .../runtime/rpc/methods/client-ui-schemas.ts | 85 +- src/main/runtime/rpc/methods/client-ui.ts | 8 +- src/main/runtime/rpc/methods/ssh.ts | 10 +- ...ved-ssh-target-tombstone-retention.test.ts | 79 + .../removed-ssh-target-tombstone-retention.ts | 94 ++ src/main/ssh/ssh-connection-store.test.ts | 19 +- src/main/ssh/ssh-connection-store.ts | 12 +- src/main/ssh/ssh-target-id-migration.ts | 30 + src/main/ssh/ssh-target-readoption.test.ts | 2 +- src/main/ssh/ssh-target-readoption.ts | 10 +- .../window/attach-main-window-services.ts | 1 + src/preload/api-types.ts | 1 + src/preload/api/automation-api.ts | 56 +- src/preload/api/ssh-api.ts | 11 +- src/preload/index.ts | 74 +- .../AutomationCreateDestinationField.test.tsx | 86 + .../AutomationCreateDestinationField.tsx | 96 ++ .../automations/AutomationDetail.test.tsx | 76 + .../automations/AutomationDetail.tsx | 2 +- .../automations/AutomationEditorDialog.tsx | 23 + .../AutomationEditorSettingsSidebar.tsx | 14 + .../automations/AutomationHostBadges.test.tsx | 168 ++ .../automations/AutomationHostBadges.tsx | 126 ++ .../AutomationHostFilterNotice.test.tsx | 205 +++ .../AutomationHostFilterNotice.tsx | 159 ++ .../AutomationListEmptyView.test.tsx | 104 ++ .../automations/AutomationListEmptyView.tsx | 54 + .../AutomationListExternalRows.tsx | 40 +- .../automations/AutomationListFilterMenu.tsx | 70 +- .../AutomationListHostGroups.test.tsx | 168 ++ .../automations/AutomationListHostGroups.tsx | 125 ++ .../AutomationListLocalRows.test.tsx | 31 +- .../automations/AutomationListLocalRows.tsx | 196 ++- .../automations/AutomationListSortHeader.tsx | 51 - .../automations/AutomationListTableHeader.tsx | 53 +- .../automations/AutomationListToolbar.tsx | 93 -- .../AutomationOwnerConflictNotice.test.tsx | 119 ++ .../AutomationOwnerConflictNotice.tsx | 88 + .../automations/AutomationRunHistory.test.tsx | 132 ++ .../automations/AutomationRunHistory.tsx | 34 +- .../AutomationTemplateEmptyState.tsx | 49 + .../automations/AutomationsDetailPane.tsx | 30 +- .../automations/AutomationsListPanel.test.tsx | 180 ++ .../automations/AutomationsListPanel.tsx | 514 +++--- ...utomationsPage.create-destination.test.tsx | 370 +++++ ...tionsPage.cross-authority-actions.test.tsx | 126 ++ .../AutomationsPage.external-scope.test.tsx | 170 ++ .../AutomationsPage.notice-recovery.test.tsx | 121 ++ ...AutomationsPage.refresh-selection.test.tsx | 243 +++ .../AutomationsPage.run-visibility.test.tsx | 85 + .../AutomationsPage.save-visibility.test.tsx | 117 ++ .../automations/AutomationsPage.test.tsx | 628 +++++++ .../automations/AutomationsPage.tsx | 1467 ++++++++++------- .../automations/AutomationsPageSkeleton.tsx | 10 + .../automations/CreateFromPicker.tsx | 20 +- .../ExternalAutomationManagers.test.tsx | 132 +- .../ExternalAutomationManagers.tsx | 67 +- .../ExternalAutomationRunTable.tsx | 14 +- .../automation-authority-identity.test.ts | 70 + .../automation-authority-identity.ts | 89 + .../automation-captured-owner.test.ts | 130 ++ .../automations/automation-captured-owner.ts | 156 ++ .../automation-create-destination.test.ts | 222 +++ .../automation-create-destination.ts | 195 +++ .../automation-delete-confirm-preference.ts | 44 + .../automation-detail-selection.test.ts | 35 - .../automation-detail-selection.ts | 20 - .../automations/automation-edit-draft.ts | 77 + .../automation-host-cache-controller.test.ts | 317 ++++ .../automation-host-cache-controller.ts | 220 +++ .../automation-host-cache-error.ts | 114 ++ .../automation-host-cache-health.test.ts | 130 ++ .../automation-host-cache-health.ts | 132 ++ .../automations/automation-host-cache-rows.ts | 111 ++ .../automation-host-cache-types.ts | 76 + .../automations/automation-host-cache.test.ts | 254 +++ .../automations/automation-host-cache.ts | 303 ++++ ...automation-host-catalog-generation.test.ts | 220 +++ .../automation-host-catalog-generation.ts | 143 ++ .../automation-host-catalog-order.test.ts | 158 ++ .../automation-host-catalog-order.ts | 76 + .../automation-host-catalog-source.test.ts | 111 ++ .../automation-host-catalog-source.ts | 154 ++ .../automation-host-catalog-types.ts | 141 ++ .../automation-host-catalog.test.ts | 459 ++++++ .../automations/automation-host-catalog.ts | 303 ++++ .../automation-host-client.test.ts | 71 +- .../automations/automation-host-client.ts | 29 +- .../automation-host-diagnostics.test.ts | 617 +++++++ .../automation-host-diagnostics.ts | 314 ++++ .../automation-host-fetch-target.ts | 42 + .../automation-host-filter-resolution.test.ts | 266 +++ .../automation-host-filter-resolution.ts | 141 ++ .../automation-host-health.test.ts | 67 + .../automations/automation-host-health.ts | 90 + ...on-host-invalidation-window-events.test.ts | 145 ++ ...omation-host-invalidation-window-events.ts | 41 + .../automation-host-invalidation.test.ts | 147 ++ .../automation-host-invalidation.ts | 122 ++ .../automation-host-list-rows.test.ts | 225 +++ .../automations/automation-host-list-rows.ts | 169 ++ .../automation-host-orphan-entry.test.ts | 216 +++ .../automation-host-picker-groups.test.ts | 99 ++ .../automation-host-picker-groups.ts | 51 + .../automation-host-recovery.test.ts | 98 ++ .../automations/automation-host-recovery.ts | 70 + ...automation-host-request-instrumentation.ts | 108 ++ .../automation-host-retry-schedule.ts | 75 + .../automation-host-scale-gates.test.ts | 467 ++++++ .../automation-host-scheduler-plan.ts | 68 + .../automation-host-scheduler-queue.ts | 102 ++ .../automation-host-scheduler.test.ts | 561 +++++++ .../automations/automation-host-scheduler.ts | 339 ++++ .../automation-host-scope-descriptors.ts | 94 ++ ...automation-host-status-descriptors.test.ts | 182 ++ .../automation-host-status-descriptors.ts | 257 +++ .../automation-list-empty-state.test.ts | 224 +++ .../automation-list-empty-state.ts | 216 +++ .../automation-list-focus-recovery.test.ts | 65 + .../automation-list-focus-recovery.ts | 52 + .../automations/automation-list-last-run.ts | 20 + .../automation-list-row-identity.test.ts | 180 ++ .../automation-list-row-identity.ts | 111 ++ .../automation-list-search-query.ts | 56 + .../automation-list-search-rows.test.ts | 169 ++ .../automation-list-search-rows.ts | 136 ++ .../automation-list-search.test.ts | 74 + .../automations/automation-list-search.ts | 130 +- .../automations/automation-list-view.test.ts | 111 +- .../automations/automation-list-view.ts | 80 +- .../automation-notice-recovery-host.ts | 27 + .../automation-owner-action-runner.ts | 230 +++ .../automation-row-action-dispatch.ts | 199 +++ ...automation-run-completion-evidence.test.ts | 88 - .../automation-run-completion-evidence.ts | 38 - .../automations/automation-run-occurrences.ts | 35 + .../automation-scoped-list-client.test.ts | 323 ++++ .../automation-scoped-list-client.ts | 339 ++++ .../automation-usage-model.test.ts | 4 +- .../automations/automation-usage-model.ts | 63 +- .../automation-write-invalidation.test.ts | 81 + .../automation-write-invalidation.ts | 77 + .../automations/automations-page-fixtures.ts | 249 +++ .../automations-page-runtime-fixtures.ts | 52 + .../automations-page-test-harness.tsx | 473 ++++++ .../automations/automations-table-layout.ts | 5 +- .../external-automation-display.test.ts | 33 + .../external-automation-display.ts | 17 +- .../external-automation-list-entries.test.ts | 97 +- .../external-automation-list-entries.ts | 16 +- .../external-automation-scope-client.ts | 181 ++ .../external-automation-scope-gating.test.ts | 113 ++ .../external-automation-scope-gating.ts | 107 ++ .../external-automation-scope-keys.test.ts | 76 + .../external-automation-scope-keys.ts | 70 + .../external-automation-unchecked-hosts.ts | 62 + .../use-automation-create-destination.ts | 199 +++ ...se-automation-desktop-ssh-registrations.ts | 17 + ...utomation-host-catalog-readoption.test.tsx | 207 +++ ...se-automation-host-catalog-source-state.ts | 89 + .../use-automation-host-catalog.test.tsx | 203 +++ .../use-automation-host-catalog.ts | 256 +++ ...se-automation-list-focus-recovery.test.tsx | 91 + .../use-automation-list-focus-recovery.ts | 88 + .../use-automation-list-search.test.tsx | 197 +++ .../automations/use-automation-list-search.ts | 237 ++- .../automations/use-automation-list-view.ts | 98 -- ...use-automation-source-host-availability.ts | 166 ++ ...ternal-automation-scope-retention.test.tsx | 140 ++ ...use-external-automation-scope-retention.ts | 32 + .../use-scoped-external-automations.ts | 149 ++ ...e-selected-automation-run-history.test.tsx | 193 +++ .../use-selected-automation-run-history.ts | 111 ++ .../RepositoryHostSetupsSection.test.tsx | 1 + .../settings/ssh-target-save-payload.ts | 12 +- ...rktreeCardAutomationDetailSection.test.tsx | 126 ++ .../WorktreeCardAutomationDetailSection.tsx | 14 +- .../components/sidebar/WorktreeCardMeta.tsx | 2 - .../add-remote-host-ssh-actions.test.ts | 6 +- .../sidebar/add-remote-host-ssh-actions.ts | 5 +- .../sidebar/worktree-card-meta-types.ts | 2 - .../sidebar/worktree-card-presentation.tsx | 2 - .../sidebar/worktree-card-surface.tsx | 1 - src/renderer/src/components/ui/select.tsx | 16 + ...omations-changed-event-attribution.test.ts | 153 ++ ...direct-ssh-runtime-wake-isolation.test.tsx | 1 + ...vents-agent-status-window-test-fixtures.ts | 1 + .../ipc-events-close-routing-test-harness.ts | 1 + ...ts-terminal-create-window-test-fixtures.ts | 1 + .../ipc-events/app-lifetime-ipc-bridge.ts | 4 + .../ipc-events/runtime-client-ipc-bridge.ts | 10 + .../hooks/useAutomationDispatchEvents.test.ts | 24 +- .../src/hooks/useAutomationDispatchEvents.ts | 46 +- .../useIpcEvents-browser-tab-create.test.ts | 1 + ...eIpcEvents-cli-worktree-activation.test.ts | 2 + ...Events-close-routing-browser-pages.test.ts | 3 + .../src/hooks/useIpcEvents-lifecycle.test.ts | 5 +- .../useIpcEvents-rate-limit-hydration.test.ts | 1 + ...seIpcEvents-ssh-disconnect-cleanup.test.ts | 1 + .../hooks/useIpcEvents-updater-status.test.ts | 2 + .../hooks/useIpcEvents-zoom-routing.test.ts | 2 + src/renderer/src/i18n/locales/en.json | 110 +- src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + .../lib/automations-changed-window-event.ts | 24 + .../lib/editor-file-operation-owner.test.ts | 1 + .../src/lib/folder-workspace-connection.ts | 109 +- .../src/lib/ssh-mutation-expectation.test.ts | 1 + src/renderer/src/main.tsx | 2 + .../src/runtime/runtime-client-events.test.ts | 24 + .../src/runtime/runtime-client-events.ts | 1 + .../runtime/runtime-environment-ssh-state.ts | 26 +- .../src/runtime/runtime-rpc-result.ts | 52 +- .../runtime-environment-ssh-selectors.ts | 90 + .../slices/runtime-environment-ssh.test.ts | 80 + .../store/slices/runtime-environment-ssh.ts | 167 +- .../src/store/slices/ssh-target-cleanup.ts | 37 +- src/renderer/src/store/slices/ssh.test.ts | 82 + src/renderer/src/store/slices/ssh.ts | 26 +- src/renderer/src/store/slices/ui.ts | 18 + .../web-preference-normalization.ts | 1 + .../src/web/web-preload-api-ui.test.ts | 2 + .../automation-execution-target.test.ts | 195 +++ src/shared/automation-execution-target.ts | 136 ++ src/shared/automation-host-filter.test.ts | 84 + src/shared/automation-host-filter.ts | 52 + .../automation-legacy-list-partition.test.ts | 111 ++ .../automation-legacy-list-partition.ts | 90 + src/shared/automation-list-response.test.ts | 147 ++ src/shared/automation-list-response.ts | 177 ++ src/shared/automation-list-scope.test.ts | 296 ++++ src/shared/automation-list-scope.ts | 291 ++++ src/shared/automation-owner-conflict.ts | 88 + src/shared/automation-owner-key.test.ts | 141 ++ src/shared/automation-owner-key.ts | 149 ++ .../automation-owner-precondition.test.ts | 307 ++++ src/shared/automation-owner-precondition.ts | 167 ++ src/shared/automation-owner-ref.ts | 58 + src/shared/automation-usage-summary.test.ts | 81 + src/shared/automation-usage-summary.ts | 65 + src/shared/automation-workspace-pin.ts | 42 + src/shared/automation-workspace-repin.ts | 37 + src/shared/automations-types.ts | 10 + .../child-process-import-allowlist.txt | 1 - .../windows-console-visibility-allowlist.txt | 1 - src/shared/constants.ts | 2 + src/shared/external-automation-scope.ts | 155 ++ .../folder-workspace-execution-host.test.ts | 175 ++ src/shared/folder-workspace-execution-host.ts | 135 ++ src/shared/folder-workspaces.test.ts | 62 + src/shared/folder-workspaces.ts | 3 + src/shared/pairing-local-ui-fields.test.ts | 1 + src/shared/pairing-local-ui-fields.ts | 3 + src/shared/persisted-state-types.ts | 2 + src/shared/persisted-ui-state-types.ts | 3 + src/shared/protocol-version.ts | 16 +- .../remote-runtime-client-capabilities.ts | 2 + src/shared/runtime-client-events.ts | 14 + src/shared/runtime-rpc-error-code.ts | 66 + src/shared/ssh-target-generation.test.ts | 64 + src/shared/ssh-target-generation.ts | 57 + src/shared/ssh-types.ts | 10 +- .../helpers/startup-exec-readiness-oracle.ts | 31 +- tests/e2e/ssh-terminal-parking.spec.ts | 17 + 358 files changed, 37233 insertions(+), 3214 deletions(-) create mode 100644 docs/automations-all-hosts-design.md create mode 100644 src/cli/automation-destination.ts create mode 100644 src/cli/automation-format.test.ts create mode 100644 src/cli/automation-format.ts create mode 100644 src/cli/automation-owner-conflict-recovery.test.ts create mode 100644 src/cli/automation-owner-conflict-recovery.ts create mode 100644 src/cli/handlers/automation-destination-fencing.test.ts create mode 100644 src/cli/handlers/automation-owner-fencing.test.ts create mode 100644 src/main/automations/automation-dispatch-host-fence.test.ts create mode 100644 src/main/automations/automation-owner-fencing.test.ts create mode 100644 src/main/automations/automation-owner-migration.test.ts create mode 100644 src/main/automations/automation-owner-migration.ts create mode 100644 src/main/automations/automation-run-terminal-surface.test.ts create mode 100644 src/main/automations/automation-run-writer.test.ts create mode 100644 src/main/automations/automation-run-writer.ts create mode 100644 src/main/automations/automation-skip-coalescing.test.ts create mode 100644 src/main/automations/automation-ssh-readoption-migration.test.ts create mode 100644 src/main/automations/automation-ssh-readoption-migration.ts create mode 100644 src/main/automations/automation-update-host-retarget.test.ts create mode 100644 src/main/automations/automation-workspace-host-attribution.test.ts create mode 100644 src/main/automations/dispatch-refusal.ts create mode 100644 src/main/automations/external-automation-manager-cache.test.ts create mode 100644 src/main/automations/external-automation-manager-cache.ts create mode 100644 src/main/automations/external-automation-owner-guard.test.ts create mode 100644 src/main/automations/external-automation-owner-guard.ts create mode 100644 src/main/automations/external-automation-probe-scheduler.test.ts create mode 100644 src/main/automations/external-automation-probe-scheduler.ts create mode 100644 src/main/automations/external-manager-scoped.test.ts create mode 100644 src/main/automations/headless-dispatch-runner.ts create mode 100644 src/main/automations/refused-manual-run.test.ts create mode 100644 src/main/automations/refused-manual-run.ts create mode 100644 src/main/automations/retained-run-reconciliation.test.ts create mode 100644 src/main/automations/retained-run-reconciliation.ts create mode 100644 src/main/automations/run-completion-watcher.test.ts create mode 100644 src/main/automations/run-completion-watcher.ts create mode 100644 src/main/automations/runtime-terminal-run-observer.test.ts create mode 100644 src/main/automations/runtime-terminal-run-observer.ts create mode 100644 src/main/ipc/automations-external-scope.test.ts create mode 100644 src/main/persistence-ssh-readoption-automation-migration.test.ts create mode 100644 src/main/persistence-workspace-pinned-automation-fence.test.ts create mode 100644 src/main/persistence-workspace-repin-automation-owner.test.ts create mode 100644 src/main/persistence/scheduling-automations/automation-owner-projection.ts create mode 100644 src/main/runtime/automation-change-publication.test.ts create mode 100644 src/main/runtime/rpc/methods/automation-schemas.ts create mode 100644 src/main/runtime/rpc/methods/automation-scoped-list-methods.test.ts create mode 100644 src/main/runtime/rpc/methods/client-settings-schemas.ts create mode 100644 src/main/ssh/removed-ssh-target-tombstone-retention.test.ts create mode 100644 src/main/ssh/removed-ssh-target-tombstone-retention.ts create mode 100644 src/renderer/src/components/automations/AutomationCreateDestinationField.test.tsx create mode 100644 src/renderer/src/components/automations/AutomationCreateDestinationField.tsx create mode 100644 src/renderer/src/components/automations/AutomationDetail.test.tsx create mode 100644 src/renderer/src/components/automations/AutomationHostBadges.test.tsx create mode 100644 src/renderer/src/components/automations/AutomationHostBadges.tsx create mode 100644 src/renderer/src/components/automations/AutomationHostFilterNotice.test.tsx create mode 100644 src/renderer/src/components/automations/AutomationHostFilterNotice.tsx create mode 100644 src/renderer/src/components/automations/AutomationListEmptyView.test.tsx create mode 100644 src/renderer/src/components/automations/AutomationListEmptyView.tsx create mode 100644 src/renderer/src/components/automations/AutomationListHostGroups.test.tsx create mode 100644 src/renderer/src/components/automations/AutomationListHostGroups.tsx delete mode 100644 src/renderer/src/components/automations/AutomationListSortHeader.tsx delete mode 100644 src/renderer/src/components/automations/AutomationListToolbar.tsx create mode 100644 src/renderer/src/components/automations/AutomationOwnerConflictNotice.test.tsx create mode 100644 src/renderer/src/components/automations/AutomationOwnerConflictNotice.tsx create mode 100644 src/renderer/src/components/automations/AutomationRunHistory.test.tsx create mode 100644 src/renderer/src/components/automations/AutomationTemplateEmptyState.tsx create mode 100644 src/renderer/src/components/automations/AutomationsListPanel.test.tsx create mode 100644 src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx create mode 100644 src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx create mode 100644 src/renderer/src/components/automations/AutomationsPage.external-scope.test.tsx create mode 100644 src/renderer/src/components/automations/AutomationsPage.notice-recovery.test.tsx create mode 100644 src/renderer/src/components/automations/AutomationsPage.refresh-selection.test.tsx create mode 100644 src/renderer/src/components/automations/AutomationsPage.run-visibility.test.tsx create mode 100644 src/renderer/src/components/automations/AutomationsPage.save-visibility.test.tsx create mode 100644 src/renderer/src/components/automations/AutomationsPage.test.tsx create mode 100644 src/renderer/src/components/automations/automation-authority-identity.test.ts create mode 100644 src/renderer/src/components/automations/automation-authority-identity.ts create mode 100644 src/renderer/src/components/automations/automation-captured-owner.test.ts create mode 100644 src/renderer/src/components/automations/automation-captured-owner.ts create mode 100644 src/renderer/src/components/automations/automation-create-destination.test.ts create mode 100644 src/renderer/src/components/automations/automation-create-destination.ts create mode 100644 src/renderer/src/components/automations/automation-delete-confirm-preference.ts delete mode 100644 src/renderer/src/components/automations/automation-detail-selection.test.ts delete mode 100644 src/renderer/src/components/automations/automation-detail-selection.ts create mode 100644 src/renderer/src/components/automations/automation-edit-draft.ts create mode 100644 src/renderer/src/components/automations/automation-host-cache-controller.test.ts create mode 100644 src/renderer/src/components/automations/automation-host-cache-controller.ts create mode 100644 src/renderer/src/components/automations/automation-host-cache-error.ts create mode 100644 src/renderer/src/components/automations/automation-host-cache-health.test.ts create mode 100644 src/renderer/src/components/automations/automation-host-cache-health.ts create mode 100644 src/renderer/src/components/automations/automation-host-cache-rows.ts create mode 100644 src/renderer/src/components/automations/automation-host-cache-types.ts create mode 100644 src/renderer/src/components/automations/automation-host-cache.test.ts create mode 100644 src/renderer/src/components/automations/automation-host-cache.ts create mode 100644 src/renderer/src/components/automations/automation-host-catalog-generation.test.ts create mode 100644 src/renderer/src/components/automations/automation-host-catalog-generation.ts create mode 100644 src/renderer/src/components/automations/automation-host-catalog-order.test.ts create mode 100644 src/renderer/src/components/automations/automation-host-catalog-order.ts create mode 100644 src/renderer/src/components/automations/automation-host-catalog-source.test.ts create mode 100644 src/renderer/src/components/automations/automation-host-catalog-source.ts create mode 100644 src/renderer/src/components/automations/automation-host-catalog-types.ts create mode 100644 src/renderer/src/components/automations/automation-host-catalog.test.ts create mode 100644 src/renderer/src/components/automations/automation-host-catalog.ts create mode 100644 src/renderer/src/components/automations/automation-host-diagnostics.test.ts create mode 100644 src/renderer/src/components/automations/automation-host-diagnostics.ts create mode 100644 src/renderer/src/components/automations/automation-host-fetch-target.ts create mode 100644 src/renderer/src/components/automations/automation-host-filter-resolution.test.ts create mode 100644 src/renderer/src/components/automations/automation-host-filter-resolution.ts create mode 100644 src/renderer/src/components/automations/automation-host-health.test.ts create mode 100644 src/renderer/src/components/automations/automation-host-health.ts create mode 100644 src/renderer/src/components/automations/automation-host-invalidation-window-events.test.ts create mode 100644 src/renderer/src/components/automations/automation-host-invalidation-window-events.ts create mode 100644 src/renderer/src/components/automations/automation-host-invalidation.test.ts create mode 100644 src/renderer/src/components/automations/automation-host-invalidation.ts create mode 100644 src/renderer/src/components/automations/automation-host-list-rows.test.ts create mode 100644 src/renderer/src/components/automations/automation-host-list-rows.ts create mode 100644 src/renderer/src/components/automations/automation-host-orphan-entry.test.ts create mode 100644 src/renderer/src/components/automations/automation-host-picker-groups.test.ts create mode 100644 src/renderer/src/components/automations/automation-host-picker-groups.ts create mode 100644 src/renderer/src/components/automations/automation-host-recovery.test.ts create mode 100644 src/renderer/src/components/automations/automation-host-recovery.ts create mode 100644 src/renderer/src/components/automations/automation-host-request-instrumentation.ts create mode 100644 src/renderer/src/components/automations/automation-host-retry-schedule.ts create mode 100644 src/renderer/src/components/automations/automation-host-scale-gates.test.ts create mode 100644 src/renderer/src/components/automations/automation-host-scheduler-plan.ts create mode 100644 src/renderer/src/components/automations/automation-host-scheduler-queue.ts create mode 100644 src/renderer/src/components/automations/automation-host-scheduler.test.ts create mode 100644 src/renderer/src/components/automations/automation-host-scheduler.ts create mode 100644 src/renderer/src/components/automations/automation-host-scope-descriptors.ts create mode 100644 src/renderer/src/components/automations/automation-host-status-descriptors.test.ts create mode 100644 src/renderer/src/components/automations/automation-host-status-descriptors.ts create mode 100644 src/renderer/src/components/automations/automation-list-empty-state.test.ts create mode 100644 src/renderer/src/components/automations/automation-list-empty-state.ts create mode 100644 src/renderer/src/components/automations/automation-list-focus-recovery.test.ts create mode 100644 src/renderer/src/components/automations/automation-list-focus-recovery.ts create mode 100644 src/renderer/src/components/automations/automation-list-row-identity.test.ts create mode 100644 src/renderer/src/components/automations/automation-list-row-identity.ts create mode 100644 src/renderer/src/components/automations/automation-list-search-query.ts create mode 100644 src/renderer/src/components/automations/automation-list-search-rows.test.ts create mode 100644 src/renderer/src/components/automations/automation-list-search-rows.ts create mode 100644 src/renderer/src/components/automations/automation-notice-recovery-host.ts create mode 100644 src/renderer/src/components/automations/automation-owner-action-runner.ts create mode 100644 src/renderer/src/components/automations/automation-row-action-dispatch.ts delete mode 100644 src/renderer/src/components/automations/automation-run-completion-evidence.test.ts delete mode 100644 src/renderer/src/components/automations/automation-run-completion-evidence.ts create mode 100644 src/renderer/src/components/automations/automation-run-occurrences.ts create mode 100644 src/renderer/src/components/automations/automation-scoped-list-client.test.ts create mode 100644 src/renderer/src/components/automations/automation-scoped-list-client.ts create mode 100644 src/renderer/src/components/automations/automation-write-invalidation.test.ts create mode 100644 src/renderer/src/components/automations/automation-write-invalidation.ts create mode 100644 src/renderer/src/components/automations/automations-page-fixtures.ts create mode 100644 src/renderer/src/components/automations/automations-page-runtime-fixtures.ts create mode 100644 src/renderer/src/components/automations/automations-page-test-harness.tsx create mode 100644 src/renderer/src/components/automations/external-automation-display.test.ts create mode 100644 src/renderer/src/components/automations/external-automation-scope-client.ts create mode 100644 src/renderer/src/components/automations/external-automation-scope-gating.test.ts create mode 100644 src/renderer/src/components/automations/external-automation-scope-gating.ts create mode 100644 src/renderer/src/components/automations/external-automation-scope-keys.test.ts create mode 100644 src/renderer/src/components/automations/external-automation-scope-keys.ts create mode 100644 src/renderer/src/components/automations/external-automation-unchecked-hosts.ts create mode 100644 src/renderer/src/components/automations/use-automation-create-destination.ts create mode 100644 src/renderer/src/components/automations/use-automation-desktop-ssh-registrations.ts create mode 100644 src/renderer/src/components/automations/use-automation-host-catalog-readoption.test.tsx create mode 100644 src/renderer/src/components/automations/use-automation-host-catalog-source-state.ts create mode 100644 src/renderer/src/components/automations/use-automation-host-catalog.test.tsx create mode 100644 src/renderer/src/components/automations/use-automation-host-catalog.ts create mode 100644 src/renderer/src/components/automations/use-automation-list-focus-recovery.test.tsx create mode 100644 src/renderer/src/components/automations/use-automation-list-focus-recovery.ts create mode 100644 src/renderer/src/components/automations/use-automation-list-search.test.tsx delete mode 100644 src/renderer/src/components/automations/use-automation-list-view.ts create mode 100644 src/renderer/src/components/automations/use-automation-source-host-availability.ts create mode 100644 src/renderer/src/components/automations/use-external-automation-scope-retention.test.tsx create mode 100644 src/renderer/src/components/automations/use-external-automation-scope-retention.ts create mode 100644 src/renderer/src/components/automations/use-scoped-external-automations.ts create mode 100644 src/renderer/src/components/automations/use-selected-automation-run-history.test.tsx create mode 100644 src/renderer/src/components/automations/use-selected-automation-run-history.ts create mode 100644 src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.test.tsx create mode 100644 src/renderer/src/hooks/automations-changed-event-attribution.test.ts create mode 100644 src/renderer/src/lib/automations-changed-window-event.ts create mode 100644 src/renderer/src/store/slices/runtime-environment-ssh-selectors.ts create mode 100644 src/shared/automation-execution-target.test.ts create mode 100644 src/shared/automation-execution-target.ts create mode 100644 src/shared/automation-host-filter.test.ts create mode 100644 src/shared/automation-host-filter.ts create mode 100644 src/shared/automation-legacy-list-partition.test.ts create mode 100644 src/shared/automation-legacy-list-partition.ts create mode 100644 src/shared/automation-list-response.test.ts create mode 100644 src/shared/automation-list-response.ts create mode 100644 src/shared/automation-list-scope.test.ts create mode 100644 src/shared/automation-list-scope.ts create mode 100644 src/shared/automation-owner-conflict.ts create mode 100644 src/shared/automation-owner-key.test.ts create mode 100644 src/shared/automation-owner-key.ts create mode 100644 src/shared/automation-owner-precondition.test.ts create mode 100644 src/shared/automation-owner-precondition.ts create mode 100644 src/shared/automation-owner-ref.ts create mode 100644 src/shared/automation-usage-summary.test.ts create mode 100644 src/shared/automation-usage-summary.ts create mode 100644 src/shared/automation-workspace-pin.ts create mode 100644 src/shared/automation-workspace-repin.ts create mode 100644 src/shared/external-automation-scope.ts create mode 100644 src/shared/folder-workspace-execution-host.test.ts create mode 100644 src/shared/folder-workspace-execution-host.ts create mode 100644 src/shared/folder-workspaces.test.ts create mode 100644 src/shared/runtime-rpc-error-code.ts create mode 100644 src/shared/ssh-target-generation.test.ts create mode 100644 src/shared/ssh-target-generation.ts diff --git a/.gitignore b/.gitignore index 496d2b2f484..a46e4f060f8 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,7 @@ docs/** !docs/agent-skill-sharing-implementation-checklist.md !docs/agent-skill-sharing-installation-plan.md !docs/ai-vault-process-isolation-plan.md +!docs/automations-all-hosts-design.md !docs/mobile-terminal-shortcut-bar.md !docs/reference/ !docs/reference/git-compatibility.md diff --git a/config/localization-coverage-allowlist.json b/config/localization-coverage-allowlist.json index 1f445997bda..57694b2033f 100644 --- a/config/localization-coverage-allowlist.json +++ b/config/localization-coverage-allowlist.json @@ -1,4 +1,18 @@ [ + { + "filePath": "src/renderer/src/components/automations/automations-page-fixtures.ts", + "kind": "object-property:title", + "text": "Nightly #1", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/components/automations/automations-page-fixtures.ts", + "kind": "object-property:label", + "text": "Hermes", + "dynamic": false, + "count": 1 + }, { "filePath": "src/renderer/src/components/sidebar/worktree-card-meta-row.tsx", "kind": "jsx-attribute:label", diff --git a/docs/automations-all-hosts-design.md b/docs/automations-all-hosts-design.md new file mode 100644 index 00000000000..c186cc67c13 --- /dev/null +++ b/docs/automations-all-hosts-design.md @@ -0,0 +1,530 @@ +# All-host Automations + +## Status + +Proposed. The protocol, identity, and fencing decisions are settled here. The product decisions listed under `Deferred / Open Questions` at the end of this document are still open and need an owner before the first release. + +## Summary + +The Automations page is global, but its data is currently loaded from one active authority and several code paths later infer ownership from a bare automation, run, repo, workspace, or host ID. That is unsafe once records from multiple desktop and runtime stores appear together. + +This design introduces an authority-qualified automation catalog. Every row captures the exact desktop/runtime and self/SSH incarnation that produced it. Lists are cached per visible host, mutations and secondary reads are fenced against that captured incarnation, runtime version skew has an explicit fallback, and failures remain isolated to one authority. + +`All hosts` is a renderer aggregation mode, not a backend-wide query. Selecting one host performs no discovery or automation query against unrelated authorities. + +## Goals + +- Show Orca automations stored by the desktop and every saved remote Orca runtime, including each authority's user-visible SSH targets. +- Make host scope visible and persistent without treating an unhydrated catalog as removal. +- Prevent ID collisions, runtime re-pairing, SSH remove/re-add, and late responses from crossing ownership boundaries. +- Keep useful cached results visible when one authority is slow, offline, incompatible, or stale. +- Route list, edit, delete, pause/resume, Run Now, run history, usage, navigation, and workspace actions through the captured owner. +- Avoid startup fanout, remote-manager fanout, unbounded retries, and all-run history downloads. +- Bring every dispatched run to a terminal state without a client attached, including unselected, page-closed, and headless runs. +- Remain safe when desktop clients and runtime servers update independently. + +## Non-goals + +- A global server-side automation index. +- Persisting automation list results across app restarts. +- Moving an automation between desktop and runtime authorities. +- Showing external automation managers owned by remote runtimes in the first release. +- Changing mobile routes, mobile RPCs, pairing, E2EE, or relay framing. +- Changing shell, path, Git, or Git-provider behavior. + +## Required invariants + +1. Storage authority is not inferred from `runContext.hostId`, `schedulerOwner`, the active runtime, or the selected workspace. Those fields may mark a record ambiguous; they may never assign it to a different authority. +2. An ID is unique only inside its authority. Automation, run, repo, workspace, setup, navigation, dialog, availability, and in-flight action keys are authority-qualified. +3. A stable host selection survives label and connection-state changes, but data or actions captured from an old runtime/SSH incarnation do not. +4. Missing catalog data is not evidence of removal. Removal requires a hydrated authoritative catalog or a tombstone. +5. Local selection never contacts a runtime or probes desktop SSH external managers. One runtime selection never contacts another runtime. +6. Target execution health and authority query health are separate. A disconnected SSH target does not prevent its owning, reachable authority from listing or editing stored automations. +7. A parameterless `automation.list` keeps returning the authority's complete list for old clients. + +## Ownership and identity + +Use separate stable and incarnation-bearing forms: + +```ts +type AutomationAuthorityRef = + | { kind: 'desktop' } + | { + kind: 'runtime' + environmentId: string + pairingRevision: number + } + +type AutomationHostSelector = + | { kind: 'self' } + | { + kind: 'ssh' + targetId: string + targetGeneration: number + } + +type AutomationOwnerRef = { + authority: AutomationAuthorityRef + selector: AutomationHostSelector +} + +type StableAutomationHostRef = { + authority: { kind: 'desktop' } | { kind: 'runtime'; environmentId: string } + selector: { kind: 'self' } | { kind: 'ssh'; targetId: string } +} + +type StableAutomationCatalogRef = + | StableAutomationHostRef + | { + authority: { kind: 'desktop' } | { kind: 'runtime'; environmentId: string } + selector: { kind: 'orphan' } + } +``` + +`pairingRevision` is the saved runtime environment's existing pairing revision. `targetGeneration` is a durable SSH registration incarnation, not a connection attempt counter. Add an optional generation to stored SSH targets and `SshTargetSummary`; assign one while loading legacy targets and advance it only when a target is deleted/re-created or explicitly re-adopted. Connection, reconnect, and status transitions do not advance it. + +Allocate generations from a persisted monotonic counter owned by each automation authority. On load, the authority reloads that counter as a high-water mark — `max(persisted counter, highest generation on any stored target or automation) + 1` — so a counter lost to a rollback can never reissue a generation an automation already captured. The migration first scans stored automations: a referenced missing SSH target creates a bounded removal tombstone with its last known label before generations are assigned. That makes legacy ghosts discoverable from the existing mirrored `removedTargetLabels`, including on a newly paired client with no list cache. Runtime-owned migrations run on the runtime authority, not on the desktop copy of its catalog. + +New and migrated automations persist the selected SSH generation as an optional owner field. A legacy automation whose target still exists adopts the target's current generation during migration. A legacy automation whose target is absent becomes an orphan and is not silently assigned to Self or to a later same-ID target. + +### Orphan and ambiguous records + +An orphan has a known storage authority and no executable owner. An ambiguous record is a desktop-stored automation whose `schedulerOwner` or run context points at a runtime. Both are readable, and both follow the same rules: + +- The owning authority refuses to dispatch them. Its scheduler and `automation.runNow` return the typed `target_removed` conflict and record a skipped-run reason. Disabling actions in the client is presentation, not enforcement. +- Migration persists `enabled: false` for every record it classifies as orphaned or ambiguous, so a classified record cannot keep firing on a guessed host before its authority is upgraded. +- Run Now, edit, and workspace navigation are disabled. Pause/resume and Delete stay enabled: both need only the storage authority, which is known. +- Re-adoption is offered only when a compatible target is present under the same authority. When none is, Delete is the only remaining repair. Cross-authority moves stay unsupported. +- Orphan reads are fenced like owned reads. List requests, `automation.show`, and cache entries carry `{ kind: 'orphan' }` as the selector, and commit compares authority, catalog, and request generations only. A record moving into or out of orphan publishes one source and one destination event, or a single unscoped authority event when the old selector cannot be recovered. + +Two keys serve different purposes: + +- `hostStableKey(StableAutomationCatalogRef)` excludes incarnation fields and is used by the persisted filter and display slot. +- `ownerKey(AutomationOwnerRef)` includes `pairingRevision` and `targetGeneration` and is used by fetched rows, mutations, secondary reads, and request commit checks. + +Use one canonical encoder with explicit kind prefixes and encoded components; do not use display labels, bare IDs, or ad hoc `JSON.stringify` order as keys. + +```ts +type AutomationListRow = + | { + kind: 'owned' + key: string // ownerKey + automation.id + owner: AutomationOwnerRef + automation: Automation + usageSummary: AutomationUsageSummary | null + } + | { + kind: 'orphan' + key: string // authority stable key + automation.id + authority: AutomationAuthorityRef + automation: Automation + issue: string + } +``` + +Every owner-qualified navigation record includes the automation ID and, when applicable, the run ID, repo ID, workspace ID, and project-host-setup ID. Existing maps keyed only by repo/workspace ID cannot be used to resolve these rows because cross-host ID collisions are legal. + +Before any mutation or secondary read, resolve the current catalog entry for the stable key and compare its full owner to the captured owner. A mismatch fails closed with `This automation's host changed. Reload it before continuing.` The backend repeats this validation for capable runtimes; client-side checking alone is not sufficient. + +## Automation host catalog + +Create a dedicated automation host catalog. Do not overload the flat `ExecutionHostId`, because Runtime + SSH needs both a parent authority and a nested target. + +```ts +type AutomationHostCatalogEntry = { + stableRef: StableAutomationCatalogRef + owner: AutomationOwnerRef | null + stableKey: string + label: string + authorityLabel: string + kind: 'self' | 'ssh' | 'orphan' + catalogState: 'authoritative' | 'unhydrated' | 'removed' + authorityHealth: AutomationAuthorityHealth + executionHealth: AutomationExecutionHealth + querySupport: 'scoped' | 'legacy-unscoped' | 'incompatible' +} +``` + +Catalog projection rules: + +- Project Desktop + Self and desktop-owned saved SSH targets from the existing execution-host registry. +- Project Runtime + Self from saved runtime environments, even while a runtime is offline. +- Project Runtime + SSH from that environment's `sshStateByEnvironment` bucket. Combine parent runtime health/compatibility with nested SSH state; never copy nested targets into desktop SSH maps. That bucket gains a per-target registration-generation map alongside `targetLabels`, populated from the extended `SshTargetSummary` and cleared with the other bucket fields when the environment's SSH state goes stale. A runtime that does not advertise `automation.list-host-scope.v1` supplies no generations: its entries key on target ID alone and are view-only. +- Preserve labels from saved environments, `targetLabels`, and `removedTargetLabels` during outages. +- Hide runtime-owned ephemeral SSH targets under the existing visibility rule. +- Preserve a ghost/orphan entry when referenced by a stored automation, cached row, persisted filter, or removal/re-adoption tombstone. +- Do not initiate runtime connections, target-list calls, or SSH connections merely to render the picker. Runtime SSH discovery consumes already mirrored state and becomes authoritative only when `targetsHydrated` is true. + +Catalog generation is tracked per authority and advances whenever that authority's authoritative membership or incarnation changes. The commit fence compares only the generation of the authority that owns the entry, so one host's membership change — another runtime's target bucket hydrating, a target added anywhere — cannot discard every other host's in-flight response and exhaust the retry cap. Runtime connection status and SSH connection status update health without changing catalog generation. + +Deterministic order is: All hosts, Desktop + Self, desktop SSH targets by locale-aware label then target ID, the desktop orphan entry, runtime authorities by label then environment ID, and each runtime's SSH targets immediately after its Self entry by label then target ID followed by that authority's orphan entry. Omit empty orphan entries. Construct one collator and precomputed sort fields per catalog rebuild. + +## Persisted filter and hydration + +Persist this optional UI value: + +```ts +type AutomationHostFilter = + | { kind: 'all' } + | { kind: 'host'; host: StableAutomationCatalogRef } +``` + +Only the stable form is persisted. Restore it after the relevant catalogs settle: + +- Desktop + Self is immediately authoritative. +- Desktop SSH absence counts only after `sshTargetsHydrated`. +- Runtime Self absence counts only after the saved runtime catalog settles. +- Runtime SSH absence counts only after that runtime's target bucket hydrates or a removal tombstone provides positive evidence. + +While relevant state is unhydrated, retain the selection and show `Loading host…`; do not fall back or write `All hosts` over the saved value. Once positive removal evidence exists, keep an orphan choice if automations/tombstones still reference it; otherwise switch to All hosts and announce the change. + +A persisted orphan selection settles only after the owning authority has returned an authoritative `orphanCount` or an old-server unscoped list. If the authority is offline, retain the selection as unavailable rather than assuming the orphan was repaired. + +SSH re-adoption migrates automation owner generations, repo/workspace references, removal tombstones, and this persisted filter in one persistence transaction. Same-ID runtime re-pairing retains the display selection, but evicts old authority data and requires a fresh query before rows or actions return. + +## List contract and runtime compatibility + +Add two string capability constants to `src/shared/protocol-version.ts`: + +```ts +export const AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY = + 'automation.list-host-scope.v1' as const + +export const AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY = + 'automation.owner-fencing.v1' as const +``` + +The first capability guarantees scoped list parameters, runtime response validation fields, bounded usage summaries, and expected SSH-generation checking for list reads. The second covers mutation and secondary-read owner preconditions. + +Both constants must also be appended to the exported `RUNTIME_CAPABILITIES` array in the same file. `getStatus()` advertises only entries drawn from that array, so a declared-but-unregistered capability leaves every capable runtime looking legacy and makes the New/New matrix row unreachable. + +The exact `automation.list` request is: + +```ts +type AutomationListParams = + | undefined + | null + | { + selector?: + | { kind: 'self' } + | { kind: 'ssh'; targetId: string; expectedTargetGeneration: number } + | { kind: 'orphan' } + } +``` + +Omitted params, `null`, `{}`, or an omitted `selector` return the authority's complete automation list. This preserves old-client behavior. A supplied selector filters in the authority backend before serialization. Self means records explicitly stored with a self/local execution target; it does not mean “anything not recognized as SSH.” + +The response keeps the existing `automations: Automation[]` field so an old client can consume a new server. A capable server adds projection metadata; for a scoped request, `items` is required and has a validated one-to-one match with `automations`: + +```ts +type AutomationListResult = { + automations: Automation[] + items?: Array<{ + automationId: string + selector: + | { kind: 'self' } + | { kind: 'ssh'; targetId: string; targetGeneration: number } + | { kind: 'orphan'; issue: string } + usageSummary?: AutomationUsageSummary | null + }> + orphanCount?: number +} +``` + +The client runtime-validates rather than casts. It rejects malformed top-level responses, drops and records malformed individual rows, requires exactly one metadata item per scoped automation ID, verifies every returned selector matches the request, and never reclassifies a mismatched row into Self. A malformed or missing metadata item drops its paired automation from the committed rows and increments the invalid-row counter; the one-to-one requirement is evaluated over the surviving pairs, not over the raw `automations` array, so one bad row neither hides a whole host nor produces an unqualified row. Old clients ignore `items` and `orphanCount`; new clients talking to old servers use the legacy partition below. Any scoped response may report `orphanCount`; a positive count adds the authority's orphan catalog entry and queues one orphan-scope request. An orphan row has known storage authority but no executable owner, so it is readable and all actions are disabled. + +Routing is by authority: + +- Desktop uses local IPC with the same selector semantics and owner preconditions. +- A capable runtime gets one scoped request per requested host. +- An older runtime gets at most one parameterless `automation.list` request per authority per refresh cycle. Partition that result into all requested Self/SSH cache entries; never make the same full-list request once per selector. + +Legacy partitioning is explicit: + +- Self requires positive evidence: `executionTargetType` is `local` **and** the owning repo resolves with no connection ID. The desktop store writes the literal `local` whenever that repo lookup misses, and for repos hosted on a runtime, so a bare `local` value is not evidence on its own. +- A valid SSH record with a non-empty target ID goes to that target's entry, creating a ghost entry if necessary. Legacy SSH rows carry no generation: key them by target ID alone and keep them view-only until the authority advertises `automation.owner-fencing.v1`. +- Unknown execution types, missing SSH IDs, contradictory target fields, records whose owning repo no longer resolves, records whose `schedulerOwner` or `runContext.hostId` points at a runtime, and other malformed legacy records go to an `Unassigned legacy automations` orphan entry. +- `runContext.hostId` and `schedulerOwner` mark a record ambiguous. They never override storage ownership, never assign a record to a different authority, and never repair a malformed selector. +- The authority's create and update paths stop re-deriving `local` from a missed repo lookup. They preserve the stored selector and fail closed instead, so a deleted project cannot silently convert an SSH automation into a local one. + +The legacy response has no bounded usage projection. Do not compensate by downloading all run histories. Render neutral `Usage unavailable` copy until the selected automation's lazy history is loaded. + +If a runtime lacks owner fencing, all of its rows stay readable and none are mutable. Runtime + Self and Runtime + SSH are both view-only until the server advertises `automation.owner-fencing.v1`; show an Update server action rather than performing an unfenced Run Now, edit, or delete. Pairing-revision freshness authenticates the connection, not the record's selector, so it is not a substitute — a record whose target changed server-side since the last refresh would still run on a host the user never saw. This mirrors the fail-closed capability assertion the codebase already applies to remote file mutations. + +### Owner-fenced operation contract + +For capable runtimes, add this optional precondition to `automation.show`, `automation.runs`, `automation.update`, `automation.delete`, and `automation.runNow`; add `destination` to create and to updates that may move selectors: + +```ts +type AutomationOwnerPrecondition = { + selector: + | { kind: 'self' } + | { kind: 'ssh'; targetId: string; targetGeneration: number } +} + +type AutomationOwnedIdParams = { + id: string + expectedOwner?: AutomationOwnerPrecondition +} + +type AutomationCreateDestination = { + destination?: AutomationOwnerPrecondition +} +``` + +Concretely, show/delete/runNow use `{ id, expectedOwner? }`; runs uses `{ automationId?, expectedOwner? }`; update uses `{ id, updates, expectedOwner?, destination? }`; and create appends `destination?` to its existing fields. Pause and resume are `automation.update` payloads and carry the same `expectedOwner` precondition; if they ever land as dedicated RPCs they take it too, so scheduler state cannot be changed for the wrong incarnation while every neighbouring action is fenced. A capable new client always supplies the applicable fields. Parameterless old-client shapes remain valid. + +The authority is implicit in the local IPC endpoint or runtime RPC connection; it is never supplied as a caller-chosen environment ID inside the payload. Runtime calls also pass the captured `pairingRevision` to the existing renderer/main runtime-environment revision guard before transport dispatch and again before accepting the response. The runtime backend compares the expected selector with the stored automation's selector and current SSH registration generation in the same synchronous persistence operation that reads or mutates the record. Create validates the destination immediately before insert. Update validates both the stored source and requested destination before replacement. Validation resolves `destination.selector` against the authority's current saved target registry inside that same operation: Self is allowed, SSH requires a present target ID whose current registration generation matches, and orphan or unknown selectors are rejected with a structured error and no write. A destination is never accepted as a free-form value, so an automation cannot be attached to a ghost or future same-ID target and become runnable on the wrong host once a later registration satisfies the fence. + +The preconditions are optional on the wire so old clients can call new servers, but optional is not unenforced. A capable server rejects any mutation or execution request that omits `expectedOwner` when the stored automation carries an SSH selector with a generation, returning a typed upgrade-required conflict with no side effects. The precondition stays genuinely optional only for Self records and for legacy SSH rows that still have no generation, so unfenced mutation never becomes permanent server behavior for a caller that skips the field. A new client sends the fields whenever `automation.owner-fencing.v1` is advertised. A mismatch returns a typed conflict and performs no mutation or execution. Validation, not Zod stripping of unknown fields, defines this behavior. + +## Cache and request lifecycle + +Keep an in-memory entry per catalog stable key: + +```ts +type CacheEntry = { + data: AutomationListRow[] + fetchedAt: number | null + attempt: number + requestGeneration: number + catalogGeneration: number + request: Promise | null + error: AutomationHostQueryError | null +} +``` + +Errors use a typed code rather than copy matching: + +```ts +type AutomationHostQueryError = { + code: + | 'authority_unavailable' + | 'timeout' + | 'permission_denied' + | 'incompatible' + | 'invalid_response' + | 'owner_changed' + | 'target_removed' + | 'unknown' + message: string + retryable: boolean + retryAt: number | null +} +``` + +Policy: + +- TTL is 30 seconds. Fresh data is returned without a request; stale data remains visible during revalidation. +- A request captures stable key, full owner, request generation, the owning authority's catalog generation, and the authority connection generation. The authority connection generation is the saved runtime environment's current `pairingRevision` — the same value the existing renderer/main revision guard compares — and a fixed constant for the desktop authority. It is not a third counter. +- Refresh, mutation invalidation, authority re-pair, SSH re-adoption/removal, and entry eviction advance `requestGeneration` before starting replacement work. +- A response commits only when every captured generation still matches and no removal tombstone supersedes it. Otherwise it is discarded. +- Concurrent callers for the same owner share `request`. One authority-level legacy fallback request may fulfill several entries. +- A failed refresh retains successful data and records the error separately. Success clears `attempt` and `error`. +- Automatic transient retry uses full jitter with a 1-second base and 30-second cap, at most three attempts while the page remains visible. Permanent validation, permission, and incompatibility failures do not retry. Manual Retry bypasses cooldown and starts one new attempt. +- Authority calls use a four-request global pool. The selected host and Desktop + Self have priority. Obsolete queued work is cancelled when the filter/catalog changes; already-sent transport work may finish but must pass the commit fence. +- An All-hosts refresh never initiates an authority connection. It queries only authorities with an established connection; a saved-but-disconnected authority renders a compact status row with a Reconnect action and is fetched only after the user reconnects or selects that host directly. Reconnect triggers one prioritized refresh for stale entries owned by that authority. Nested SSH disconnection does not block a list query when its authority is reachable. +- Manual All-host refresh bypasses TTL for reachable authorities only. Unreachable authorities retain stale data and expose Reconnect; the refresh does not enqueue requests already known to fail. +- Focus/visibility refreshes only stale entries and is coalesced. No fixed polling interval is added. +- Removed non-visible entries are evicted after request invalidation. Retired cache entries are LRU-capped at 256; visible catalog entries and active requests are not retained in that retired pool. + +Mutation invalidation happens before the request is sent, so an older in-flight list cannot overwrite the mutation result. Create/update/delete/pause/resume/Run Now invalidate only affected entries. An update may move an automation between Self and SSH selectors inside one authority: remove it optimistically from the source, invalidate both source and destination, and reconcile both responses. Cross-authority movement is rejected. + +## Editing, creation, and actions + +Edit hydration, conflict checks, save, delete, pause/resume, Run Now, and lazy history all receive the selected row's `AutomationOwnerRef`. They must not call the desktop store or active runtime as a fallback. + +The editor resolves project, repo, workspace, folder-workspace, and project-host-setup options within the captured authority. Those option identities are authority-qualified before entering maps. Selecting another project may move between Self and SSH selectors owned by the same authority; the save request includes both expected source owner and destination owner. It cannot move between desktop and runtime authorities. + +Creation rules: + +- A single concrete host filter preselects and constrains the destination. Orphan entries cannot create. +- Under All hosts, default from the active workspace's qualified owner only when it resolves to a catalog entry with a non-null executable owner, and show that owner explicitly before submit. When that owner is missing, unhydrated, orphaned, or not uniquely resolvable, require an explicit host choice before submit; never fall back to the active runtime or to a bare workspace or repo ID. +- The create request captures and validates the destination incarnation immediately before submit. +- If a concurrent catalog change makes the destination stale, fail closed and preserve the form. +- After success, select the created row. If it is unexpectedly outside the current filter, intentionally switch the filter to its destination and announce that change; never let a successful creation silently disappear. + +Dialogs keep their captured owner for their entire lifetime. An authority/target incarnation change disables submit and presents Reload; it never silently retargets an open form. + +## Run history, usage, and completion + +Run history stays lazy and authority-qualified: fetch only the selected automation's runs, page/cap them under the existing retention rules, and key selection/navigation by owner + automation ID + run ID. + +The list must not fetch all runs to build usage. Capable list projections include the existing aggregate fields (`knownRuns`, `unavailableRuns`, token totals, and estimated cost) computed by the authority while it already owns bounded retained runs. Legacy rows show neutral unavailable usage until their selected history is present. + +Move dispatched-run completion and usage reconciliation out of `AutomationsPage` rendering into the authority-owned automation service/dispatcher. The desktop service and each runtime service observe their own dispatched sessions, persist terminal status exactly once, and publish change events whether or not any client has the Automations page open. On authority startup, reconcile retained non-terminal runs against owned execution/session state. This prevents unselected or headless runs remaining stuck in `dispatched` and avoids client fanout. + +## External automation managers + +The first release includes external managers only for Desktop + Self and desktop-owned SSH hosts. Runtime + Self and Runtime + SSH show Orca automations only. This is an explicit scope boundary, not an unknown-source fallback. On a runtime-owned host, both the empty and the populated state say so: the absence of manager rows there is a stated scope boundary and is never presented as "none configured". + +Replace the broad desktop `listExternalAutomationManagers()` page call with scoped IPC operations that accept the captured desktop `AutomationOwnerRef` and provider. Every scoped call — list, runs, create, update, and action — re-applies the two checks the broad call enforced, before contacting a relay or launching a provider command: the runtime-owned-target exclusion that keeps hidden targets out of user-facing surfaces, and the same saved-owner comparison used for Orca mutations, including `targetGeneration` against the current SSH registration. A mismatch fails closed with the host-changed conflict and performs no probe. Selecting Local probes only local managers. Selecting one desktop SSH host probes only that target. All hosts schedules `{host, provider}` calls through the same bounded pool at lower priority than Orca automation list and mutation traffic for the selected host and Desktop + Self, cancels them when the filter leaves desktop scope, and does not eagerly probe every SSH target from a Local view. Manager work counts toward the release profiling in-flight assertion. + +Manager, job, run, dialog, and action keys include owner + provider + provider ID. Cache and error state are per `{owner, provider}`, separate from Orca automation storage health. An external-manager failure cannot mark the host's Orca store unavailable. + +Adding runtime-owned external managers later requires dedicated runtime list/runs/create/update/action RPCs, authority-qualified target types, capabilities, validation, and old-server UX. It must not be implemented by tunneling a desktop-only target or treating unknown authority as Local. + +## Security and trust boundaries + +Runtime authentication and authorization remain those of the existing paired RPC connection. The server derives authority from that connection, validates selectors against its saved target registry, and treats target IDs only as identifiers; no selector is interpolated into a path or shell command. Owner conflicts, permission errors, and invalid schemas return structured errors without record or prompt contents. Response validation and telemetry never log prompts, precheck commands, run output, credentials, or external-manager payloads. + +The renderer cannot bypass fencing by supplying a different authority or generation. Local IPC performs the same saved-owner comparison, and runtime RPC handlers compare against server-owned state. External-manager scoped IPC verifies the requested desktop target and provider allowlist before contacting a relay or launching a provider command. + +## Change events and invalidation + +Add an `automationsChanged` event to the existing local event channel and runtime client event stream: + +```ts +type AutomationsChangedEvent = { + type: 'automationsChanged' + selector?: { kind: 'self' } | { kind: 'ssh'; targetId: string } | { kind: 'orphan' } + reason?: 'definition' | 'run' | 'usage' +} +``` + +CRUD, scheduler transitions, run creation/status changes, and usage updates publish after persistence succeeds. Runtime authority is derived from the subscription environment and its current pairing revision; it is never accepted from the event body. + +A scoped event invalidates its one stable entry. An older or unscoped event invalidates all entries for that one authority only. Coalesce event bursts in one microtask and share any legacy authority request. Reconnect, focus, and TTL revalidation remain fallback paths for older servers that publish no event. + +An update that moves selectors publishes one event for the source and one for the destination; if the old selector cannot be recovered, publish one unscoped authority event. Subscribers treat duplicate events as harmless invalidations. + +The event is additive. Old clients must continue to ignore the unknown event type. Do not introduce a new stream opcode; if transport framing ever requires one, capability-negotiate it because old decoders may silently drop unknown opcodes. + +## User experience + +The host picker and search field remain visible in loading, empty, no-match, and failure states. Use the existing Select primitive for eight or fewer entries and the existing searchable Command/Popover for nine or more. Both expose the label `Filter by host`; the searchable variant focuses search on open, Enter selects, and Esc closes without changing selection. + +Every row shows a host badge rendered with the existing `RepoBadgeLabel` component already used for host and repo labels elsewhere in the automations UI. The pill `Badge` component has no `muted` variant, and this design does not add one. Badge truncation retains the full accessible name and tooltip. Search runs after host selection and matches name, project, workspace, agent, host label, and at most the first 2,048 prompt characters. Build the normalized search index once per changed row set, not during each render or comparator call. + +Keep these states distinct: + +- Authority query: loading, fresh, refreshing, stale-error, unavailable, incompatible. +- Execution target: connected, connecting, disconnected, unavailable, unknown. + +Under All hosts, rows are grouped by host in the same deterministic catalog order the picker uses, with each host's compact host-level status row anchored at the top of its group, so incomplete authorities do not reflow the list as responses arrive. Healthy and stale rows render inside their group as they arrive. A stale row remains readable. Each persistent failure supplies the relevant Retry, Reconnect, or Update server action; do not rely on a toast for recoverable errors. Run Now is disabled when execution health is insufficient, with a concrete reason, while storage-only actions follow authority and fencing availability. + +Use a polite, deduplicated `aria-live` summary for partial failures, worded ` of hosts could not be loaded`. Re-announce only when the failed-host count changes, not on every retry attempt. Do not move focus when status rows appear. If filtering or refresh removes the focused row, move focus to the next row, then previous row, then the picker; changing selection must not open a detail or fetch history until the replacement row is rendered. + +Empty copy distinguishes `No automations on `, `No automations across loaded hosts`, `No automations match your search`, and `Automations could not be loaded from `. Copy never claims a disconnected or unhydrated host is empty. On a runtime-owned host it also states that external automation managers are not listed for that host in this release, so a scope-limited host is never presented as clean. + +## Performance and resource budgets + +- No automation or external-manager network work is added to app startup or picker render. +- Selected-host first usable rows require at most one authority request; old-server All hosts requires at most one unscoped list request per authority. +- Concurrency is four remote requests, retries are capped, event bursts are coalesced, and timers/listeners are disposed when the page/controller closes. +- Prompt indexing is bounded per row. Retired cache history is capped. Tombstone history is capped per authority, but a tombstone still referenced by a stored automation, cached row, or persisted filter is retained past the cap: a tombstone becomes evictable only after its owning authority has returned an authoritative catalog with no remaining reference to that target. Positive removal evidence is never discarded while something still depends on it. Run lists continue to use existing retention/pagination caps. +- Cache instrumentation records request counts by authority/stable key, in-flight dedupe hits, discarded stale responses, result row counts/bytes, and refresh duration. It does not log prompts or run output. +- Release profiling covers 1,000 automations across 50 hosts, one offline authority, one old runtime, rapid filter changes, and an event burst. Acceptance: no more than four remote calls in flight, one legacy call per authority per cycle, no stale commit, and no synchronous long task over 50 ms attributable to filter/search on release hardware. + +## Migration and mixed-version behavior + +Persisted additions are optional so rollback builds ignore them: + +- SSH target registration generation. +- Automation SSH owner generation. +- Persisted stable automation host filter. + +Migration is idempotent and preserves unknown fields. It assigns generations only with positive current-target evidence, preserves missing references as ghosts, and never rewrites an orphan to Self. + +Supported rollback fixtures must prove the previous desktop/runtime builds preserve the optional generation fields on read/write. If a rollback-era state has lost those fields and a same-ID SSH replacement cannot be distinguished from the prior target, re-upgrade classifies the automation as ambiguous and requires explicit re-adoption; it does not guess and run on the replacement host. + +Existing desktop-stored records whose `schedulerOwner` or run context points at a runtime need explicit classification. Physical storage remains the authority unless a separate verified migration proves the runtime holds the canonical record. The first release does not delete or automatically transfer such records. Ambiguous records appear in the legacy/orphan entry with actions disabled and recovery guidance, preventing duplicate scheduling or destructive guessing. + +Desktop/runtime version matrix: + + +| Desktop | Runtime | Behavior | +| -------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Old | New | Parameterless list still returns the complete authority list; new optional fields/events are ignored. | +| New | Old | One unscoped list per authority, defensive partition, neutral usage, all runtime rows view-only without owner fencing. Runs dispatched by that server are reconciled only by it, so they stay `dispatched` until it updates; the row surfaces Update server rather than hanging with no explanation. | +| New | New | Scoped lists, bounded usage summaries, owner-fenced actions, scoped invalidation. | +| New before/after same-ID re-pair | Any | Old cache/actions are evicted by pairing revision; stable display selection may remain. | + + +No mobile-facing route, schema, RPC, handshake, protocol minimum, recommended app version, pairing file, E2EE, or relay surface changes. Automation RPC/event additions remain runtime-scoped, additive, and optional. No platform-specific path, shell, keyboard, native-module, filesystem, or Git behavior is introduced; the design applies equally on macOS, Windows, Linux, WSL, SSH, relay, git worktrees, and folder workspaces. + +## Implementation sequence + +1. Add canonical stable/owner refs, SSH registration generation persistence/migration, qualified row/run/navigation keys, and owner comparison helpers. +2. Build the dedicated catalog and persisted-filter hydration/re-adoption behavior. +3. Add local scoped IPC, runtime list capability/schema/validation, legacy authority partitioning, and owner-fenced mutation/secondary-read contracts. +4. Add the generation-fenced cache, bounded scheduler, invalidation events, retry policy, and instrumentation. +5. Move run completion/usage reconciliation into authority-owned services and add list usage projections. This step fixes a defect every user hits today — runs stuck in `dispatched` whenever the page is closed — and depends on nothing in steps 1-4, so it can land on its own schedule rather than waiting on the multi-host work. +6. Convert edit/create/actions/dialogs/project/workspace resolution to captured owners. +7. Add picker, badges, search index, partial states, recovery actions, focus, and announcements using the style guide and existing primitives/tokens. +8. Replace broad external-manager discovery with desktop-only scoped provider calls. +9. Remove the active-runtime inference and broad all-run fetch paths only after the new tests pass. + +Each step lands with its compatibility tests; do not ship a UI that merges authorities before mutation and navigation identity are converted. + +## Test plan + +### Identity, catalog, and persistence + +- Equal automation/run/repo/workspace IDs under two authorities remain distinct through selection, edit, navigation, and React keys. +- Equal SSH target IDs under Desktop and a runtime do not collide. +- Same-ID runtime re-pair and SSH remove/re-add discard stale rows, requests, dialogs, and actions. +- Unhydrated absence retains the persisted selection; authoritative removal falls back or preserves a ghost as specified. +- Rename preserves stable selection and cache slot; re-adoption migrates automations and filter atomically. +- Ephemeral runtime SSH targets stay hidden; parent outage preserves nested labels without claiming removal. + +### Query, cache, and performance + +- Local selection makes zero runtime and SSH-manager calls. +- Runtime selection contacts only that runtime; nested SSH disconnection does not block authority listing. +- Capable All hosts makes scoped calls with at most four remote requests in flight. +- Old-server All hosts makes exactly one unscoped request per authority and partitions Self, SSH, and orphan records correctly. +- Fresh/stale/error behavior, in-flight dedupe, capped retry, reconnect refresh, queue cancellation, and LRU eviction are deterministic with fake time. +- Late responses after refresh, mutation, removal, re-adoption, re-pair, and catalog replacement cannot commit. +- Search indexing is bounded and the 1,000-row/50-host profiling fixture meets the stated budgets. + +### Actions, create, and edit + +- Show, edit hydration/conflict/save, delete, pause/resume, Run Now, runs, usage, and open-workspace route through captured owners. +- Incarnation changes while a dialog is open fail closed and preserve input. +- In-authority selector movement invalidates/removes source and destination correctly; cross-authority movement is rejected. +- Single-host create is constrained; All hosts defaults visibly from the active qualified workspace; post-create selection never disappears silently. +- Folder workspaces and git worktrees both resolve inside the captured authority. +- An enabled automation whose SSH target was deleted produces no run on any host: its authority refuses the dispatch, records a skipped-run reason, and the migration left the record disabled. +- An automation whose owning repo was deleted, and a desktop-stored record scheduled against a runtime, both land in the orphan entry rather than under Desktop + Self. +- Delete and pause remain available on orphan and ambiguous rows; Run Now, edit, and workspace navigation do not. + +### Runs and events + +- List rendering fetches no broad run history. +- Selected history is lazy and authority-qualified; legacy usage copy is neutral. +- Unselected, page-closed, remote, restarted, and headless dispatched runs reach a terminal state through authority-owned reconciliation on capable authorities. Against an older runtime the run stays `dispatched` and surfaces Update server instead of hanging with no explanation. +- CRUD/scheduler/run/usage events invalidate one selector; old unscoped events invalidate one authority; bursts coalesce. +- Old clients ignore the new event and still use parameterless list behavior. + +### Failures, accessibility, and compatibility + +- One timeout, malformed response, permission error, offline authority, or incompatible runtime does not hide other hosts. +- Stale data remains readable and persistent errors expose working Retry/Reconnect/Update actions. +- Picker/search remain present in every empty/error state; full badge labels, keyboard behavior, focus recovery, and deduplicated `aria-live` output pass accessibility tests. +- New/old desktop-runtime matrix tests cover omitted/null/empty/scoped list params and unknown optional fields. +- Persisted migration/rollback fixtures cover missing generations, orphan targets, same-ID replacement, and ambiguous desktop-stored remote-scheduled records. +- No mobile legacy fixture, protocol minimum, or mobile package changes; Windows/Linux/macOS static review finds no new platform-specific behavior. + +## Release gates + +- All unit, integration, and RPC compatibility tests above pass. +- Real desktop + current runtime, desktop + previous runtime, SSH, runtime-owned SSH, runtime re-pair, SSH re-adoption, folder workspace, and offline/reconnect smoke tests pass. +- The request-count and stale-response assertions pass under rapid interaction and network failure. +- There is no unresolved path that infers authority from a bare ID, active runtime, `runContext.hostId`, or `schedulerOwner`. +- External managers remain desktop-scoped unless their full runtime RPC design lands separately. + +## Existing code that must change + +- `src/main/runtime/rpc/methods/automations.ts`: `automation.list` is currently parameterless. +- `src/renderer/src/components/automations/automation-host-client.ts`: ownership currently collapses to local vs environment and can fall back from `runContext.hostId`. +- `src/renderer/src/components/automations/AutomationsPage.tsx`: refresh currently fetches broad runs, completion reconciliation is page-owned, and edit/refresh paths use the wrong ambient target. +- `src/main/persistence.ts`: update can change execution target and SSH re-adoption does not migrate automations/filter state. Create and update also derive `executionTargetType` from the owning repo, writing the literal `local` whenever that lookup misses. +- `src/main/ipc/automations.ts`: `automations:list` takes no selector, and `automations:markDispatchResult` is driven by the renderer. +- `src/main/automations/service.ts`: only the headless dispatch path reconciles completion; renderer-dispatched runs depend on the page being open. +- `src/renderer/src/store/worktree-repo-index.ts`: bare-ID maps cannot resolve cross-authority collisions. +- `src/renderer/src/store/slices/runtime-environment-ssh.ts`: runtime SSH state is correctly separate and supplies the basis for nested catalog projection, but connection generation is not the durable target registration generation required here. +- `src/main/automations/external-manager.ts`: manager listing currently probes all desktop SSH targets. +- `src/shared/automations-types.ts`: external targets and existing automation owner data lack authority/incarnation qualification. +- `src/shared/runtime-client-events.ts`: no automation invalidation event exists. +- `src/shared/protocol-version.ts`: runtime capabilities are string constants and need the new additive entries. diff --git a/src/cli/automation-destination.ts b/src/cli/automation-destination.ts new file mode 100644 index 00000000000..18a9b435594 --- /dev/null +++ b/src/cli/automation-destination.ts @@ -0,0 +1,77 @@ +/** + * Where a CLI create — or an edit that moves the record's project/workspace — + * wants the automation to land. + * + * The CLI cannot project a host itself, so it asks the same authority that will + * perform the write to resolve the selector it was given and to name its + * current SSH registrations. That captured incarnation rides along on the + * request, and the authority re-checks it inside the write, so a target that + * disappears in between fails the write instead of silently repointing the + * record at a dead or re-registered host. + */ + +import { + AUTOMATION_OWNER_CONFLICT_CODES, + AutomationOwnerConflictError +} from '../shared/automation-owner-conflict' +import type { AutomationDestination } from '../shared/automation-owner-precondition' +import type { RuntimeWorktreeRecord } from '../shared/runtime-types' +import type { SshTargetSummary } from '../shared/ssh-types' +import type { Repo } from '../shared/repo-types' +import type { RuntimeClient } from './runtime-client' + +/** The project/workspace selectors the same request carries; both empty means the host cannot change. */ +export type AutomationDestinationTarget = { repo?: string; workspace?: string } + +async function resolveTargetRepo( + client: RuntimeClient, + target: AutomationDestinationTarget +): Promise { + if (target.repo) { + return (await client.call<{ repo: Repo }>('repo.show', { repo: target.repo })).result.repo + } + const worktree = ( + await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.show', { + worktree: target.workspace + }) + ).result.worktree + return (await client.call<{ repo: Repo }>('repo.show', { repo: `id:${worktree.repoId}` })).result + .repo +} + +/** + * `undefined` means the authority registers the target but assigns no + * generation, which is a host with nothing to fence — not a host to fail + * against. A target the authority does not register at all is positive + * evidence that the destination is gone. + */ +async function sshTargetGeneration( + client: RuntimeClient, + targetId: string +): Promise { + const targets = (await client.call<{ targets: SshTargetSummary[] }>('ssh.listTargetSummaries')) + .result.targets + const match = targets.find((candidate) => candidate.id === targetId) + if (!match) { + throw new AutomationOwnerConflictError(AUTOMATION_OWNER_CONFLICT_CODES.invalidDestination) + } + return match.generation +} + +export async function resolveAutomationDestination( + client: RuntimeClient, + target: AutomationDestinationTarget +): Promise { + if (!target.repo && !target.workspace) { + return undefined + } + const repo = await resolveTargetRepo(client, target) + const connectionId = repo.connectionId?.trim() + if (!connectionId) { + return { selector: { kind: 'self' } } + } + const generation = await sshTargetGeneration(client, connectionId) + return generation === undefined + ? undefined + : { selector: { kind: 'ssh', targetId: connectionId, targetGeneration: generation } } +} diff --git a/src/cli/automation-format.test.ts b/src/cli/automation-format.test.ts new file mode 100644 index 00000000000..18fcf0e3844 --- /dev/null +++ b/src/cli/automation-format.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import { AUTOMATION_ORPHAN_ISSUES } from '../shared/automation-list-scope' +import type { Automation } from '../shared/automations-types' +import { formatAutomationList, formatAutomationShow } from './automation-format' + +function automation(overrides: Partial = {}): Automation { + return { + id: 'auto-1', + name: 'Nightly', + prompt: 'Run checks', + precheck: null, + agentId: 'codex', + projectId: 'repo-legacy', + executionTargetType: 'ssh', + executionTargetId: 'box-1', + schedulerOwner: 'local_host_service', + workspaceMode: 'new_per_run', + workspaceId: null, + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: 0, + enabled: true, + nextRunAt: 0, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 0, + updatedAt: 0, + ...overrides + } +} + +// `orca automations run` sends users to `orca automations show` to find out where +// an automation lives. The stored `executionTarget*` fields survive their host's +// removal untouched, so printing only those renders a dead automation byte-for-byte +// like a healthy one and the named recovery step cannot be completed. +describe('formatAutomationShow reports the host the authority projects', () => { + it('distinguishes a live SSH host from a replaced one by its incarnation', () => { + const live = formatAutomationShow({ + automation: automation(), + owner: { selector: { kind: 'ssh', targetId: 'box-1', targetGeneration: 4 } } + }) + const replaced = formatAutomationShow({ + automation: automation(), + owner: { selector: { kind: 'ssh', targetId: 'box-1', targetGeneration: 9 } } + }) + + expect(live).toContain('host: ssh:box-1 (generation 4)') + expect(replaced).toContain('host: ssh:box-1 (generation 9)') + expect(live).not.toBe(replaced) + }) + + it('says outright that an orphan has no host, rather than naming its dead target', () => { + const output = formatAutomationShow({ + automation: automation(), + owner: { selector: { kind: 'orphan' } } + }) + + expect(output).toContain('host: orphan (no host can run this automation)') + // The stored pin stays visible; it is the record's history, not its current owner. + expect(output).toContain('target: ssh:box-1') + }) + + it('names the local host for a self-owned automation', () => { + const output = formatAutomationShow({ + automation: automation({ executionTargetType: 'local', executionTargetId: 'local' }), + owner: { selector: { kind: 'self' } } + }) + + expect(output).toContain('host: self') + }) + + // An older authority projects no owner; inventing one from the stored fields would + // report a removed host as healthy. + it('omits the host line when the authority reports no owner', () => { + const output = formatAutomationShow({ automation: automation() }) + + expect(output).not.toContain('host:') + expect(output).toContain('target: ssh:box-1') + }) +}) + +describe('formatAutomationList reports each row host', () => { + it('names the orphan issue so the list explains why a row cannot run', () => { + const output = formatAutomationList({ + automations: [automation(), automation({ id: 'auto-2', name: 'Weekly' })], + items: [ + { + automationId: 'auto-1', + selector: { kind: 'ssh', targetId: 'box-1', targetGeneration: 4 } + }, + { + automationId: 'auto-2', + selector: { kind: 'orphan', issue: AUTOMATION_ORPHAN_ISSUES.targetMissing } + } + ] + }) + + expect(output).toContain('host: ssh:box-1 (generation 4)') + expect(output).toContain(`host: orphan — ${AUTOMATION_ORPHAN_ISSUES.targetMissing}`) + }) + + it('omits the host line for rows an older authority does not qualify', () => { + const output = formatAutomationList({ automations: [automation()] }) + + expect(output).not.toContain('host:') + }) +}) diff --git a/src/cli/automation-format.ts b/src/cli/automation-format.ts new file mode 100644 index 00000000000..3b7669b014b --- /dev/null +++ b/src/cli/automation-format.ts @@ -0,0 +1,146 @@ +/** + * Human-readable automation output for the CLI. + * + * The host lines come from the authority's own projection, never from the + * stored `executionTarget*` fields: a target that was removed — or removed and + * re-registered under the same id — leaves those fields untouched, so printing + * them alone would render a dead host identically to a healthy one and leave + * `orca automations show` unable to complete the recovery it is named in. + */ + +import type { Automation, AutomationRun } from '../shared/automations-types' +import type { AutomationListItem } from '../shared/automation-list-scope' +import type { AutomationOwnerPrecondition } from '../shared/automation-owner-precondition' +import { getAutomationLegacyRepoId } from '../shared/automation-run-identity' +import { formatAutomationPrecheckTimeout } from '../shared/automation-precheck' +import { formatAutomationSchedule } from '../shared/automation-schedules' + +export type AutomationListPayload = { + automations: Automation[] + /** Present only when the authority qualifies its rows; an older host sends none. */ + items?: AutomationListItem[] +} + +export type AutomationShowPayload = { + automation: Automation + /** Present only when the authority can project an owner; an older host sends none. */ + owner?: AutomationOwnerPrecondition +} + +const ORPHAN_HOST_LABEL = 'orphan (no host can run this automation)' + +function formatOwnerSelector(selector: AutomationOwnerPrecondition['selector']): string { + if (selector.kind === 'ssh') { + return `ssh:${selector.targetId} (generation ${selector.targetGeneration})` + } + return selector.kind === 'orphan' ? ORPHAN_HOST_LABEL : 'self' +} + +function formatListItemSelector(item: AutomationListItem): string { + const selector = item.selector + if (selector.kind === 'ssh') { + return `ssh:${selector.targetId} (generation ${selector.targetGeneration})` + } + return selector.kind === 'orphan' ? `orphan — ${selector.issue}` : 'self' +} + +export function formatAutomationList(result: AutomationListPayload): string { + if (result.automations.length === 0) { + return 'No automations found.' + } + const hosts = new Map(result.items?.map((item) => [item.automationId, item]) ?? []) + return result.automations + .map((automation) => { + const status = automation.enabled ? 'enabled' : 'disabled' + const item = hosts.get(automation.id) + const host = item ? `\nhost: ${formatListItemSelector(item)}` : '' + return `${automation.id} ${automation.name} ${automation.agentId} ${status}\n${formatAutomationSchedule(automation.rrule)} next: ${new Date(automation.nextRunAt).toISOString()}${host}` + }) + .join('\n\n') +} + +export function formatAutomationShow(result: AutomationShowPayload): string { + const automation = result.automation + const runContext = automation.runContext ?? null + const projectLines = runContext + ? [ + `runProjectId: ${runContext.projectId}`, + `runHostId: ${runContext.hostId}`, + `projectHostSetupId: ${runContext.projectHostSetupId}`, + `runRepoId: ${runContext.repoId}`, + `runPath: ${runContext.path}`, + `legacyRepoId: ${getAutomationLegacyRepoId(automation)}` + ] + : [`legacyRepoId: ${getAutomationLegacyRepoId(automation)}`] + return [ + `id: ${automation.id}`, + `name: ${automation.name}`, + `provider: ${automation.agentId}`, + `enabled: ${automation.enabled}`, + `schedule: ${formatAutomationSchedule(automation.rrule)}`, + `rrule: ${automation.rrule}`, + `precheck: ${ + automation.precheck + ? `${automation.precheck.command} (timeout ${formatAutomationPrecheckTimeout( + automation.precheck.timeoutSeconds + )})` + : 'none' + }`, + `nextRunAt: ${new Date(automation.nextRunAt).toISOString()}`, + ...projectLines, + `workspaceMode: ${automation.workspaceMode}`, + `workspaceId: ${automation.workspaceId ?? 'null'}`, + `baseBranch: ${automation.baseBranch ?? 'null'}`, + `reuseSession: ${automation.reuseSession}`, + `target: ${automation.executionTargetType}:${automation.executionTargetId}`, + // Omitted rather than guessed: a host that reports no owner has none to report. + ...(result.owner ? [`host: ${formatOwnerSelector(result.owner.selector)}`] : []), + `prompt: ${automation.prompt}` + ].join('\n') +} + +export function formatAutomationRemoved(result: { removed: boolean; id: string }): string { + return result.removed + ? `Removed automation ${result.id}.` + : `Automation ${result.id} not removed.` +} + +export function formatAutomationRun(result: { run: AutomationRun }): string { + return [ + `id: ${result.run.id}`, + `automationId: ${result.run.automationId}`, + `title: ${result.run.title}`, + `status: ${result.run.status}`, + `trigger: ${result.run.trigger}`, + `scheduledFor: ${new Date(result.run.scheduledFor).toISOString()}`, + `workspaceId: ${result.run.workspaceId ?? 'null'}`, + `precheck: ${formatAutomationRunPrecheck(result.run)}`, + `error: ${result.run.error ?? 'null'}` + ].join('\n') +} + +function formatAutomationRunPrecheck(run: AutomationRun): string { + const result = run.precheckResult + if (!result) { + return 'none' + } + const outcome = result.timedOut + ? 'timed out' + : result.error + ? 'error' + : `exit ${result.exitCode ?? 'unknown'}` + const output = result.stderr.trim() || result.stdout.trim() + return output ? `${outcome}; ${output}` : outcome +} + +export function formatAutomationRuns(result: { runs: AutomationRun[] }): string { + if (result.runs.length === 0) { + return 'No automation runs found.' + } + return result.runs + .map( + (run) => + `${run.id} ${run.automationId} ${run.status} ${run.trigger} ${new Date(run.scheduledFor).toISOString()}\n${run.title}${run.precheckResult ? `\nprecheck: ${formatAutomationRunPrecheck(run)}` : ''}${run.error ? `\nerror: ${run.error}` : ''}` + ) + .join('\n\n') +} diff --git a/src/cli/automation-owner-conflict-recovery.test.ts b/src/cli/automation-owner-conflict-recovery.test.ts new file mode 100644 index 00000000000..738632945f8 --- /dev/null +++ b/src/cli/automation-owner-conflict-recovery.test.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AUTOMATION_OWNER_CONFLICT_CODES } from '../shared/automation-owner-conflict' +import { formatCliError, reportCliError } from './format' +import { RuntimeRpcFailureError } from './runtime-client' + +function conflict(code: string, message: string): RuntimeRpcFailureError { + return new RuntimeRpcFailureError({ + id: 'request-1', + ok: false, + error: { code, message: `${message}: ${code}` }, + _meta: { runtimeId: 'runtime-1' } + }) +} + +afterEach(() => vi.restoreAllMocks()) + +describe('automation owner conflicts read as CLI outcomes', () => { + it('drops the machine token from the human message and names the recovery', () => { + const output = formatCliError( + conflict(AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged, "This automation's host changed.") + ) + + expect(output).toContain("This automation's host changed.") + expect(output).not.toContain('automation_owner_changed') + expect(output).toContain('Next step: Run the command again') + }) + + // A removed SSH host fails identically forever, so proposing a retry would send + // the user in a loop instead of to the two things that actually resolve it. + it('never proposes a retry for a removed target', () => { + const output = formatCliError( + conflict(AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved, 'Host removed.') + ) + + expect(output).not.toMatch(/run the command again|try again/i) + expect(output).toContain('retrying will not change that') + expect(output).toContain('Re-add that SSH host') + expect(output).toContain('orca automations remove') + }) + + it('keeps the code machine-readable in --json while carrying the same steps', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined) + + reportCliError( + conflict(AUTOMATION_OWNER_CONFLICT_CODES.fencingRequired, 'Owner required.'), + true + ) + + const payload = JSON.parse(log.mock.calls[0]![0] as string) + expect(payload.error.code).toBe('automation_owner_fencing_required') + expect(payload.error.message).toBe('Owner required.') + expect(payload.error.data.nextSteps[1]).toContain('Update Orca on the host') + }) + + it('leaves unrelated failures untouched', () => { + const output = formatCliError(conflict('runtime_error', 'Something else')) + + expect(output).toBe('Something else: runtime_error') + }) + + // The trailing token is the convention precisely because a hop can flatten the + // error class. Classifying on `.code` alone reads only the hop that preserved it, + // and the user loses the recovery on every other one. + it('recovers the conflict from the message when a hop flattened the code', () => { + const flattened = new RuntimeRpcFailureError({ + id: 'request-1', + ok: false, + error: { + code: 'runtime_error', + message: `This automation's host changed.: ${AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged}` + }, + _meta: { runtimeId: 'runtime-1' } + }) + + const output = formatCliError(flattened) + + expect(output).not.toContain('automation_owner_changed') + expect(output).toContain('Next step: Run the command again') + }) + + it('restores the flattened code for --json consumers', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined) + + reportCliError( + new RuntimeRpcFailureError({ + id: 'request-1', + ok: false, + error: { + code: 'runtime_error', + message: `Host removed.: ${AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved}` + }, + _meta: { runtimeId: 'runtime-1' } + }), + true + ) + + const payload = JSON.parse(log.mock.calls[0]![0] as string) + expect(payload.error.code).toBe('automation_target_removed') + expect(payload.error.message).toBe('Host removed.') + expect(payload.error.data.nextSteps[0]).toContain('no longer registered') + }) + + // A local throw never reaches the envelope, so the token is all the CLI has. + it('classifies a locally thrown conflict the same way', () => { + const output = formatCliError( + new Error( + `Destination host is not registered.: ${AUTOMATION_OWNER_CONFLICT_CODES.invalidDestination}` + ) + ) + + expect(output).not.toContain('automation_destination_invalid') + expect(output).toContain('Next step: Run `orca automations show --id `') + }) +}) diff --git a/src/cli/automation-owner-conflict-recovery.ts b/src/cli/automation-owner-conflict-recovery.ts new file mode 100644 index 00000000000..8a900fcb17c --- /dev/null +++ b/src/cli/automation-owner-conflict-recovery.ts @@ -0,0 +1,40 @@ +/** + * What a user should do about each automation owner conflict, phrased for the + * CLI rather than for a window that can re-render itself. + * + * `automation_target_removed` deliberately offers no retry: the SSH host the + * record is pinned to is gone, so every retry fails identically until the user + * re-adds that host or deletes the automation. + */ + +import { + AUTOMATION_OWNER_CONFLICT_CODES, + isAutomationOwnerConflictCode +} from '../shared/automation-owner-conflict' + +export type AutomationOwnerConflictRecovery = { nextSteps: readonly string[] } + +const RECOVERY: Record = { + [AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged]: [ + 'The automation moved to a different host between the read and the write.', + 'Run the command again; it captures the current host on each attempt.' + ], + [AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved]: [ + 'This automation is pinned to an SSH host that is no longer registered, so it cannot run and retrying will not change that.', + 'Re-add that SSH host, or delete the automation with `orca automations remove --id `.' + ], + [AUTOMATION_OWNER_CONFLICT_CODES.fencingRequired]: [ + 'The host accepted the request but did not report which host owns the automation, so the CLI had no owner to send.', + 'Update Orca on the host, then run the command again.' + ], + [AUTOMATION_OWNER_CONFLICT_CODES.invalidDestination]: [ + 'The host this command targets is not registered on the authority that stores the automation.', + 'Run `orca automations show --id ` to see where it lives, then target a host that authority knows.' + ] +} + +export function automationOwnerConflictRecovery( + code: string | null | undefined +): AutomationOwnerConflictRecovery | undefined { + return isAutomationOwnerConflictCode(code) ? { nextSteps: RECOVERY[code] } : undefined +} diff --git a/src/cli/format.ts b/src/cli/format.ts index 2b6b67f05e9..8707ceef092 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -1,5 +1,10 @@ import type { CliStatusResult } from '../shared/runtime-types' import { computerUseErrorRecoveryData } from '../shared/computer-use-error-recovery' +import { + matchAutomationOwnerConflict, + stripAutomationOwnerConflictCode +} from '../shared/automation-owner-conflict' +import { automationOwnerConflictRecovery } from './automation-owner-conflict-recovery' import { prepareComputerCliJsonResult } from './computer-format' import type { RuntimeRpcFailure, RuntimeRpcSuccess } from './runtime-client' import { RuntimeClientError, RuntimeRpcFailureError } from './runtime/types' @@ -47,7 +52,10 @@ export { formatAutomationRemoved, formatAutomationRun, formatAutomationRuns, - formatAutomationShow, + formatAutomationShow +} from './automation-format' +export type { AutomationListPayload, AutomationShowPayload } from './automation-format' +export { formatEnvironment, formatEnvironmentList, formatMemorySnapshot, @@ -84,6 +92,11 @@ export function formatCliError(error: unknown, context: CliErrorContext = {}): s return `${message}\nOrca is not running. Run 'orca open' first.` } // Why: error-specific recovery must win over the generic computer fallback. + // Classified from the whole error, not just `.code`: a hop that flattens the class leaves only the token. + const conflict = automationOwnerConflictRecovery(matchAutomationOwnerConflict(error)) + if (conflict) { + return formatMessageWithNextSteps(stripAutomationOwnerConflictCode(message), conflict.nextSteps) + } if (error instanceof RuntimeClientError) { const nextSteps = nextStepsFromData(error.data) if (nextSteps.length > 0) { @@ -119,14 +132,18 @@ function hasOrchestrationRequestId(data: unknown): boolean { export function reportCliError(error: unknown, json: boolean, context: CliErrorContext = {}): void { if (json) { if (error instanceof RuntimeRpcFailureError) { - console.log(JSON.stringify(error.response, null, 2)) + console.log(JSON.stringify(withAutomationOwnerConflictRecovery(error.response), null, 2)) } else { const response: RuntimeRpcFailure = { id: 'local', ok: false, error: { - code: error instanceof RuntimeClientError ? error.code : 'runtime_error', - message: error instanceof Error ? error.message : String(error), + code: + matchAutomationOwnerConflict(error) ?? + (error instanceof RuntimeClientError ? error.code : 'runtime_error'), + message: stripAutomationOwnerConflictCode( + error instanceof Error ? error.message : String(error) + ), data: localCliErrorData(error, context) }, _meta: { @@ -140,6 +157,25 @@ export function reportCliError(error: unknown, json: boolean, context: CliErrorC } } +/** Machine-readable half of the same recovery the human message carries. */ +function withAutomationOwnerConflictRecovery(response: RuntimeRpcFailure): RuntimeRpcFailure { + const code = matchAutomationOwnerConflict(response) + const conflict = automationOwnerConflictRecovery(code) + if (!conflict || !code) { + return response + } + return { + ...response, + error: { + ...response.error, + // Restores the classification a flattening hop dropped, so --json consumers read the conflict, not the transport. + code, + message: stripAutomationOwnerConflictCode(response.error.message), + data: response.error.data ?? conflict + } + } +} + function formatMessageWithNextSteps(message: string, nextSteps: readonly string[]): string { if (nextSteps.length === 0) { return message @@ -165,6 +201,10 @@ function localCliErrorData(error: unknown, context: CliErrorContext): unknown { if (error instanceof RuntimeClientError && error.data !== undefined) { return error.data } + const conflict = automationOwnerConflictRecovery(matchAutomationOwnerConflict(error)) + if (conflict) { + return conflict + } if ( error instanceof RuntimeClientError && error.code === 'invalid_argument' && diff --git a/src/cli/handlers/automation-destination-fencing.test.ts b/src/cli/handlers/automation-destination-fencing.test.ts new file mode 100644 index 00000000000..ba2947e716b --- /dev/null +++ b/src/cli/handlers/automation-destination-fencing.test.ts @@ -0,0 +1,234 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { deriveAutomationExecutionTargetForCreate } from '../../shared/automation-execution-target' +import { + assertAutomationDestination, + assertExecutionTargetMatchesDestination +} from '../../shared/automation-owner-precondition' +import { AUTOMATION_HANDLERS } from './automations' + +type SshRegistration = { id: string; label: string; generation?: number } + +function ok(result: unknown): unknown { + return { id: 'request-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +/** + * Stands in for the storing authority: it answers the reads the CLI makes and + * enforces the destination exactly where persistence does — with the real + * assertions, and only when the client actually supplies one. + */ +function authority(options: { + /** What `ssh.listTargetSummaries` reports while the CLI captures its destination. */ + registered: SshRegistration[] + /** Generation the same target carries by the time the write lands. */ + generationAtWrite?: number + repoConnectionId?: string | null + targetSummaryError?: Error +}): { call: ReturnType; writes: { method: string; params: unknown }[] } { + const writes: { method: string; params: unknown }[] = [] + const connectionId = options.repoConnectionId ?? null + const call = vi.fn(async (method: string, params?: Record) => { + if (method === 'automation.show') { + return ok({ + automation: { id: 'a1' }, + owner: { selector: { kind: 'ssh', targetId: 'box-0', targetGeneration: 3 } } + }) + } + if (method === 'repo.show') { + return ok({ repo: { id: 'r1', connectionId } }) + } + if (method === 'ssh.listTargetSummaries') { + if (options.targetSummaryError) { + throw options.targetSummaryError + } + return ok({ targets: options.registered }) + } + if (method === 'automation.create' || method === 'automation.update') { + writes.push({ method, params: params ?? {} }) + const destination = (params as { destination?: never } | undefined)?.destination + if (destination) { + const sshTargetGeneration = (): number | undefined => options.generationAtWrite + assertAutomationDestination(destination, { sshTargetGeneration }) + assertExecutionTargetMatchesDestination( + deriveAutomationExecutionTargetForCreate({ + repo: { connectionId }, + sshTargetGeneration: options.generationAtWrite + }), + destination + ) + } + return ok({ automation: { id: 'a1' } }) + } + return ok({}) + }) + return { call, writes } +} + +const CREATE_FLAGS: [string, string][] = [ + ['name', 'nightly'], + ['prompt', 'go'], + ['provider', 'claude'], + ['trigger', 'daily'] +] + +afterEach(() => vi.restoreAllMocks()) + +// The expected owner only fences the host the record is leaving. Without the +// destination the arrival is never checked, so a move onto a host that is gone — +// or gone and re-registered as a different machine under the same id — is +// accepted and only surfaces later, at dispatch, as a dead automation. +describe('CLI automation writes fence the host they land on', () => { + it('refuses a selector-moving edit onto an SSH host the authority no longer registers', async () => { + const { call, writes } = authority({ registered: [], repoConnectionId: 'box-1' }) + vi.spyOn(console, 'log').mockImplementation(() => undefined) + + await expect( + AUTOMATION_HANDLERS['automations edit']!({ + client: { call } as never, + cwd: '/tmp', + flags: new Map([ + ['id', 'a1'], + ['repo', 'repo-on-box-1'] + ]), + json: true + }) + ).rejects.toThrow('automation_destination_invalid') + + expect(writes).toEqual([]) + }) + + it('refuses a create onto an SSH host the authority no longer registers', async () => { + const { call, writes } = authority({ registered: [], repoConnectionId: 'box-1' }) + vi.spyOn(console, 'log').mockImplementation(() => undefined) + + await expect( + AUTOMATION_HANDLERS['automations create']!({ + client: { call } as never, + cwd: '/tmp', + flags: new Map([...CREATE_FLAGS, ['repo', 'repo-on-box-1']]), + json: true + }) + ).rejects.toThrow('automation_destination_invalid') + + expect(writes).toEqual([]) + }) + + it('does not drop the fence when reading SSH registrations fails', async () => { + const { call, writes } = authority({ + registered: [], + repoConnectionId: 'box-1', + targetSummaryError: new Error('rpc timeout') + }) + vi.spyOn(console, 'log').mockImplementation(() => undefined) + + await expect( + AUTOMATION_HANDLERS['automations create']!({ + client: { call } as never, + cwd: '/tmp', + flags: new Map([...CREATE_FLAGS, ['repo', 'repo-on-box-1']]), + json: true + }) + ).rejects.toThrow('rpc timeout') + + expect(writes).toEqual([]) + }) + + it('refuses a selector-moving edit when the host is re-registered between capture and write', async () => { + const { call } = authority({ + registered: [{ id: 'box-1', label: 'box 1', generation: 7 }], + generationAtWrite: 8, + repoConnectionId: 'box-1' + }) + vi.spyOn(console, 'log').mockImplementation(() => undefined) + + await expect( + AUTOMATION_HANDLERS['automations edit']!({ + client: { call } as never, + cwd: '/tmp', + flags: new Map([ + ['id', 'a1'], + ['repo', 'repo-on-box-1'] + ]), + json: true + }) + ).rejects.toThrow('automation_destination_invalid') + }) + + it('lets a move onto a still-registered host through, carrying the incarnation it captured', async () => { + const { call, writes } = authority({ + registered: [{ id: 'box-1', label: 'box 1', generation: 7 }], + generationAtWrite: 7, + repoConnectionId: 'box-1' + }) + vi.spyOn(console, 'log').mockImplementation(() => undefined) + + await AUTOMATION_HANDLERS['automations edit']!({ + client: { call } as never, + cwd: '/tmp', + flags: new Map([ + ['id', 'a1'], + ['repo', 'repo-on-box-1'] + ]), + json: true + }) + + expect(writes[0]?.params).toMatchObject({ + destination: { selector: { kind: 'ssh', targetId: 'box-1', targetGeneration: 7 } } + }) + }) + + // A host that predates SSH generations has no incarnation to capture, so the + // write must still land — failing closed there would break a host that is only old. + it('omits the destination when the authority registers the target without a generation', async () => { + const { call, writes } = authority({ + registered: [{ id: 'box-1', label: 'box 1' }], + repoConnectionId: 'box-1' + }) + vi.spyOn(console, 'log').mockImplementation(() => undefined) + + await AUTOMATION_HANDLERS['automations edit']!({ + client: { call } as never, + cwd: '/tmp', + flags: new Map([ + ['id', 'a1'], + ['repo', 'repo-on-box-1'] + ]), + json: true + }) + + expect(writes[0]?.params).not.toHaveProperty('destination') + }) + + it('sends a self destination for a local project', async () => { + const { call, writes } = authority({ registered: [], repoConnectionId: null }) + vi.spyOn(console, 'log').mockImplementation(() => undefined) + + await AUTOMATION_HANDLERS['automations create']!({ + client: { call } as never, + cwd: '/tmp', + flags: new Map([...CREATE_FLAGS, ['repo', 'local-repo']]), + json: true + }) + + expect(writes[0]?.params).toMatchObject({ destination: { selector: { kind: 'self' } } }) + }) + + // Nothing moves, so there is no arrival to fence and no reason to spend the reads. + it('sends no destination when the edit names no project or workspace', async () => { + const { call, writes } = authority({ registered: [] }) + vi.spyOn(console, 'log').mockImplementation(() => undefined) + + await AUTOMATION_HANDLERS['automations edit']!({ + client: { call } as never, + cwd: '/tmp', + flags: new Map([ + ['id', 'a1'], + ['name', 'renamed'] + ]), + json: true + }) + + expect(call).not.toHaveBeenCalledWith('repo.show', expect.anything()) + expect(writes[0]?.params).not.toHaveProperty('destination') + }) +}) diff --git a/src/cli/handlers/automation-owner-fencing.test.ts b/src/cli/handlers/automation-owner-fencing.test.ts new file mode 100644 index 00000000000..987c6ecfdec --- /dev/null +++ b/src/cli/handlers/automation-owner-fencing.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AUTOMATION_HANDLERS } from './automations' + +const owner = { selector: { kind: 'ssh', targetId: 'box', targetGeneration: 4 } } + +function ok(result: unknown): unknown { + return { id: 'request-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +function clientAnsweringShow(owner: unknown): { call: ReturnType } { + const call = vi.fn(async (method: string) => + method === 'automation.show' + ? ok(owner === undefined ? { automation: { id: 'a1' } } : { automation: { id: 'a1' }, owner }) + : ok({ automation: { id: 'a1' }, removed: true, id: 'a1', run: { id: 'r1' } }) + ) + return { call } +} + +afterEach(() => vi.restoreAllMocks()) + +// Why: the fence refuses an unfenced mutation on a generation-bearing SSH record, so +// every CLI mutation has to read the owner first or it cannot touch its own host. +describe('CLI automation mutations carry the owner they read', () => { + it.each([ + ['automations remove', 'automation.delete'], + ['automations run', 'automation.runNow'] + ])('%s sends the owner from automation.show to %s', async (command, method) => { + const { call } = clientAnsweringShow(owner) + vi.spyOn(console, 'log').mockImplementation(() => undefined) + + await AUTOMATION_HANDLERS[command]!({ + client: { call } as never, + cwd: '/tmp', + flags: new Map([['id', 'a1']]), + json: true + }) + + expect(call).toHaveBeenNthCalledWith(1, 'automation.show', { id: 'a1' }) + expect(call).toHaveBeenNthCalledWith(2, method, { id: 'a1', expectedOwner: owner }) + }) + + it('edit sends the owner alongside the update payload', async () => { + const { call } = clientAnsweringShow(owner) + vi.spyOn(console, 'log').mockImplementation(() => undefined) + + await AUTOMATION_HANDLERS['automations edit']!({ + client: { call } as never, + cwd: '/tmp', + flags: new Map([ + ['id', 'a1'], + ['name', 'renamed'] + ]), + json: true + }) + + expect(call).toHaveBeenNthCalledWith( + 2, + 'automation.update', + expect.objectContaining({ id: 'a1', expectedOwner: owner }) + ) + }) + + // An older host has no fence to satisfy, so a missing owner must not become a + // fabricated one — and must not stop the mutation either. + it('omits the precondition when the host reports no owner', async () => { + const { call } = clientAnsweringShow(undefined) + vi.spyOn(console, 'log').mockImplementation(() => undefined) + + await AUTOMATION_HANDLERS['automations run']!({ + client: { call } as never, + cwd: '/tmp', + flags: new Map([['id', 'a1']]), + json: true + }) + + expect(call).toHaveBeenNthCalledWith(2, 'automation.runNow', { id: 'a1' }) + }) + + it('leaves reads unfenced, so listing still works on a record whose host is gone', async () => { + const { call } = clientAnsweringShow(owner) + vi.spyOn(console, 'log').mockImplementation(() => undefined) + + await AUTOMATION_HANDLERS['automations runs']!({ + client: { call } as never, + cwd: '/tmp', + flags: new Map([['id', 'a1']]), + json: true + }) + + expect(call).toHaveBeenCalledTimes(1) + expect(call).toHaveBeenCalledWith('automation.runs', { automationId: 'a1' }) + }) +}) diff --git a/src/cli/handlers/automations.ts b/src/cli/handlers/automations.ts index ab592504c00..d413b8a2a79 100644 --- a/src/cli/handlers/automations.ts +++ b/src/cli/handlers/automations.ts @@ -13,6 +13,10 @@ import { type TaskSourceContext, type WorkspaceRunContext } from '../../shared/task-source-context' +import type { + AutomationDestination, + AutomationOwnerPrecondition +} from '../../shared/automation-owner-precondition' import type { ProjectHostSetup } from '../../shared/project-types' import type { TuiAgent } from '../../shared/tui-agent' import { @@ -28,13 +32,15 @@ import { formatAutomationRun, formatAutomationRuns, formatAutomationShow, - printResult + printResult, + type AutomationShowPayload } from '../format' import { getOptionalPositiveIntegerFlag, getOptionalStringFlag, getRequiredStringFlag } from '../flags' +import { resolveAutomationDestination } from '../automation-destination' import { RuntimeClientError } from '../runtime-client' import { getOptionalWorktreeSelector, resolveCurrentWorktreeSelector } from '../selectors' import { @@ -44,6 +50,7 @@ import { } from '../worktree-project-target' type AutomationCreateParams = Omit & { + destination?: AutomationDestination repo?: string timezone?: string workspace?: string @@ -405,6 +412,24 @@ async function getExplicitTarget( return { repo, workspace } } +/** + * Reads the record's current owner so the mutation that follows can name it. + * + * The CLI cannot project an owner itself — it has no SSH target registry — so it + * asks the authority that stores the record. A host too old to answer sends + * nothing, and that host has no fence to satisfy either. + */ +async function resolveExpectedOwner( + client: Parameters[0]['client'], + id: string +): Promise { + const result = await client.call<{ automation: Automation; owner?: AutomationOwnerPrecondition }>( + 'automation.show', + { id } + ) + return result.result.owner +} + function buildAutomationRunContextFromSetup(setup: ProjectHostSetup): WorkspaceRunContext { const runContext = buildWorkspaceRunContext({ projectId: setup.projectId, @@ -428,7 +453,7 @@ export const AUTOMATION_HANDLERS: Record = { printResult(result, json, formatAutomationList) }, 'automations show': async ({ flags, client, json }) => { - const result = await client.call<{ automation: Automation }>('automation.show', { + const result = await client.call('automation.show', { id: getRequiredStringFlag(flags, 'id') }) printResult(result, json, formatAutomationShow) @@ -442,7 +467,8 @@ export const AUTOMATION_HANDLERS: Record = { const sourceContext = getSourceContextFlag(flags) const workspaceMode = getWorkspaceModeFlag(flags) ?? (target.workspace ? 'existing' : 'new_per_run') - const result = await client.call<{ automation: Automation }>('automation.create', { + // Built before the destination read so a contradictory flag still fails without a runtime call. + const create = { name: getRequiredStringFlag(flags, 'name'), prompt: getRequiredStringFlag(flags, 'prompt'), precheck: getPrecheckFlag(flags), @@ -458,43 +484,63 @@ export const AUTOMATION_HANDLERS: Record = { enabled: getEnabledFlag(flags), missedRunGraceMinutes: getOptionalPositiveIntegerFlag(flags, 'missed-run-grace-minutes'), ...schedule - } satisfies AutomationCreateParams) + } satisfies AutomationCreateParams + const destination = await resolveAutomationDestination(client, target) + const result = await client.call<{ automation: Automation }>('automation.create', { + ...create, + ...(destination ? { destination } : {}) + }) printResult(result, json, formatAutomationShow) }, 'automations edit': async ({ flags, client, cwd, json }) => { const target = await getExplicitTarget(flags, cwd, client) const schedule = getScheduleFlag(flags, false) const sourceContext = getSourceContextFlag(flags) + const id = getRequiredStringFlag(flags, 'id') + // Built before the owner read so a contradictory flag still fails without a runtime call. + const updates = { + name: getOptionalStringFlag(flags, 'name'), + prompt: getOptionalStringFlag(flags, 'prompt'), + precheck: getPrecheckFlag(flags), + agentId: getOptionalProviderFlag(flags), + ...(target.runContext ? { runContext: target.runContext } : {}), + ...(sourceContext !== undefined ? { sourceContext } : {}), + repo: target.repo, + workspace: target.workspace, + workspaceMode: getWorkspaceModeFlag(flags), + baseBranch: getOptionalStringFlag(flags, 'base-branch'), + reuseSession: getReuseSessionFlag(flags), + timezone: getOptionalStringFlag(flags, 'timezone'), + enabled: getEnabledFlag(flags), + missedRunGraceMinutes: getOptionalPositiveIntegerFlag(flags, 'missed-run-grace-minutes'), + ...schedule + } satisfies AutomationUpdateParams + const expectedOwner = await resolveExpectedOwner(client, id) + // Why: expectedOwner only fences the host the record is leaving; an edit that moves it needs the arrival fenced too. + const destination = await resolveAutomationDestination(client, target) const result = await client.call<{ automation: Automation }>('automation.update', { - id: getRequiredStringFlag(flags, 'id'), - updates: { - name: getOptionalStringFlag(flags, 'name'), - prompt: getOptionalStringFlag(flags, 'prompt'), - precheck: getPrecheckFlag(flags), - agentId: getOptionalProviderFlag(flags), - ...(target.runContext ? { runContext: target.runContext } : {}), - ...(sourceContext !== undefined ? { sourceContext } : {}), - repo: target.repo, - workspace: target.workspace, - workspaceMode: getWorkspaceModeFlag(flags), - baseBranch: getOptionalStringFlag(flags, 'base-branch'), - reuseSession: getReuseSessionFlag(flags), - timezone: getOptionalStringFlag(flags, 'timezone'), - enabled: getEnabledFlag(flags), - missedRunGraceMinutes: getOptionalPositiveIntegerFlag(flags, 'missed-run-grace-minutes'), - ...schedule - } satisfies AutomationUpdateParams + id, + ...(expectedOwner ? { expectedOwner } : {}), + ...(destination ? { destination } : {}), + updates }) printResult(result, json, formatAutomationShow) }, 'automations remove': async ({ flags, client, json }) => { const id = getRequiredStringFlag(flags, 'id') - const result = await client.call<{ removed: boolean; id: string }>('automation.delete', { id }) + const expectedOwner = await resolveExpectedOwner(client, id) + const result = await client.call<{ removed: boolean; id: string }>('automation.delete', { + id, + ...(expectedOwner ? { expectedOwner } : {}) + }) printResult(result, json, formatAutomationRemoved) }, 'automations run': async ({ flags, client, json }) => { + const id = getRequiredStringFlag(flags, 'id') + const expectedOwner = await resolveExpectedOwner(client, id) const result = await client.call<{ run: AutomationRun }>('automation.runNow', { - id: getRequiredStringFlag(flags, 'id') + id, + ...(expectedOwner ? { expectedOwner } : {}) }) printResult(result, json, formatAutomationRun) }, diff --git a/src/cli/index-automation-identifiers.test.ts b/src/cli/index-automation-identifiers.test.ts index 488ca0f0ed1..a54dd92edf8 100644 --- a/src/cli/index-automation-identifiers.test.ts +++ b/src/cli/index-automation-identifiers.test.ts @@ -57,8 +57,11 @@ describe('orca cli worktree awareness', () => { it('passes positional automation ids to edit, remove, run, and show', async () => { queueFixtures( callMock, + okFixture('req_edit_owner', { automation: { id: 'auto-1', name: 'Paused' } }), okFixture('req_edit', { automation: { id: 'auto-1', name: 'Paused' } }), + okFixture('req_remove_owner', { automation: { id: 'auto-1', name: 'Paused' } }), okFixture('req_remove', { removed: true, id: 'auto-1' }), + okFixture('req_run_owner', { automation: { id: 'auto-1', name: 'Paused' } }), okFixture('req_run', { run: { id: 'run-1', @@ -88,7 +91,8 @@ describe('orca cli worktree awareness', () => { await main(['automations', 'run', 'auto-1', '--json'], '/tmp/repo') await main(['automations', 'show', 'auto-1', '--json'], '/tmp/repo') - expect(callMock).toHaveBeenNthCalledWith(1, 'automation.update', { + expect(callMock).toHaveBeenNthCalledWith(1, 'automation.show', { id: 'auto-1' }) + expect(callMock).toHaveBeenNthCalledWith(2, 'automation.update', { id: 'auto-1', updates: { name: undefined, @@ -103,13 +107,15 @@ describe('orca cli worktree awareness', () => { missedRunGraceMinutes: undefined } }) - expect(callMock).toHaveBeenNthCalledWith(2, 'automation.delete', { + expect(callMock).toHaveBeenNthCalledWith(3, 'automation.show', { id: 'auto-1' }) + expect(callMock).toHaveBeenNthCalledWith(4, 'automation.delete', { id: 'auto-1' }) - expect(callMock).toHaveBeenNthCalledWith(3, 'automation.runNow', { + expect(callMock).toHaveBeenNthCalledWith(5, 'automation.show', { id: 'auto-1' }) + expect(callMock).toHaveBeenNthCalledWith(6, 'automation.runNow', { id: 'auto-1' }) - expect(callMock).toHaveBeenNthCalledWith(4, 'automation.show', { + expect(callMock).toHaveBeenNthCalledWith(7, 'automation.show', { id: 'auto-1' }) }) diff --git a/src/cli/index-automation-session-reuse.test.ts b/src/cli/index-automation-session-reuse.test.ts index 3b6182b62d7..c4a79e9c803 100644 --- a/src/cli/index-automation-session-reuse.test.ts +++ b/src/cli/index-automation-session-reuse.test.ts @@ -41,7 +41,13 @@ vi.mock('child_process', async () => { }) import { main } from './index' -import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures' +import { + buildWorktree, + okFixture, + queueFixtures, + workspaceDestinationFixtures, + worktreeListFixture +} from './test-fixtures' import { useWorktreeAwarenessEnvironment } from './index-test-harness' describe('orca cli worktree awareness', () => { @@ -58,9 +64,11 @@ describe('orca cli worktree awareness', () => { queueFixtures( callMock, worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]), + ...workspaceDestinationFixtures(), okFixture('req_create', { automation: { id: 'auto-1', name: 'Daily review' } }), + okFixture('req_edit_owner', { automation: { id: 'auto-1', name: 'Daily review' } }), okFixture('req_edit', { automation: { id: 'auto-1', name: 'Daily review' } }) @@ -92,7 +100,7 @@ describe('orca cli worktree awareness', () => { limit: 10_000 }) expect(callMock).toHaveBeenNthCalledWith( - 2, + 4, 'automation.create', expect.objectContaining({ workspace: 'id:repo-1::/tmp/repo/feature', @@ -100,7 +108,8 @@ describe('orca cli worktree awareness', () => { reuseSession: true }) ) - expect(callMock).toHaveBeenNthCalledWith(3, 'automation.update', { + expect(callMock).toHaveBeenNthCalledWith(5, 'automation.show', { id: 'auto-1' }) + expect(callMock).toHaveBeenNthCalledWith(6, 'automation.update', { id: 'auto-1', updates: expect.objectContaining({ reuseSession: false }) }) diff --git a/src/cli/index-automation-source-context.test.ts b/src/cli/index-automation-source-context.test.ts index 4d446291729..6ed9557bc4a 100644 --- a/src/cli/index-automation-source-context.test.ts +++ b/src/cli/index-automation-source-context.test.ts @@ -41,7 +41,7 @@ vi.mock('child_process', async () => { }) import { main } from './index' -import { okFixture, queueFixtures } from './test-fixtures' +import { localRepoDestinationFixtures, okFixture, queueFixtures } from './test-fixtures' import { useWorktreeAwarenessEnvironment } from './index-test-harness' describe('orca cli worktree awareness', () => { @@ -67,6 +67,7 @@ describe('orca cli worktree awareness', () => { } queueFixtures( callMock, + ...localRepoDestinationFixtures('repo-gpu'), okFixture('req_automation_create', { automation: { id: 'auto-1', name: 'GPU task review' } }) @@ -95,7 +96,7 @@ describe('orca cli worktree awareness', () => { ) expect(callMock).toHaveBeenNthCalledWith( - 1, + 2, 'automation.create', expect.objectContaining({ repo: 'id:repo-gpu', @@ -107,6 +108,7 @@ describe('orca cli worktree awareness', () => { it('clears automation source context on edit with null', async () => { queueFixtures( callMock, + okFixture('req_edit_owner', { automation: { id: 'auto-1', name: 'GPU task review' } }), okFixture('req_edit', { automation: { id: 'auto-1', name: 'GPU task review' } }) @@ -116,7 +118,7 @@ describe('orca cli worktree awareness', () => { await main(['automations', 'edit', 'auto-1', '--source-context', 'null', '--json'], '/tmp/repo') expect(callMock).toHaveBeenNthCalledWith( - 1, + 2, 'automation.update', expect.objectContaining({ id: 'auto-1', diff --git a/src/cli/index-automation-target.test.ts b/src/cli/index-automation-target.test.ts index ca9e398cadd..564cc73ffab 100644 --- a/src/cli/index-automation-target.test.ts +++ b/src/cli/index-automation-target.test.ts @@ -41,7 +41,14 @@ vi.mock('child_process', async () => { }) import { main } from './index' -import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures' +import { + buildWorktree, + localRepoDestinationFixtures, + okFixture, + queueFixtures, + workspaceDestinationFixtures, + worktreeListFixture +} from './test-fixtures' import { pairRuntimeEnvironment, useWorktreeAwarenessEnvironment } from './index-test-harness' describe('orca cli worktree awareness', () => { @@ -58,6 +65,7 @@ describe('orca cli worktree awareness', () => { queueFixtures( callMock, worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]), + ...workspaceDestinationFixtures(), okFixture('req_automation_create', { automation: { id: 'auto-1', @@ -105,9 +113,10 @@ describe('orca cli worktree awareness', () => { expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 }) - expect(callMock).toHaveBeenNthCalledWith(2, 'automation.create', { + expect(callMock).toHaveBeenNthCalledWith(4, 'automation.create', { name: 'Daily review', prompt: 'Review open changes', + precheck: undefined, agentId: 'codex', repo: undefined, workspace: 'id:repo-1::/tmp/repo/feature', @@ -118,7 +127,8 @@ describe('orca cli worktree awareness', () => { enabled: undefined, missedRunGraceMinutes: undefined, rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', - dtstart: expect.any(Number) + dtstart: expect.any(Number), + destination: { selector: { kind: 'self' } } }) }) @@ -154,6 +164,7 @@ describe('orca cli worktree awareness', () => { } ] }), + ...localRepoDestinationFixtures('repo-gpu'), okFixture('req_automation_create', { automation: { id: 'auto-1', name: 'GPU review' } }) @@ -184,7 +195,7 @@ describe('orca cli worktree awareness', () => { expect(runtimeClientConstructorMock).toHaveBeenCalledWith(null, 'gpu') expect(callMock).toHaveBeenNthCalledWith(1, 'projectHostSetup.list') expect(callMock).toHaveBeenNthCalledWith( - 2, + 3, 'automation.create', expect.objectContaining({ repo: 'id:repo-gpu', @@ -221,6 +232,8 @@ describe('orca cli worktree awareness', () => { } ] }), + okFixture('req_edit_owner', { automation: { id: 'auto-1', name: 'GPU review' } }), + ...localRepoDestinationFixtures('repo-gpu'), okFixture('req_edit', { automation: { id: 'auto-1', name: 'GPU review' } }) @@ -234,7 +247,7 @@ describe('orca cli worktree awareness', () => { expect(callMock).toHaveBeenNthCalledWith(1, 'projectHostSetup.list') expect(callMock).toHaveBeenNthCalledWith( - 2, + 4, 'automation.update', expect.objectContaining({ id: 'auto-1', @@ -364,6 +377,7 @@ describe('orca cli worktree awareness', () => { queueFixtures( callMock, worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]), + ...workspaceDestinationFixtures(), okFixture('req_automation_create', { automation: { id: 'auto-1', name: 'Daily review' } }) @@ -392,9 +406,10 @@ describe('orca cli worktree awareness', () => { expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 }) - expect(callMock).toHaveBeenNthCalledWith(2, 'automation.create', { + expect(callMock).toHaveBeenNthCalledWith(4, 'automation.create', { name: 'Daily review', prompt: 'Review open changes', + precheck: undefined, agentId: 'codex', repo: undefined, workspace: 'id:repo-1::/tmp/repo/feature', @@ -404,7 +419,9 @@ describe('orca cli worktree awareness', () => { enabled: undefined, missedRunGraceMinutes: undefined, rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', - dtstart: expect.any(Number) + dtstart: expect.any(Number), + reuseSession: undefined, + destination: { selector: { kind: 'self' } } }) }) @@ -412,6 +429,8 @@ describe('orca cli worktree awareness', () => { queueFixtures( callMock, worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]), + okFixture('req_edit_owner', { automation: { id: 'auto-1', name: 'Daily review' } }), + ...workspaceDestinationFixtures(), okFixture('req_edit', { automation: { id: 'auto-1', name: 'Daily review' } }) @@ -426,11 +445,12 @@ describe('orca cli worktree awareness', () => { expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 }) - expect(callMock).toHaveBeenNthCalledWith(2, 'automation.update', { + expect(callMock).toHaveBeenNthCalledWith(5, 'automation.update', { id: 'auto-1', updates: { name: undefined, prompt: undefined, + precheck: undefined, agentId: undefined, repo: undefined, workspace: 'id:repo-1::/tmp/repo/feature', @@ -440,7 +460,8 @@ describe('orca cli worktree awareness', () => { timezone: undefined, enabled: true, missedRunGraceMinutes: undefined - } + }, + destination: { selector: { kind: 'self' } } }) }) }) diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 7f12c6957d9..985953fd4a9 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -19,6 +19,7 @@ import { addEnvironmentFromPairingCode } from './environments' import { RuntimeClientError } from './types' import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, RUNTIME_PROTOCOL_VERSION, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, @@ -71,7 +72,8 @@ describe('CLI remote WebSocket transport', () => { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, SKILL_INSTALL_RESULT_V2_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, - WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY + WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY ] }) ) diff --git a/src/cli/test-fixtures.ts b/src/cli/test-fixtures.ts index a8e7d8f91f2..b97018e2193 100644 --- a/src/cli/test-fixtures.ts +++ b/src/cli/test-fixtures.ts @@ -61,6 +61,22 @@ export function okFixture(id: string, result: unknown): OkFixture { return { id, ok: true, result, _meta: { runtimeId: 'runtime-1' } } } +/** The project read a CLI automation write makes to fence the host it lands on. */ +export function localRepoDestinationFixtures(repoId = 'repo-1'): OkFixture[] { + return [okFixture('req_repo_show', { repo: { id: repoId, connectionId: null } })] +} + +/** A workspace selector resolves through its workspace before its project. */ +export function workspaceDestinationFixtures( + repoId = 'repo-1', + worktreePath = '/tmp/repo/feature' +): OkFixture[] { + return [ + okFixture('req_worktree_show', { worktree: { id: `${repoId}::${worktreePath}`, repoId } }), + ...localRepoDestinationFixtures(repoId) + ] +} + export function queueFixtures(mock: Mock, ...fixtures: OkFixture[]): void { for (const fixture of fixtures) { mock.mockResolvedValueOnce(fixture) diff --git a/src/cli/workspace-format.ts b/src/cli/workspace-format.ts index 01dd0dbabcf..008c2cf4c21 100644 --- a/src/cli/workspace-format.ts +++ b/src/cli/workspace-format.ts @@ -1,7 +1,3 @@ -import type { Automation, AutomationRun } from '../shared/automations-types' -import { getAutomationLegacyRepoId } from '../shared/automation-run-identity' -import { formatAutomationPrecheckTimeout } from '../shared/automation-precheck' -import { formatAutomationSchedule } from '../shared/automation-schedules' import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' import type { RuntimeRepoList, @@ -174,99 +170,3 @@ export function formatWorktreeShow(result: { worktree: RuntimeWorktreeRecord }): ) .join('\n') } - -export function formatAutomationList(result: { automations: Automation[] }): string { - if (result.automations.length === 0) { - return 'No automations found.' - } - return result.automations - .map((automation) => { - const status = automation.enabled ? 'enabled' : 'disabled' - return `${automation.id} ${automation.name} ${automation.agentId} ${status}\n${formatAutomationSchedule(automation.rrule)} next: ${new Date(automation.nextRunAt).toISOString()}` - }) - .join('\n\n') -} - -export function formatAutomationShow(result: { automation: Automation }): string { - const automation = result.automation - const runContext = automation.runContext ?? null - const projectLines = runContext - ? [ - `runProjectId: ${runContext.projectId}`, - `runHostId: ${runContext.hostId}`, - `projectHostSetupId: ${runContext.projectHostSetupId}`, - `runRepoId: ${runContext.repoId}`, - `runPath: ${runContext.path}`, - `legacyRepoId: ${getAutomationLegacyRepoId(automation)}` - ] - : [`legacyRepoId: ${getAutomationLegacyRepoId(automation)}`] - return [ - `id: ${automation.id}`, - `name: ${automation.name}`, - `provider: ${automation.agentId}`, - `enabled: ${automation.enabled}`, - `schedule: ${formatAutomationSchedule(automation.rrule)}`, - `rrule: ${automation.rrule}`, - `precheck: ${ - automation.precheck - ? `${automation.precheck.command} (timeout ${formatAutomationPrecheckTimeout( - automation.precheck.timeoutSeconds - )})` - : 'none' - }`, - `nextRunAt: ${new Date(automation.nextRunAt).toISOString()}`, - ...projectLines, - `workspaceMode: ${automation.workspaceMode}`, - `workspaceId: ${automation.workspaceId ?? 'null'}`, - `baseBranch: ${automation.baseBranch ?? 'null'}`, - `reuseSession: ${automation.reuseSession}`, - `target: ${automation.executionTargetType}:${automation.executionTargetId}`, - `prompt: ${automation.prompt}` - ].join('\n') -} - -export function formatAutomationRemoved(result: { removed: boolean; id: string }): string { - return result.removed - ? `Removed automation ${result.id}.` - : `Automation ${result.id} not removed.` -} - -export function formatAutomationRun(result: { run: AutomationRun }): string { - return [ - `id: ${result.run.id}`, - `automationId: ${result.run.automationId}`, - `title: ${result.run.title}`, - `status: ${result.run.status}`, - `trigger: ${result.run.trigger}`, - `scheduledFor: ${new Date(result.run.scheduledFor).toISOString()}`, - `workspaceId: ${result.run.workspaceId ?? 'null'}`, - `precheck: ${formatAutomationRunPrecheck(result.run)}`, - `error: ${result.run.error ?? 'null'}` - ].join('\n') -} - -function formatAutomationRunPrecheck(run: AutomationRun): string { - const result = run.precheckResult - if (!result) { - return 'none' - } - const outcome = result.timedOut - ? 'timed out' - : result.error - ? 'error' - : `exit ${result.exitCode ?? 'unknown'}` - const output = result.stderr.trim() || result.stdout.trim() - return output ? `${outcome}; ${output}` : outcome -} - -export function formatAutomationRuns(result: { runs: AutomationRun[] }): string { - if (result.runs.length === 0) { - return 'No automation runs found.' - } - return result.runs - .map( - (run) => - `${run.id} ${run.automationId} ${run.status} ${run.trigger} ${new Date(run.scheduledFor).toISOString()}\n${run.title}${run.precheckResult ? `\nprecheck: ${formatAutomationRunPrecheck(run)}` : ''}${run.error ? `\nerror: ${run.error}` : ''}` - ) - .join('\n\n') -} diff --git a/src/main/automations/automation-dispatch-host-fence.test.ts b/src/main/automations/automation-dispatch-host-fence.test.ts new file mode 100644 index 00000000000..a974ebb4ae0 --- /dev/null +++ b/src/main/automations/automation-dispatch-host-fence.test.ts @@ -0,0 +1,358 @@ +/** + * Removing a host — or removing and re-registering it under the same id — leaves + * hostId, repoId and path untouched, so every other run-target check still passes + * while the host is gone or is a different machine. The list already calls both + * records orphans; without this fence the scheduler keeps firing them anyway. + * + * What must keep dispatching is the record whose scheduler lives on a remote + * server: the list files it as an orphan too, and on that server it is exactly + * what the scheduler exists to run. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { Repo } from '../../shared/repo-types' +import type { SshTarget } from '../../shared/ssh-types' +import type { Automation } from '../../shared/automations-types' +import { AUTOMATION_ORPHAN_ISSUES } from '../../shared/automation-list-scope' +import type { Store } from '../persistence' +import { resolveAutomationRunTarget } from './run-target-resolution' +import { AutomationService } from './service' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' + +const testState = { dir: '' } + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.dir + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length) + } +})) + +async function createStore(): Promise { + vi.resetModules() + installFakeAppEnvironment({ getPath: () => testState.dir }) + const { Store: StoreClass, initDataPath } = await import('../persistence') + initDataPath() + return new StoreClass() +} + +/** The reachable shape: a runtime-owned id is derived, not minted per lifecycle. */ +const TARGET_ID = 'runtime-ssh-recipe-1' + +const REPLACED = + 'The automation host was removed and re-registered, so this automation must be re-adopted before it can run.' + +const MISSING = + 'The automation host is no longer registered, so this automation has nowhere to run.' + +function sshTarget(generation: number | undefined): SshTarget { + return { + id: TARGET_ID, + label: 'devbox', + host: 'devbox.internal', + port: 22, + username: 'orca', + ...(generation === undefined ? {} : { generation }) + } +} + +/** Daily at 09:00 on an SSH host, with whatever generation that host currently has. */ +function seedSshAutomation(store: Store, options: { capture?: boolean } = {}): Automation { + store.addSshTarget(sshTarget(options.capture === false ? undefined : 1)) + store.addRepo({ + id: 'r1', + path: '/repo', + displayName: 'test', + badgeColor: '#fff', + addedAt: 1, + connectionId: TARGET_ID + } as Repo) + return store.createAutomation({ + name: 'Nightly check', + prompt: 'Check the repo', + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'new_per_run', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-12T00:00:00').getTime() + }) +} + +/** A second record refused for a different reason, to prove the two never collide. */ +function seedMissingSetupAutomation(store: Store): Automation { + store.addRepo({ + id: 'r2', + path: '/other', + displayName: 'other', + badgeColor: '#fff', + addedAt: 1 + } as Repo) + const automation = store.createAutomation({ + name: 'Other check', + prompt: 'Check the other repo', + agentId: 'claude', + projectId: 'r2', + workspaceMode: 'new_per_run', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-12T00:00:00').getTime() + }) + // Drift after storage: the setup the record captured no longer exists. The + // create path derives contexts itself, so the drifted record is built here. + return { + ...automation, + runContext: { + kind: 'workspace-run', + projectId: 'project-2', + hostId: 'local', + projectHostSetupId: 'missing-setup', + repoId: 'r2', + path: '/other' + } + } +} + +/** + * The record a remote server's own scheduler owns. The list files it + * `orphan(scheduledElsewhere)`, so a blanket orphan refusal would stop remote + * scheduling everywhere it is the point of the feature. + */ +function seedRemoteScheduledAutomation(store: Store): Automation { + store.addRepo({ + id: 'r3', + path: '/remote', + displayName: 'remote', + badgeColor: '#fff', + addedAt: 1, + executionHostId: 'runtime:vm-1' + } as Repo) + return store.createAutomation({ + name: 'Remote check', + prompt: 'Check the remote repo', + agentId: 'claude', + projectId: 'r3', + workspaceMode: 'new_per_run', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-12T00:00:00').getTime() + }) +} + +/** Remove then re-register under the same id — the only way a generation advances. */ +function reRegisterTarget(store: Store): void { + store.removeSshTarget(TARGET_ID) + store.addSshTarget(sshTarget(store.allocateSshTargetGeneration())) +} + +function attachedService(store: Store): { + service: AutomationService + send: ReturnType +} { + const send = vi.fn() + const service = new AutomationService(store, { tickMs: 60_000 }) + service.setWebContents({ isDestroyed: () => false, send } as never) + return { service, send } +} + +/** One scheduler pass. `nextRunAt` advancing is the signal that it finished. */ +async function evaluateAt( + store: Store, + service: AutomationService, + automationId: string, + when: string +): Promise { + const nextRunAt = (): number | undefined => + store.listAutomations().find((entry) => entry.id === automationId)?.nextRunAt + const before = nextRunAt() + vi.setSystemTime(new Date(when)) + service.setRendererReady() + await vi.waitFor(() => expect(nextRunAt()).toBeGreaterThan(before ?? 0)) +} + +describe('scheduled dispatch fenced on the host the record captured', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-dispatch-fence-test-')) + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('refuses the run once the same id carries a new registration', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00')) + const store = await createStore() + const automation = seedSshAutomation(store) + const { service, send } = attachedService(store) + reRegisterTarget(store) + + await evaluateAt(store, service, automation.id, '2026-05-13T09:01:00') + + expect(send).not.toHaveBeenCalled() + expect(store.listAutomationRuns(automation.id)[0]).toMatchObject({ + status: 'skipped_unavailable', + error: REPLACED + }) + }) + + it('refuses the run once its host is gone entirely', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00')) + const store = await createStore() + const automation = seedSshAutomation(store) + const { service, send } = attachedService(store) + store.removeSshTarget(TARGET_ID) + + await evaluateAt(store, service, automation.id, '2026-05-13T09:01:00') + + expect(send).not.toHaveBeenCalled() + expect(store.listAutomationRuns(automation.id)[0]).toMatchObject({ + status: 'skipped_unavailable', + error: MISSING + }) + }) + + // The trap a blanket orphan refusal would fall into: on a serve-mode host these + // records are the whole feature, and the list calls every one of them an orphan. + it('still dispatches a record whose scheduler lives on a remote server', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00')) + const store = await createStore() + const automation = seedRemoteScheduledAutomation(store) + const send = vi.fn() + const service = new AutomationService(store, { + tickMs: 60_000, + allowRemoteHostScheduling: true + }) + service.setWebContents({ isDestroyed: () => false, send } as never) + + expect(automation.schedulerOwner).toBe('remote_host_service') + expect( + store.listAutomationsForScope().items.find((entry) => entry.automationId === automation.id) + ?.selector + ).toEqual({ kind: 'orphan', issue: AUTOMATION_ORPHAN_ISSUES.scheduledElsewhere }) + + await evaluateAt(store, service, automation.id, '2026-05-13T09:01:00') + + expect(send).toHaveBeenCalledWith('automations:dispatchRequested', expect.anything()) + }) + + // The whole point of the fence: one verdict, so the two can't drift apart. + it('refuses exactly the record the list files as an orphan', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00')) + const store = await createStore() + const automation = seedSshAutomation(store) + reRegisterTarget(store) + + const item = store + .listAutomationsForScope() + .items.find((entry) => entry.automationId === automation.id) + + expect(item?.selector).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.targetReplaced + }) + }) + + it('dispatches while the registration the record captured is still the live one', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00')) + const store = await createStore() + const automation = seedSshAutomation(store) + const { service, send } = attachedService(store) + + await evaluateAt(store, service, automation.id, '2026-05-13T09:01:00') + + expect(send).toHaveBeenCalledWith('automations:dispatchRequested', expect.anything()) + expect(store.listAutomationRuns(automation.id)[0]?.status).toBe('dispatching') + }) + + // Absent is not mismatched: fencing these would silently stop every record + // written before generations existed. + it('dispatches a record that captured no generation at all', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00')) + const store = await createStore() + const automation = seedSshAutomation(store, { capture: false }) + // The migration stamps the target long after the record was written. + store.updateSshTarget(TARGET_ID, { generation: store.allocateSshTargetGeneration() }) + const { service, send } = attachedService(store) + + expect(automation.executionTargetGeneration).toBeUndefined() + await evaluateAt(store, service, automation.id, '2026-05-13T09:01:00') + + expect(send).toHaveBeenCalledWith('automations:dispatchRequested', expect.anything()) + }) + + // A missing run context resolves from bare repo state, which survives the host + // being replaced — so the legacy path must consult the same verdict. + it('refuses a legacy record with no run context once its host was replaced', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00')) + const store = await createStore() + const automation = seedSshAutomation(store) + store.updateAutomation( + automation.id, + { runContext: null }, + { expectedOwner: store.automationOwnerPrecondition(automation.id) ?? undefined } + ) + const { service, send } = attachedService(store) + reRegisterTarget(store) + + await evaluateAt(store, service, automation.id, '2026-05-13T09:01:00') + + expect(send).not.toHaveBeenCalled() + expect(store.listAutomationRuns(automation.id)[0]).toMatchObject({ + status: 'skipped_unavailable', + error: REPLACED + }) + }) + + // The fold in #26 keys on byte-identical error text. A generation number, a host + // label or a timestamp in this message would defeat it and restore one row per + // occurrence; colliding with another diagnosis would merge two of them. + it('refuses in bytes that neither vary nor collide with another skip reason', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00')) + const store = await createStore() + const automation = seedSshAutomation(store) + const other = seedMissingSetupAutomation(store) + + reRegisterTarget(store) + const first = resolveAutomationRunTarget(store, automation) + reRegisterTarget(store) + const second = resolveAutomationRunTarget(store, automation) + + expect(first).toEqual({ ok: false, error: REPLACED }) + // Two different live generations, one string: nothing per-occurrence leaked in. + expect(second).toEqual(first) + expect(resolveAutomationRunTarget(store, other)).toEqual({ + ok: false, + error: 'Project is not set up on the selected automation host anymore.' + }) + // A gone host and a replaced one are different diagnoses and must never fold together. + store.removeSshTarget(TARGET_ID) + expect(resolveAutomationRunTarget(store, automation)).toEqual({ ok: false, error: MISSING }) + }) + + // Composes with the coalescing from #26 rather than fighting it: a refusal the + // user cannot fix from the host side must not write one row per occurrence. + it('folds repeated refusals into a single record', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00')) + const store = await createStore() + const automation = seedSshAutomation(store) + const { service } = attachedService(store) + reRegisterTarget(store) + + await evaluateAt(store, service, automation.id, '2026-05-13T09:01:00') + await evaluateAt(store, service, automation.id, '2026-05-14T09:01:00') + await evaluateAt(store, service, automation.id, '2026-05-15T09:01:00') + + const runs = store.listAutomationRuns(automation.id) + expect(runs).toHaveLength(1) + expect(runs[0]).toMatchObject({ error: REPLACED, occurrenceCount: 3 }) + }) +}) diff --git a/src/main/automations/automation-owner-fencing.test.ts b/src/main/automations/automation-owner-fencing.test.ts new file mode 100644 index 00000000000..02e3419f38a --- /dev/null +++ b/src/main/automations/automation-owner-fencing.test.ts @@ -0,0 +1,312 @@ +/** + * The desktop authority's end of the scoped-list and owner-fenced contracts: + * a parameterless read still answers with everything it stores, a scoped read is + * qualified per row, and no mutation or dispatch may cross an incarnation + * boundary — including one that a client silently declines to name. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { Automation } from '../../shared/automations-types' +import type { Repo } from '../../shared/repo-types' +import type { SshTarget } from '../../shared/ssh-types' +import { getDefaultPersistedState } from '../../shared/constants' +import { AUTOMATION_OWNER_CONFLICT_CODES } from '../../shared/automation-owner-conflict' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' + +const testState = { dir: '' } + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) + +function automation(overrides: Partial): Automation { + return { + id: 'a1', + name: 'Nightly', + prompt: 'go', + precheck: null, + agentId: 'claude', + projectId: 'repo-local', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'new_per_run', + workspaceId: null, + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: 0, + enabled: true, + nextRunAt: 0, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 0, + updatedAt: 0, + ...overrides + } as Automation +} + +const REPOS: Repo[] = [ + { id: 'repo-local', path: '/local', displayName: 'Local', badgeColor: '#000', addedAt: 1 }, + { + id: 'repo-ssh', + path: '/remote', + displayName: 'Remote', + badgeColor: '#000', + addedAt: 2, + connectionId: 'ssh-1' + } +] as Repo[] + +const TARGETS: SshTarget[] = [ + { id: 'ssh-1', label: 'Box', host: 'box', port: 22, username: 'me', generation: 7 } +] as SshTarget[] + +const AUTOMATIONS: Automation[] = [ + automation({ id: 'local-1', name: 'A local' }), + automation({ + id: 'ssh-1-a', + name: 'B ssh', + projectId: 'repo-ssh', + executionTargetType: 'ssh', + executionTargetId: 'ssh-1', + executionTargetGeneration: 7 + }), + automation({ + id: 'orphan-1', + name: 'C orphan', + projectId: 'repo-ssh', + executionTargetType: 'ssh', + executionTargetId: 'ssh-gone', + executionTargetGeneration: 3, + enabled: false + }) +] + +async function createStore() { + mkdirSync(testState.dir, { recursive: true }) + writeFileSync( + join(testState.dir, 'orca-data.json'), + JSON.stringify({ + ...getDefaultPersistedState(testState.dir), + repos: REPOS, + sshTargets: TARGETS, + sshTargetGenerationCounter: 7, + automations: AUTOMATIONS + }), + 'utf-8' + ) + vi.resetModules() + installFakeAppEnvironment({ getPath: () => testState.dir }) + const { Store, initDataPath } = await import('../persistence') + initDataPath() + return new Store() +} + +beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'automation-fencing-')) +}) + +afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) +}) + +const OWNED_SSH = { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } } as const + +describe('scoped automation list', () => { + it('keeps the parameterless list at the authority-complete legacy shape', async () => { + const store = await createStore() + expect(store.listAutomations().map((entry) => entry.id)).toEqual([ + 'local-1', + 'ssh-1-a', + 'orphan-1' + ]) + }) + + it('qualifies every row and reports the authority orphan count', async () => { + const store = await createStore() + const result = store.listAutomationsForScope({ selector: { kind: 'self' } }) + expect(result.automations.map((entry) => entry.id)).toEqual(['local-1']) + expect(result.items).toEqual([ + { automationId: 'local-1', selector: { kind: 'self' }, usageSummary: null } + ]) + expect(result.orphanCount).toBe(1) + }) + + it('answers an SSH scope only for the requested registration', async () => { + const store = await createStore() + const result = store.listAutomationsForScope({ + selector: { kind: 'ssh', targetId: 'ssh-1', expectedTargetGeneration: 7 } + }) + expect(result.automations.map((entry) => entry.id)).toEqual(['ssh-1-a']) + expect(() => + store.listAutomationsForScope({ + selector: { kind: 'ssh', targetId: 'ssh-1', expectedTargetGeneration: 6 } + }) + ).toThrowError(expect.objectContaining({ code: AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged })) + expect(() => + store.listAutomationsForScope({ + selector: { kind: 'ssh', targetId: 'ssh-gone', expectedTargetGeneration: 3 } + }) + ).toThrowError(expect.objectContaining({ code: AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved })) + }) + + it('exposes orphans only under the orphan scope', async () => { + const store = await createStore() + const result = store.listAutomationsForScope({ selector: { kind: 'orphan' } }) + expect(result.automations.map((entry) => entry.id)).toEqual(['orphan-1']) + expect(result.items[0]?.selector.kind).toBe('orphan') + }) +}) + +describe('owner-fenced mutations', () => { + it('passes when the captured owner still matches', async () => { + const store = await createStore() + const updated = store.updateAutomation( + 'ssh-1-a', + { enabled: false }, + { expectedOwner: OWNED_SSH } + ) + expect(updated.enabled).toBe(false) + }) + + it('refuses an update, delete, and dispatch fenced on a stale generation', async () => { + const store = await createStore() + const stale = { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 6 } } as const + const conflict = expect.objectContaining({ + code: AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged + }) + expect(() => + store.updateAutomation('ssh-1-a', { enabled: false }, { expectedOwner: stale }) + ).toThrowError(conflict) + expect(() => store.deleteAutomation('ssh-1-a', { expectedOwner: stale })).toThrowError(conflict) + expect(() => + store.assertAutomationOwnerFence({ + id: 'ssh-1-a', + expectedOwner: stale, + operation: 'execute' + }) + ).toThrowError(conflict) + expect(store.listAutomations().find((entry) => entry.id === 'ssh-1-a')?.enabled).toBe(true) + }) + + // Optional on the wire is not unenforced: a caller that names no host may not + // mutate a record fenced to an SSH registration. + it('refuses an ownerless mutation of a generation-bearing SSH record', async () => { + const store = await createStore() + const required = expect.objectContaining({ + code: AUTOMATION_OWNER_CONFLICT_CODES.fencingRequired + }) + expect(() => store.updateAutomation('ssh-1-a', { enabled: false })).toThrowError(required) + expect(() => store.deleteAutomation('ssh-1-a')).toThrowError(required) + expect(() => + store.assertAutomationOwnerFence({ id: 'ssh-1-a', operation: 'execute' }) + ).toThrowError(required) + expect(store.listAutomations().find((entry) => entry.id === 'ssh-1-a')?.enabled).toBe(true) + expect(store.listAutomations()).toHaveLength(3) + }) + + it('leaves a legacy client unaffected on a self record', async () => { + const store = await createStore() + expect(store.updateAutomation('local-1', { enabled: false }).enabled).toBe(false) + store.deleteAutomation('local-1') + expect(store.listAutomations().map((entry) => entry.id)).toEqual(['ssh-1-a', 'orphan-1']) + }) + + it('keeps delete available on an orphan but refuses to dispatch it', async () => { + const store = await createStore() + expect(() => + store.assertAutomationOwnerFence({ id: 'orphan-1', operation: 'execute' }) + ).toThrowError(expect.objectContaining({ code: AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved })) + store.deleteAutomation('orphan-1', { expectedOwner: { selector: { kind: 'orphan' } } }) + expect(store.listAutomations().map((entry) => entry.id)).toEqual(['local-1', 'ssh-1-a']) + }) + + it('rejects a create whose destination is not a current registration', async () => { + const store = await createStore() + const input = { + name: 'New', + prompt: 'go', + agentId: 'claude' as const, + projectId: 'repo-ssh', + workspaceMode: 'new_per_run' as const, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: 0 + } + expect(() => + store.createAutomation(input, { + destination: { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 6 } } + }) + ).toThrowError( + expect.objectContaining({ code: AUTOMATION_OWNER_CONFLICT_CODES.invalidDestination }) + ) + expect(() => + store.createAutomation(input, { destination: { selector: { kind: 'self' } } }) + ).toThrowError( + expect.objectContaining({ code: AUTOMATION_OWNER_CONFLICT_CODES.invalidDestination }) + ) + const created = store.createAutomation(input, { + destination: { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } } + }) + expect(created.executionTargetGeneration).toBe(7) + }) +}) + +/** + * A client with no target registry of its own reads the owner, then acts on it. + * The projection is what makes the fence satisfiable off-host rather than a wall. + */ +describe('projected owner precondition', () => { + it('reuses the complete projection until automation state changes', async () => { + const store = await createStore() + const first = store.listAutomationsForScope() + expect(store.listAutomationsForScope()).toBe(first) + + store.updateAutomation('local-1', { enabled: false }) + expect(store.listAutomationsForScope()).not.toBe(first) + }) + + it('projects the fenceable owner for each kind of record', async () => { + const store = await createStore() + expect(store.automationOwnerPrecondition('ssh-1-a')).toEqual(OWNED_SSH) + expect(store.automationOwnerPrecondition('local-1')).toEqual({ selector: { kind: 'self' } }) + // The orphan issue is diagnosis, not identity, so it stays out of the precondition. + expect(store.automationOwnerPrecondition('orphan-1')).toEqual({ + selector: { kind: 'orphan' } + }) + expect(store.automationOwnerPrecondition('missing')).toBeNull() + }) + + it('satisfies the fence it was projected from, on the record that used to refuse', async () => { + const store = await createStore() + const owner = store.automationOwnerPrecondition('ssh-1-a')! + expect(() => store.updateAutomation('ssh-1-a', { enabled: false })).toThrowError( + expect.objectContaining({ code: AUTOMATION_OWNER_CONFLICT_CODES.fencingRequired }) + ) + expect( + store.updateAutomation('ssh-1-a', { enabled: false }, { expectedOwner: owner }).enabled + ).toBe(false) + }) + + // Execute is refused whatever the caller names: an orphan has no host to run on. + it('does not let an orphan precondition unlock a dispatch', async () => { + const store = await createStore() + const owner = store.automationOwnerPrecondition('orphan-1')! + expect(() => + store.assertAutomationOwnerFence({ + id: 'orphan-1', + expectedOwner: owner, + operation: 'execute' + }) + ).toThrowError(expect.objectContaining({ code: AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved })) + store.deleteAutomation('orphan-1', { expectedOwner: owner }) + expect(store.listAutomations().map((entry) => entry.id)).not.toContain('orphan-1') + }) +}) diff --git a/src/main/automations/automation-owner-migration.test.ts b/src/main/automations/automation-owner-migration.test.ts new file mode 100644 index 00000000000..78f58d55bbd --- /dev/null +++ b/src/main/automations/automation-owner-migration.test.ts @@ -0,0 +1,428 @@ +import { describe, expect, it } from 'vitest' +import { migrateAutomationOwners } from './automation-owner-migration' +import { + AUTOMATION_ORPHAN_ISSUES, + projectAutomationSelector +} from '../../shared/automation-list-scope' +import type { Automation } from '../../shared/automations-types' +import type { SshTarget } from '../../shared/ssh-types' +import type { FolderWorkspace } from '../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../shared/project-group-types' +import type { Repo } from '../../shared/repo-types' +import { folderWorkspaceKey } from '../../shared/workspace-scope' + +const NOW = 1_700_000_000_000 + +function makeAutomation(overrides: Partial = {}): Automation { + return { + id: 'auto-1', + name: 'Nightly', + prompt: 'go', + precheck: null, + agentId: 'codex', + projectId: 'repo-1', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'new_per_run', + workspaceId: null, + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: NOW, + enabled: true, + nextRunAt: NOW, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: NOW, + updatedAt: NOW, + ...overrides + } +} + +function makeTarget(overrides: Partial = {}): SshTarget { + return { + id: 'ssh-1', + label: 'Dev box', + host: 'dev.example.com', + port: 22, + username: 'tim', + ...overrides + } +} + +function makeRepo(overrides: Partial = {}): Repo { + return { + id: 'repo-1', + path: '/tmp/repo', + displayName: 'Repo One', + badgeColor: '#fff', + addedAt: NOW, + ...overrides + } +} + +function migrate(input: { + automations?: Automation[] + sshTargets?: SshTarget[] + repos?: Repo[] + folderWorkspaces?: FolderWorkspace[] + projectGroups?: ProjectGroup[] + sshTargetGenerationCounter?: number + storageAuthority?: 'desktop' | 'runtime' +}) { + return migrateAutomationOwners({ + automations: input.automations ?? [], + sshTargets: input.sshTargets ?? [], + repos: input.repos ?? [], + folderWorkspaces: input.folderWorkspaces, + projectGroups: input.projectGroups, + sshTargetGenerationCounter: input.sshTargetGenerationCounter, + storageAuthority: input.storageAuthority + }) +} + +/** A folder workspace rooted on an SSH host: its scope connection pins every record inside it. */ +function pinnedWorkspaceState(): { + folderWorkspaces: FolderWorkspace[] + projectGroups: ProjectGroup[] +} { + return { + projectGroups: [ + { + id: 'group-1', + name: 'Group', + parentPath: '/srv', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: NOW, + updatedAt: NOW + } + ], + folderWorkspaces: [ + { + id: 'fw-1', + projectGroupId: 'group-1', + name: 'Remote root', + folderPath: '/srv/remote', + connectionId: 'ssh-1', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: NOW, + createdAt: NOW, + updatedAt: NOW + } + ] + } +} + +/** The same workspace after its execution host was changed outside the automation editor. */ +function repinnedWorkspaceState(connectionId: string): { + folderWorkspaces: FolderWorkspace[] + projectGroups: ProjectGroup[] +} { + const state = pinnedWorkspaceState() + return { + ...state, + folderWorkspaces: state.folderWorkspaces.map((workspace) => ({ ...workspace, connectionId })) + } +} + +function pinnedLocalAutomation(overrides: Partial = {}): Automation { + return makeAutomation({ + workspaceMode: 'existing', + workspaceId: folderWorkspaceKey('fw-1'), + ...overrides + }) +} + +describe('migrateAutomationOwners', () => { + it('stamps generations on legacy targets and lets a live SSH automation adopt one', () => { + const result = migrate({ + automations: [ + makeAutomation({ + executionTargetType: 'ssh', + executionTargetId: 'ssh-1', + schedulerOwner: 'ssh_bridge' + }) + ], + sshTargets: [makeTarget()] + }) + expect(result.changed).toBe(true) + expect(result.sshTargets[0].generation).toBe(1) + expect(result.automations[0].executionTargetGeneration).toBe(1) + expect(result.automations[0].enabled).toBe(true) + expect(result.sshTargetGenerationCounter).toBe(1) + }) + + it('is idempotent', () => { + const first = migrate({ + automations: [ + makeAutomation({ + executionTargetType: 'ssh', + executionTargetId: 'ssh-1', + schedulerOwner: 'ssh_bridge' + }), + makeAutomation({ id: 'auto-2', executionTargetType: 'ssh', executionTargetId: 'ssh-gone' }) + ], + sshTargets: [makeTarget()], + repos: [makeRepo()] + }) + const second = migrateAutomationOwners({ + automations: first.automations, + sshTargets: first.sshTargets, + repos: [makeRepo()], + sshTargetGenerationCounter: first.sshTargetGenerationCounter + }) + expect(second.changed).toBe(false) + expect(second.automations).toEqual(first.automations) + expect(second.sshTargets).toEqual(first.sshTargets) + }) + + // Refusing to run an orphan is dispatch's live verdict, not a persisted write: + // the record keeps its selector and its enablement untouched. + it('leaves an automation whose target is gone in place without rewriting or disabling it', () => { + const automation = makeAutomation({ + executionTargetType: 'ssh', + executionTargetId: 'ssh-gone', + schedulerOwner: 'ssh_bridge' + }) + const result = migrate({ + automations: [automation], + repos: [makeRepo({ connectionId: 'ssh-gone' })] + }) + const migrated = result.automations[0] + expect(migrated).toEqual(automation) + expect(migrated.executionTargetGeneration).toBeUndefined() + }) + + it.each([ + { label: 'schedulerOwner', overrides: { schedulerOwner: 'remote_host_service' as const } }, + { + label: 'runContext.hostId', + overrides: { + runContext: { + kind: 'workspace-run' as const, + projectId: 'p', + hostId: 'runtime:env-1' as const, + projectHostSetupId: 's', + repoId: 'repo-1', + path: '/tmp/repo' + } + } + } + ])( + 'leaves an ambiguous desktop record flagged by $label unstamped and in place', + ({ overrides }) => { + const result = migrate({ automations: [makeAutomation(overrides)] }) + const migrated = result.automations[0] + expect(migrated.enabled).toBe(true) + expect(migrated.executionTargetGeneration).toBeUndefined() + expect(migrated.executionTargetType).toBe('local') + expect(migrated.executionTargetId).toBe('local') + expect(result.automations).toHaveLength(1) + } + ) + + it.each([ + { label: 'schedulerOwner', overrides: { schedulerOwner: 'remote_host_service' as const } }, + { + label: 'runContext.hostId', + overrides: { + runContext: { + kind: 'workspace-run' as const, + projectId: 'p', + hostId: 'runtime:env-1' as const, + projectHostSetupId: 's', + repoId: 'repo-1', + path: '/tmp/repo' + } + } + } + ])('keeps a runtime-owned record flagged by $label enabled and owned', ({ overrides }) => { + const result = migrate({ + automations: [makeAutomation(overrides)], + repos: [makeRepo()], + storageAuthority: 'runtime' + }) + const migrated = result.automations[0] + expect(migrated.enabled).toBe(true) + expect( + projectAutomationSelector(migrated, { + storageAuthority: 'runtime', + sshTargetGeneration: () => undefined, + repoConnectionId: () => null + }) + ).toEqual({ kind: 'self' }) + }) + + it('leaves a differing captured generation alone so the record stays a replaced orphan', () => { + const result = migrate({ + automations: [ + makeAutomation({ + executionTargetType: 'ssh', + executionTargetId: 'ssh-1', + schedulerOwner: 'ssh_bridge', + executionTargetGeneration: 4 + }) + ], + sshTargets: [makeTarget({ generation: 9 })], + sshTargetGenerationCounter: 9 + }) + const migrated = result.automations[0] + expect(migrated.executionTargetGeneration).toBe(4) + expect(migrated.enabled).toBe(true) + expect( + projectAutomationSelector(migrated, { + sshTargetGeneration: () => 9, + repoConnectionId: () => 'ssh-1' + }) + ).toEqual({ kind: 'orphan', issue: AUTOMATION_ORPHAN_ISSUES.targetReplaced }) + }) + + it('leaves a healthy local automation untouched', () => { + const result = migrate({ automations: [makeAutomation()] }) + expect(result.changed).toBe(false) + expect(result.automations[0].enabled).toBe(true) + }) + + it('preserves unknown persisted fields on migrated records and targets', () => { + const automation = { + ...makeAutomation({ executionTargetType: 'ssh', executionTargetId: 'ssh-1' }), + futureField: 'keep' + } + const target = { ...makeTarget(), futureTargetField: 'keep' } + const result = migrate({ + automations: [automation as Automation], + sshTargets: [target as SshTarget] + }) + expect(result.automations[0]).toMatchObject({ futureField: 'keep' }) + expect(result.sshTargets[0]).toMatchObject({ futureTargetField: 'keep' }) + }) + + it('backfills the pinned registration for a local record its folder workspace puts on SSH', () => { + const result = migrate({ + automations: [pinnedLocalAutomation()], + sshTargets: [makeTarget({ generation: 4 })], + sshTargetGenerationCounter: 4, + ...pinnedWorkspaceState() + }) + expect(result.changed).toBe(true) + expect(result.automations[0].executionTargetType).toBe('local') + expect(result.automations[0].executionTargetGeneration).toBe(4) + }) + + it('never overwrites a differing capture on a pinned record, so it stays a replaced orphan', () => { + const workspace = pinnedWorkspaceState() + const result = migrate({ + automations: [pinnedLocalAutomation({ executionTargetGeneration: 4 })], + sshTargets: [makeTarget({ generation: 9 })], + sshTargetGenerationCounter: 9, + ...workspace + }) + const migrated = result.automations[0] + expect(migrated.executionTargetGeneration).toBe(4) + expect(migrated.enabled).toBe(true) + expect( + projectAutomationSelector(migrated, { + sshTargetGeneration: () => 9, + repoConnectionId: () => null, + workspaceHost: () => ({ kind: 'ssh', targetId: 'ssh-1' }) + }) + ).toEqual({ kind: 'orphan', issue: AUTOMATION_ORPHAN_ISSUES.targetReplaced }) + }) + + it('is idempotent for pinned records', () => { + const workspace = pinnedWorkspaceState() + const first = migrate({ + automations: [pinnedLocalAutomation()], + sshTargets: [makeTarget({ generation: 4 })], + sshTargetGenerationCounter: 4, + ...workspace + }) + const second = migrateAutomationOwners({ + automations: first.automations, + sshTargets: first.sshTargets, + repos: [], + ...workspace, + sshTargetGenerationCounter: first.sshTargetGenerationCounter + }) + expect(second.changed).toBe(false) + expect(second.automations).toEqual(first.automations) + }) + + it('leaves an unpinned local record without a capture', () => { + const result = migrate({ + automations: [makeAutomation()], + sshTargets: [makeTarget({ generation: 4 })], + sshTargetGenerationCounter: 4, + ...pinnedWorkspaceState() + }) + expect(result.automations[0].executionTargetGeneration).toBeUndefined() + }) + + // The pin, not the project, is what dispatch resolves; a pin this authority cannot + // vouch for stays unstamped so it classifies like an unresolvable SSH selector. + it('never stamps a pinned local record whose pin names an unregistered target', () => { + const result = migrate({ + automations: [pinnedLocalAutomation()], + sshTargets: [], + ...pinnedWorkspaceState() + }) + const migrated = result.automations[0] + expect(migrated.enabled).toBe(true) + expect(migrated.executionTargetGeneration).toBeUndefined() + expect(migrated.executionTargetType).toBe('local') + expect(migrated.executionTargetId).toBe('local') + }) + + // Absence of evidence: with no workspace list supplied, nothing proves the record is pinned. + it('leaves a record alone when no workspace list was supplied to prove a pin', () => { + const result = migrate({ automations: [pinnedLocalAutomation()], sshTargets: [] }) + expect(result.automations[0].enabled).toBe(true) + expect(result.automations[0].executionTargetGeneration).toBeUndefined() + }) + + it('follows an external re-pin onto the registration the workspace names now', () => { + const result = migrate({ + automations: [pinnedLocalAutomation({ executionTargetGeneration: 4 })], + sshTargets: [makeTarget({ generation: 4 }), makeTarget({ id: 'ssh-2', generation: 9 })], + sshTargetGenerationCounter: 9, + ...repinnedWorkspaceState('ssh-2') + }) + const migrated = result.automations[0] + expect(migrated.executionTargetGeneration).toBe(9) + expect(migrated.enabled).toBe(true) + expect( + projectAutomationSelector(migrated, { + sshTargetGeneration: (targetId) => (targetId === 'ssh-2' ? 9 : 4), + repoConnectionId: () => null, + workspaceHost: () => ({ kind: 'ssh', targetId: 'ssh-2' }) + }) + ).toEqual({ kind: 'ssh', targetId: 'ssh-2', targetGeneration: 9 }) + }) + + it('cannot reissue a generation an automation already captured after a counter rollback', () => { + const result = migrate({ + automations: [ + makeAutomation({ + executionTargetType: 'ssh', + executionTargetId: 'ssh-gone', + executionTargetGeneration: 9 + }) + ], + sshTargets: [makeTarget({ id: 'ssh-new' })], + sshTargetGenerationCounter: 2 + }) + expect(result.sshTargets[0].generation).toBe(10) + expect(result.sshTargetGenerationCounter).toBe(10) + }) +}) diff --git a/src/main/automations/automation-owner-migration.ts b/src/main/automations/automation-owner-migration.ts new file mode 100644 index 00000000000..34d18cdc1a3 --- /dev/null +++ b/src/main/automations/automation-owner-migration.ts @@ -0,0 +1,236 @@ +import type { Automation } from '../../shared/automations-types' +import type { SshTarget } from '../../shared/ssh-types' +import type { FolderWorkspace } from '../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../shared/project-group-types' +import type { Repo } from '../../shared/repo-types' +import { parseExecutionHostId } from '../../shared/execution-host' +import { + resolveAutomationWorkspaceSshTargetId, + type AutomationWorkspaceSshPin +} from '../../shared/automation-workspace-pin' +import { + isWorkspaceSshPinRepinned, + type SshTargetIdForGeneration +} from '../../shared/automation-workspace-repin' +import type { FolderWorkspaceHostState } from '../../shared/folder-workspace-execution-host' +import { + nextSshTargetGeneration, + resolveSshTargetGenerationHighWaterMark, + sanitizeSshTargetGeneration +} from '../../shared/ssh-target-generation' + +/** + * One idempotent load-time migration that gives every stored automation a + * fenceable owner: generations are stamped on live targets, then automations + * adopt or are classified. Running it twice changes nothing. Classification only + * gates adoption here — refusing to run an orphan is dispatch's live verdict + * (`resolveAutomationRunTarget`), not a persisted write this migration makes. + */ + +export type AutomationOwnerClassification = 'owned' | 'orphan' | 'ambiguous' + +/** + * Runtime markers belong to a runtime store; the same markers are ambiguous desktop mirrors. + * + * `knownSshTargetIds` is the authority's own persisted registry, so a miss is + * settled evidence rather than a list that has not loaded. `workspacePin` is + * likewise only supplied once a workspace has been resolved: an absent pin means + * nothing proves the record is pinned, never that its host is gone. + */ +export function classifyAutomationOwner( + automation: Automation, + knownSshTargetIds: ReadonlySet, + workspacePin?: AutomationWorkspaceSshPin, + storageAuthority: 'desktop' | 'runtime' = 'desktop' +): AutomationOwnerClassification { + if ( + storageAuthority !== 'runtime' && + (automation.schedulerOwner === 'remote_host_service' || + parseExecutionHostId(automation.runContext?.hostId)?.kind === 'runtime') + ) { + return 'ambiguous' + } + if (automation.executionTargetType !== 'ssh') { + // The pin is where the run goes, so a pin this authority cannot vouch for is the same + // unrunnable record dispatch refuses — classifying it `owned` left it enabled and editable. + if (!workspacePin) { + return 'owned' + } + const captured = sanitizeSshTargetGeneration(automation.executionTargetGeneration) + return knownSshTargetIds.has(workspacePin.targetId) && + (captured === undefined || captured === workspacePin.generation) + ? 'owned' + : 'orphan' + } + return automation.executionTargetId && knownSshTargetIds.has(automation.executionTargetId) + ? 'owned' + : 'orphan' +} + +export type AutomationOwnerMigrationInput = { + automations: readonly Automation[] + sshTargets: readonly SshTarget[] + repos: readonly Repo[] + /** Needed to resolve which SSH target a record's folder workspace pins it to. */ + folderWorkspaces?: readonly FolderWorkspace[] + projectGroups?: readonly ProjectGroup[] + sshTargetGenerationCounter?: number + storageAuthority?: 'desktop' | 'runtime' +} + +export type AutomationOwnerMigrationResult = { + automations: Automation[] + sshTargets: SshTarget[] + sshTargetGenerationCounter: number + changed: boolean +} + +type StampedTargets = { targets: SshTarget[]; counter: number; changed: boolean } + +function stampTargetGenerations( + sshTargets: readonly SshTarget[], + highWaterMark: number +): StampedTargets { + let counter = highWaterMark + let changed = false + const targets = sshTargets.map((target) => { + if (sanitizeSshTargetGeneration(target.generation) !== undefined) { + return target + } + counter = nextSshTargetGeneration(counter) + changed = true + return { ...target, generation: counter } + }) + return { targets, counter, changed } +} + +type MigratedAutomations = { automations: Automation[]; changed: boolean } + +/** + * Only an absent capture is stamped: overwriting a differing one would re-adopt + * the record onto the target that replaced the incarnation it was attached to. + */ +function stampCapturedGeneration( + automation: Automation, + ownerTargetId: string | undefined, + targetsById: ReadonlyMap +): Automation { + const generation = + ownerTargetId === undefined ? undefined : targetsById.get(ownerTargetId)?.generation + if ( + generation === undefined || + sanitizeSshTargetGeneration(automation.executionTargetGeneration) !== undefined + ) { + return automation + } + return { ...automation, executionTargetGeneration: generation } +} + +/** + * A local record its workspace pins to SSH runs on that registration, so it is + * fenced by it too — the pin owns the capture the way the selector does for SSH rows. + */ +function resolveWorkspacePin( + automation: Automation, + workspaceState: FolderWorkspaceHostState, + targetsById: ReadonlyMap +): AutomationWorkspaceSshPin | undefined { + if (automation.executionTargetType === 'ssh') { + return undefined + } + const targetId = resolveAutomationWorkspaceSshTargetId(workspaceState, automation.workspaceId) + return targetId === undefined + ? undefined + : { targetId, generation: targetsById.get(targetId)?.generation } +} + +/** A re-pinned workspace moved the record's host, so the capture moves with it. */ +function followWorkspaceRepin( + automation: Automation, + pin: AutomationWorkspaceSshPin | undefined, + sshTargetIdForGeneration: SshTargetIdForGeneration +): Automation { + return isWorkspaceSshPinRepinned({ + capturedGeneration: automation.executionTargetGeneration, + pin, + sshTargetIdForGeneration + }) + ? { ...automation, executionTargetGeneration: pin?.generation } + : automation +} + +function indexTargetsByGeneration( + targetsById: ReadonlyMap +): SshTargetIdForGeneration { + const owners = new Map() + for (const target of targetsById.values()) { + const generation = sanitizeSshTargetGeneration(target.generation) + if (generation !== undefined) { + owners.set(generation, target.id) + } + } + return (generation) => owners.get(generation) +} + +function migrateAutomations( + automations: readonly Automation[], + targetsById: ReadonlyMap, + workspaceState: FolderWorkspaceHostState, + storageAuthority: 'desktop' | 'runtime' +): MigratedAutomations { + const knownIds = new Set(targetsById.keys()) + const sshTargetIdForGeneration = indexTargetsByGeneration(targetsById) + let changed = false + const migrated = automations.map((automation) => { + const pin = resolveWorkspacePin(automation, workspaceState, targetsById) + // Before classification: a followed re-pin is a healthy record, not an orphan. + const record = followWorkspaceRepin(automation, pin, sshTargetIdForGeneration) + const classification = classifyAutomationOwner(record, knownIds, pin, storageAuthority) + // Orphans and ambiguous records are never moved, rewritten to Self, stamped, + // or deleted; dispatch refuses them live rather than this migration writing state. + if (classification !== 'owned') { + changed ||= record !== automation + return record + } + const ownerTargetId = + record.executionTargetType === 'ssh' ? record.executionTargetId : pin?.targetId + const stamped = stampCapturedGeneration(record, ownerTargetId, targetsById) + if (stamped !== automation) { + changed = true + } + return stamped + }) + return { automations: migrated, changed } +} + +export function migrateAutomationOwners( + input: AutomationOwnerMigrationInput +): AutomationOwnerMigrationResult { + const highWaterMark = resolveSshTargetGenerationHighWaterMark({ + persistedCounter: input.sshTargetGenerationCounter, + targetGenerations: input.sshTargets.map((target) => target.generation), + capturedGenerations: input.automations.map((automation) => automation.executionTargetGeneration) + }) + const stamped = stampTargetGenerations(input.sshTargets, highWaterMark) + const targetsById = new Map(stamped.targets.map((target) => [target.id, target])) + const migrated = migrateAutomations( + input.automations, + targetsById, + { + folderWorkspaces: [...(input.folderWorkspaces ?? [])], + projectGroups: [...(input.projectGroups ?? [])], + repos: [...input.repos] + }, + input.storageAuthority ?? 'desktop' + ) + return { + automations: migrated.automations, + sshTargets: stamped.targets, + sshTargetGenerationCounter: stamped.counter, + changed: + stamped.changed || + migrated.changed || + // A rolled-back counter must be rewritten even when nothing else changed. + stamped.counter !== (sanitizeSshTargetGeneration(input.sshTargetGenerationCounter) ?? 0) + } +} diff --git a/src/main/automations/automation-run-terminal-surface.test.ts b/src/main/automations/automation-run-terminal-surface.test.ts new file mode 100644 index 00000000000..21f350093a1 --- /dev/null +++ b/src/main/automations/automation-run-terminal-surface.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../runtime/orca-runtime' +import { HEADLESS_RUNTIME_WINDOW_ID } from '../../shared/runtime-types' +import { createRuntimeAutomationRunTerminalObserver } from './runtime-terminal-run-observer' +import type { AutomationRun } from '../../shared/automations-types' +import type { Repo } from '../../shared/repo-types' + +const repo: Repo = { + id: 'repo-1', + path: '/tmp/orca', + displayName: 'orca', + badgeColor: 'blue', + addedAt: 1, + kind: 'git' +} + +const TAB_ID = 'tab-1' +const LEAF_ID = '11111111-2222-4333-8444-555555555555' +const PANE_KEY = `${TAB_ID}:${LEAF_ID}` +const WORKTREE_ID = '/tmp/orca::wt1' + +function makeStore() { + return { + getRepos: vi.fn(() => [repo]), + listAutomations: vi.fn(() => []), + listAutomationRuns: vi.fn(() => []), + getSettings: vi.fn(() => ({ + workspaceDir: '/tmp', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: '', + branchPrefixCustom: '' + })), + getAllWorktreeMeta: vi.fn(() => new Map()), + getWorktreeMeta: vi.fn(), + setWorktreeMeta: vi.fn(), + removeWorktreeMeta: vi.fn(), + getGitHubCache: vi.fn() + } +} + +const retainedRun = { + id: 'run-1', + automationId: 'auto-1', + terminalPaneKey: PANE_KEY, + status: 'dispatched' +} as unknown as AutomationRun + +/** + * The gate the startup fix depends on: an empty window graph and a bound PTY are + * two different answers, and only the second is evidence about the terminal. + */ +describe('resolving an automation run terminal across startup states', () => { + it('cannot answer for any pane while no graph has published', () => { + const runtime = new OrcaRuntimeService(makeStore() as never) + const observer = createRuntimeAutomationRunTerminalObserver(runtime) + + expect(observer.resolveRunTerminal(retainedRun)).toBeNull() + }) + + it('still cannot answer once an explicitly empty headless graph publishes', () => { + // Serve publishes exactly this graph before arming automations, so leaves are + // empty there too — serve is not saved by graph ordering alone. + const runtime = new OrcaRuntimeService(makeStore() as never) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + const observer = createRuntimeAutomationRunTerminalObserver(runtime) + + expect(observer.resolveRunTerminal(retainedRun)).toBeNull() + }) + + it('answers from an adopted PTY bound to the pane, with no leaves at all', () => { + // What serve's pre-start refreshRestoredOrchestrationAuthority produces: a + // ptysById record carrying the restored paneKey, never a leaf. + const runtime = new OrcaRuntimeService(makeStore() as never) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + runtime.registerPty('pty-1', WORKTREE_ID, null, { tabId: TAB_ID, leafId: LEAF_ID }) + const observer = createRuntimeAutomationRunTerminalObserver(runtime) + + expect(observer.resolveRunTerminal(retainedRun)).toBeTruthy() + }) + + it('answers only after the pane binds, so an early lookup is not a lost terminal', () => { + const runtime = new OrcaRuntimeService(makeStore() as never) + const observer = createRuntimeAutomationRunTerminalObserver(runtime) + + expect(observer.resolveRunTerminal(retainedRun)).toBeNull() + runtime.registerPty('pty-1', WORKTREE_ID, null, { tabId: TAB_ID, leafId: LEAF_ID }) + expect(observer.resolveRunTerminal(retainedRun)).toBeTruthy() + }) +}) diff --git a/src/main/automations/automation-run-writer.test.ts b/src/main/automations/automation-run-writer.test.ts new file mode 100644 index 00000000000..a149f28ea38 --- /dev/null +++ b/src/main/automations/automation-run-writer.test.ts @@ -0,0 +1,88 @@ +/** + * Run and usage writes are the highest-frequency events in the system, so they + * carry the host they belong to: an unscoped one refetches every host in the + * catalog. + */ +import { describe, expect, it, vi } from 'vitest' +import { createAutomationRunWriter } from './automation-run-writer' +import { AutomationService } from './service' +import type { Store } from '../persistence' +import type { Automation, AutomationRun } from '../../shared/automations-types' + +const SSH_SELECTOR = { kind: 'ssh', targetId: 'ssh-1' } as const + +function writerWith(selector: ReturnType) { + const publish = vi.fn() + const automationChangeSelector = vi.fn(() => selector) + const store = { + createAutomationRun: vi.fn(() => ({ id: 'run-1', automationId: 'auto-1' }) as AutomationRun), + updateAutomationRun: vi.fn(() => ({ id: 'run-1', automationId: 'auto-1' }) as AutomationRun), + automationChangeSelector + } as unknown as Store + return { + publish, + automationChangeSelector, + writer: createAutomationRunWriter(store, publish), + store + } +} + +describe('automation run writer publications', () => { + it('names the host a created run belongs to', () => { + const { writer, publish } = writerWith(SSH_SELECTOR) + writer.createRun({ id: 'auto-1' } as Automation, 0, 'scheduled') + expect(publish).toHaveBeenCalledWith({ reason: 'run', selector: SSH_SELECTOR }) + }) + + it('resolves the host from the written run, which is all a dispatch result names', () => { + const { writer, publish, automationChangeSelector } = writerWith(SSH_SELECTOR) + writer.updateRun({ runId: 'run-1', status: 'completed', usage: null }) + expect(automationChangeSelector).toHaveBeenCalledWith('auto-1') + expect(publish).toHaveBeenCalledWith({ reason: 'run', selector: SSH_SELECTOR }) + }) + + it('keeps the usage reason on a usage-bearing write', () => { + const { writer, publish } = writerWith({ kind: 'self' }) + writer.updateRun({ + runId: 'run-1', + status: 'completed', + usage: { status: 'known' } as AutomationRun['usage'] + }) + expect(publish).toHaveBeenCalledWith({ reason: 'usage', selector: { kind: 'self' } }) + }) + + // Over-broad beats silent: a subscriber must still hear that something changed. + it('falls back to the whole authority when the record can no longer be named', () => { + const { writer, publish } = writerWith(null) + writer.createRun({ id: 'auto-1' } as Automation, 0, 'scheduled') + expect(publish).toHaveBeenCalledWith({ reason: 'run' }) + }) + + it('does not project a selector nobody will hear', () => { + const automationChangeSelector = vi.fn(() => SSH_SELECTOR) + const store = { + createAutomationRun: vi.fn(() => ({ id: 'run-1', automationId: 'auto-1' }) as AutomationRun), + automationChangeSelector + } as unknown as Store + createAutomationRunWriter(store, null).createRun({ id: 'auto-1' } as Automation, 0, 'scheduled') + expect(automationChangeSelector).not.toHaveBeenCalled() + }) + + // Why the renderer dispatch path no longer emits its own: the scoped event is + // published during the write, so it is queued before the reply the caller awaits. + it('publishes before markDispatchResult settles', async () => { + const publish = vi.fn() + const store = { + updateAutomationRun: vi.fn( + () => ({ id: 'run-1', automationId: 'auto-1', status: 'dispatched' }) as AutomationRun + ), + automationChangeSelector: vi.fn(() => SSH_SELECTOR) + } as unknown as Store + const service = new AutomationService(store, { onAutomationsChanged: publish }) + + const settled = service.markDispatchResult({ runId: 'run-1', status: 'dispatched' }) + + expect(publish).toHaveBeenCalledWith({ reason: 'run', selector: SSH_SELECTOR }) + await settled + }) +}) diff --git a/src/main/automations/automation-run-writer.ts b/src/main/automations/automation-run-writer.ts new file mode 100644 index 00000000000..ef08c84de49 --- /dev/null +++ b/src/main/automations/automation-run-writer.ts @@ -0,0 +1,47 @@ +import type { Store } from '../persistence' +import type { PublishAutomationsChanged } from '../../shared/runtime-client-events' +import type { AutomationDispatchResult, AutomationRun } from '../../shared/automations-types' + +export type AutomationRunWriter = { + createRun: Store['createAutomationRun'] + updateRun: Store['updateAutomationRun'] + /** Null when nothing could be folded — the caller then writes an ordinary run. */ + repeatSkip: Store['recordRepeatedAutomationSkip'] +} + +/** Wraps run persistence so every committed write announces itself. Clients with + * the Automations page closed — or none attached at all — have no other way to + * learn that a run progressed, so the event must follow the write, not a render. */ +export function createAutomationRunWriter( + store: Store, + publish: PublishAutomationsChanged | null +): AutomationRunWriter { + // A run write never moves the record, so its own host is the whole publication. + // A record that can no longer be named degrades to the authority-wide event. + const announce = (automationId: string, reason: 'run' | 'usage'): void => { + if (!publish) { + return + } + const selector = store.automationChangeSelector(automationId) + publish({ reason, ...(selector ? { selector } : {}) }) + } + return { + createRun: (automation, scheduledFor, trigger): AutomationRun => { + const run = store.createAutomationRun(automation, scheduledFor, trigger) + announce(automation.id, 'run') + return run + }, + updateRun: (result: AutomationDispatchResult): AutomationRun => { + const run = store.updateAutomationRun(result) + announce(run.automationId, result.usage ? 'usage' : 'run') + return run + }, + repeatSkip: (automationId, error, scheduledFor): AutomationRun | null => { + const run = store.recordRepeatedAutomationSkip(automationId, error, scheduledFor) + if (run) { + announce(automationId, 'run') + } + return run + } + } +} diff --git a/src/main/automations/automation-skip-coalescing.test.ts b/src/main/automations/automation-skip-coalescing.test.ts new file mode 100644 index 00000000000..5ab58c1e831 --- /dev/null +++ b/src/main/automations/automation-skip-coalescing.test.ts @@ -0,0 +1,302 @@ +/** + * A target that cannot resolve refuses every occurrence, and the refusal record + * must stay — a user who sees nothing concludes the feature is broken. What must + * not stay is one row per occurrence: retention keeps 100 runs per automation, so + * a five-minute schedule would bury its real history in under a day. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { Repo } from '../../shared/repo-types' +import type { Automation } from '../../shared/automations-types' +import type { Store } from '../persistence' +import { AutomationService } from './service' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' + +const testState = { dir: '' } + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.dir + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length) + } +})) + +async function createStore(): Promise { + vi.resetModules() + installFakeAppEnvironment({ getPath: () => testState.dir }) + const { Store: StoreClass, initDataPath } = await import('../persistence') + initDataPath() + return new StoreClass() +} + +const makeRepo = (): Repo => ({ + id: 'r1', + path: '/repo', + displayName: 'test', + badgeColor: '#fff', + addedAt: 1 +}) + +const SETUP_GONE = 'Project is not set up on the selected automation host anymore.' + +const NO_WINDOW = 'No Orca window was available to launch the automation.' + +/** + * Daily at 09:00, pointed at a project host setup that does not exist. The + * unresolvable context is written into the stored record and reloaded, as drift + * after creation would leave it — the create path derives contexts itself. + */ +async function seedUnresolvableAutomation(): Promise<{ store: Store; automation: Automation }> { + const seed = await createStore() + seed.addRepo(makeRepo()) + const automation = seed.createAutomation({ + name: 'Nightly check', + prompt: 'Check the repo', + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'new_per_run', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-12T00:00:00').getTime() + }) + const file = join(testState.dir, 'orca-data.json') + const state = JSON.parse(readFileSync(file, 'utf-8')) + state.automations[0].runContext = { + kind: 'workspace-run', + projectId: 'project-1', + hostId: 'local', + projectHostSetupId: 'missing-setup', + repoId: 'r1', + path: '/repo' + } + writeFileSync(file, JSON.stringify(state, null, 2), 'utf-8') + return { store: await createStore(), automation } +} + +/** Daily at 09:00 against a repo that resolves, so only the window can refuse it. */ +function seedRunnableAutomation(store: Store): Automation { + store.addRepo(makeRepo()) + return store.createAutomation({ + name: 'Nightly check', + prompt: 'Check the repo', + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'new_per_run', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-12T00:00:00').getTime() + }) +} + +function attachedService(store: Store): AutomationService { + const service = new AutomationService(store, { tickMs: 60_000 }) + service.setWebContents({ isDestroyed: () => false, send: vi.fn() } as never) + return service +} + +/** One scheduler pass. `nextRunAt` advancing is the signal that it finished. */ +async function evaluateAt( + store: Store, + service: AutomationService, + automationId: string, + when: string +): Promise { + const nextRunAt = (): number | undefined => + store.listAutomations().find((entry) => entry.id === automationId)?.nextRunAt + const before = nextRunAt() + vi.setSystemTime(new Date(when)) + service.setRendererReady() + await vi.waitFor(() => expect(nextRunAt()).toBeGreaterThan(before ?? 0)) +} + +function seedSkip(store: Store, automation: Automation, scheduledFor: number, error: string): void { + const run = store.createAutomationRun(automation, scheduledFor) + store.updateAutomationRun({ + runId: run.id, + status: 'skipped_unavailable', + workspaceId: null, + error + }) +} + +describe('repeated skipped_unavailable coalescing', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-skip-coalescing-test-')) + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('folds every later refusal into the first record instead of writing a row each', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00')) + const { store, automation } = await seedUnresolvableAutomation() + const service = attachedService(store) + + await evaluateAt(store, service, automation.id, '2026-05-13T09:01:00') + await evaluateAt(store, service, automation.id, '2026-05-14T09:01:00') + await evaluateAt(store, service, automation.id, '2026-05-15T09:01:00') + + const runs = store.listAutomationRuns(automation.id) + expect(runs).toHaveLength(1) + expect(runs[0]).toMatchObject({ + status: 'skipped_unavailable', + error: SETUP_GONE, + occurrenceCount: 3, + // The record keeps the first occurrence and names the latest it stands for. + scheduledFor: new Date('2026-05-13T09:00:00').getTime(), + lastOccurrenceAt: new Date('2026-05-15T09:00:00').getTime() + }) + }) + + /** + * Closing the window on macOS keeps the scheduler running — nothing stops it + * until quit — so this refusal repeats on the schedule's own cadence and must + * fold like any other, or a five-minute automation buries its history in ~8h. + */ + it('folds the refusals a closed window produces instead of writing a row each', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00')) + const store = await createStore() + const automation = seedRunnableAutomation(store) + const service = attachedService(store) + + await evaluateAt(store, service, automation.id, '2026-05-13T09:01:00') + service.setWebContents(null) + await evaluateAt(store, service, automation.id, '2026-05-14T09:01:00') + await evaluateAt(store, service, automation.id, '2026-05-15T09:01:00') + await evaluateAt(store, service, automation.id, '2026-05-16T09:01:00') + + const runs = store.listAutomationRuns(automation.id) + expect(runs.map((run) => run.status)).toEqual(['skipped_unavailable', 'dispatching']) + expect(runs[0]).toMatchObject({ + error: NO_WINDOW, + occurrenceCount: 3, + lastOccurrenceAt: new Date('2026-05-16T09:00:00').getTime() + }) + }) + + it('starts a new record when a real run intervened, so history is not rewritten', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00')) + const { store, automation } = await seedUnresolvableAutomation() + const completed = store.createAutomationRun( + automation, + new Date('2026-05-12T09:00:00').getTime() + ) + store.updateAutomationRun({ runId: completed.id, status: 'completed', workspaceId: null }) + const service = attachedService(store) + + await evaluateAt(store, service, automation.id, '2026-05-13T09:01:00') + await evaluateAt(store, service, automation.id, '2026-05-14T09:01:00') + + const runs = store.listAutomationRuns(automation.id) + expect(runs.map((run) => run.status)).toEqual(['skipped_unavailable', 'completed']) + expect(runs[0].occurrenceCount).toBe(2) + }) + + it('gives a manual attempt its own answer rather than folding it into a scheduled skip', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00')) + const { store, automation } = await seedUnresolvableAutomation() + const service = attachedService(store) + + await evaluateAt(store, service, automation.id, '2026-05-13T09:01:00') + const manual = await service.runNow(automation.id) + + expect(manual.status).toBe('skipped_unavailable') + expect(manual.occurrenceCount).toBeUndefined() + expect(store.listAutomationRuns(automation.id)).toHaveLength(2) + }) + + it('never folds a scheduled refusal into a manual one', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00')) + const { store, automation } = await seedUnresolvableAutomation() + const manual = store.createAutomationRun(automation, Date.now(), 'manual') + store.updateAutomationRun({ + runId: manual.id, + status: 'skipped_unavailable', + workspaceId: null, + error: SETUP_GONE + }) + + expect( + store.recordRepeatedAutomationSkip(automation.id, SETUP_GONE, Date.now() + 1000) + ).toBeNull() + }) + + // Merging these would destroy the signal the record exists to carry. + it('keeps distinct diagnoses in distinct records', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00')) + const { store, automation } = await seedUnresolvableAutomation() + seedSkip(store, automation, Date.now(), 'The SSH host is unreachable.') + + expect( + store.recordRepeatedAutomationSkip(automation.id, SETUP_GONE, Date.now() + 1000) + ).toBeNull() + }) + + it('counts one occurrence once, however many times the scheduler revisits it', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00')) + const { store, automation } = await seedUnresolvableAutomation() + const scheduledFor = Date.now() + seedSkip(store, automation, scheduledFor, SETUP_GONE) + + expect( + store.recordRepeatedAutomationSkip(automation.id, SETUP_GONE, scheduledFor)?.occurrenceCount + ).toBeUndefined() + const folded = store.recordRepeatedAutomationSkip( + automation.id, + SETUP_GONE, + scheduledFor + 1000 + ) + expect(folded?.occurrenceCount).toBe(2) + expect( + store.recordRepeatedAutomationSkip(automation.id, SETUP_GONE, scheduledFor + 1000) + ?.occurrenceCount + ).toBe(2) + }) + + it('announces the fold, so a client that is not polling still sees the repeat', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00')) + const { store, automation } = await seedUnresolvableAutomation() + const published: unknown[] = [] + const service = new AutomationService(store, { + tickMs: 60_000, + onAutomationsChanged: (payload) => published.push(payload) + }) + service.setWebContents({ isDestroyed: () => false, send: vi.fn() } as never) + + await evaluateAt(store, service, automation.id, '2026-05-13T09:01:00') + published.length = 0 + await evaluateAt(store, service, automation.id, '2026-05-14T09:01:00') + + expect(published).toEqual([{ reason: 'run', selector: { kind: 'self' } }]) + }) + + // Rollback shape: a build that never heard of these fields writes plain rows and + // preserves stamped ones, so an upgrade resumes counting instead of restarting. + it('resumes counting a folded record across a reload', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00')) + const { store, automation } = await seedUnresolvableAutomation() + const scheduledFor = Date.now() + seedSkip(store, automation, scheduledFor, SETUP_GONE) + store.recordRepeatedAutomationSkip(automation.id, SETUP_GONE, scheduledFor + 1000) + store.flush() + + const { Store: StoreClass } = await import('../persistence') + const reloaded = new StoreClass() + + expect(reloaded.listAutomationRuns(automation.id)[0]?.occurrenceCount).toBe(2) + expect( + reloaded.recordRepeatedAutomationSkip(automation.id, SETUP_GONE, scheduledFor + 2000) + ?.occurrenceCount + ).toBe(3) + }) +}) diff --git a/src/main/automations/automation-ssh-readoption-migration.test.ts b/src/main/automations/automation-ssh-readoption-migration.test.ts new file mode 100644 index 00000000000..21333aa6801 --- /dev/null +++ b/src/main/automations/automation-ssh-readoption-migration.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect } from 'vitest' +import { + migrateAutomationHostFilterSshTargetId, + migrateAutomationsForSshReadoption +} from './automation-ssh-readoption-migration' +import { hostStableKey } from '../../shared/automation-owner-key' +import type { Automation, AutomationRun } from '../../shared/automations-types' +import type { PersistedUIState } from '../../shared/persisted-ui-state-types' + +const NOW = 1_700_000_000_000 +const OLD_ID = 'ssh-1738-a9f3x' +const NEW_ID = 'ssh-1799-b2k7z' + +function makeAutomation(overrides: Partial = {}): Automation { + return { + id: 'auto-1', + name: 'Nightly', + prompt: 'go', + precheck: null, + agentId: 'codex', + projectId: 'repo-1', + executionTargetType: 'ssh', + executionTargetId: OLD_ID, + executionTargetGeneration: 3, + schedulerOwner: 'local_host_service', + workspaceMode: 'new_per_run', + workspaceId: null, + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: NOW, + enabled: true, + nextRunAt: NOW, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: NOW, + updatedAt: NOW, + ...overrides + } +} + +function migrate( + automations: Automation[], + generation: number | undefined, + runs: AutomationRun[] = [] +) { + return migrateAutomationsForSshReadoption({ + automations, + automationRuns: runs, + oldTargetId: OLD_ID, + newTargetId: NEW_ID, + newTargetGeneration: generation + }) +} + +function runContext(hostId: string) { + return { + kind: 'workspace-run' as const, + projectId: 'project-1', + hostId: hostId as `ssh:${string}`, + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + path: '/srv/repo' + } +} + +function uiWithFilter(hostKey: string): PersistedUIState { + return { automationHostFilter: { kind: 'host', hostKey } } as PersistedUIState +} + +describe('migrateAutomationsForSshReadoption', () => { + it('re-points the owner and adopts the re-added registration generation', () => { + const automation = makeAutomation() + expect(migrate([automation], 9)).toBe(true) + expect(automation.executionTargetId).toBe(NEW_ID) + expect(automation.executionTargetGeneration).toBe(9) + }) + + it('drops a stale capture when the re-added target carries no generation', () => { + const automation = makeAutomation() + expect(migrate([automation], undefined)).toBe(true) + expect(automation.executionTargetId).toBe(NEW_ID) + expect('executionTargetGeneration' in automation).toBe(false) + }) + + it('migrates run and source context host ids, including for local records', () => { + const automation = makeAutomation({ + executionTargetType: 'local', + executionTargetId: 'local', + executionTargetGeneration: undefined, + runContext: runContext(`ssh:${OLD_ID}`), + sourceContext: { + kind: 'task-source', + provider: 'github', + projectId: 'project-1', + hostId: `ssh:${OLD_ID}` + } + }) + expect(migrate([automation], 9)).toBe(true) + expect(automation.runContext?.hostId).toBe(`ssh:${NEW_ID}`) + expect(automation.sourceContext?.hostId).toBe(`ssh:${NEW_ID}`) + // A local record's execution target is not an SSH reference and must stay untouched. + expect(automation.executionTargetId).toBe('local') + expect(automation.executionTargetGeneration).toBeUndefined() + }) + + it('leaves automations on other targets alone', () => { + const other = makeAutomation({ id: 'auto-2', executionTargetId: 'ssh-other' }) + expect(migrate([other], 9)).toBe(false) + expect(other.executionTargetId).toBe('ssh-other') + expect(other.executionTargetGeneration).toBe(3) + }) + + it('re-points run history so workspace navigation survives re-adoption', () => { + const run = { + id: 'run-1', + automationId: 'auto-1', + runContext: runContext(`ssh:${OLD_ID}`) + } as AutomationRun + expect(migrate([], 9, [run])).toBe(true) + expect(run.runContext?.hostId).toBe(`ssh:${NEW_ID}`) + }) +}) + +describe('migrateAutomationHostFilterSshTargetId', () => { + it('follows the re-adopted id', () => { + const ui = uiWithFilter( + hostStableKey({ authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: OLD_ID } }) + ) + expect(migrateAutomationHostFilterSshTargetId(ui, OLD_ID, NEW_ID)).toBe(true) + expect(ui.automationHostFilter).toEqual({ + kind: 'host', + hostKey: hostStableKey({ + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId: NEW_ID } + }) + }) + }) + + it('never rewrites a runtime authority filter that reuses the same target id', () => { + const hostKey = hostStableKey({ + authority: { kind: 'runtime', environmentId: 'env-1' }, + selector: { kind: 'ssh', targetId: OLD_ID } + }) + const ui = uiWithFilter(hostKey) + expect(migrateAutomationHostFilterSshTargetId(ui, OLD_ID, NEW_ID)).toBe(false) + expect(ui.automationHostFilter).toEqual({ kind: 'host', hostKey }) + }) + + it('ignores All hosts, other hosts, and unparseable persisted values', () => { + const all = { automationHostFilter: { kind: 'all' } } as PersistedUIState + expect(migrateAutomationHostFilterSshTargetId(all, OLD_ID, NEW_ID)).toBe(false) + const selfKey = hostStableKey({ + authority: { kind: 'desktop' }, + selector: { kind: 'self' } + }) + expect(migrateAutomationHostFilterSshTargetId(uiWithFilter(selfKey), OLD_ID, NEW_ID)).toBe( + false + ) + expect(migrateAutomationHostFilterSshTargetId(uiWithFilter('garbage'), OLD_ID, NEW_ID)).toBe( + false + ) + // A store written before the filter existed must migrate without throwing. + expect(migrateAutomationHostFilterSshTargetId({} as PersistedUIState, OLD_ID, NEW_ID)).toBe( + false + ) + }) +}) diff --git a/src/main/automations/automation-ssh-readoption-migration.ts b/src/main/automations/automation-ssh-readoption-migration.ts new file mode 100644 index 00000000000..29a561ef898 --- /dev/null +++ b/src/main/automations/automation-ssh-readoption-migration.ts @@ -0,0 +1,131 @@ +import type { Automation, AutomationRun } from '../../shared/automations-types' +import type { PersistedUIState } from '../../shared/persisted-ui-state-types' +import type { TaskSourceContext, WorkspaceRunContext } from '../../shared/task-source-context' +import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import { hostStableKey, parseHostStableKey } from '../../shared/automation-owner-key' +import { sanitizeSshTargetGeneration } from '../../shared/ssh-target-generation' + +/** + * The automation half of SSH re-adoption (see ssh/ssh-target-readoption.ts). + * + * `reassignSshTargetId` re-points repos and worktrees onto the re-added target's + * id; the same transaction must move every automation-side reference too, or the + * workspace repairs while its automation stays orphaned on the dead id and the + * persisted host filter keeps naming a host that no longer exists. + * + * The captured generation is rewritten to the re-added registration's rather + * than left to fail closed: an identity-matched removal tombstone plus a live + * registration is exactly the positive current-target evidence the design + * requires, and the value is read from the stored target at migration time — so + * it can never exceed the allocated high-water mark, and an older incarnation + * can never be made to look current. + */ + +type HostContextCarrier = { + runContext?: WorkspaceRunContext | null + sourceContext?: TaskSourceContext | null +} + +/** Mutates in place, matching how the Store edits `this.state`. */ +function migrateContextHostIds( + record: HostContextCarrier, + oldHostId: string, + newHostId: ExecutionHostId +): boolean { + let changed = false + if (record.runContext?.hostId === oldHostId) { + record.runContext.hostId = newHostId + changed = true + } + if (record.sourceContext?.hostId === oldHostId) { + record.sourceContext.hostId = newHostId + changed = true + } + return changed +} + +export type AutomationSshReadoptionInput = { + automations: Automation[] + automationRuns: AutomationRun[] + oldTargetId: string + newTargetId: string + /** + * Records the old target owned through their folder workspace rather than their + * selector, collected before the workspace sweep re-points the pin. Their + * capture is the pin's, so re-adoption has to move it with everything else. + */ + workspacePinnedAutomationIds?: ReadonlySet + /** The re-added target's current registration generation, read from stored state. */ + newTargetGeneration: number | undefined +} + +/** Why: a re-added target with no generation yet must not leave a foreign capture behind — drop it so load-time migration stamps it. */ +function rewriteCapturedGeneration(automation: Automation, generation: number | undefined): void { + if (generation === undefined) { + delete automation.executionTargetGeneration + } else { + automation.executionTargetGeneration = generation + } +} + +/** Re-point stored automations and their run history onto the re-added target. */ +export function migrateAutomationsForSshReadoption(input: AutomationSshReadoptionInput): boolean { + const oldHostId = toSshExecutionHostId(input.oldTargetId) + const newHostId = toSshExecutionHostId(input.newTargetId) + const generation = sanitizeSshTargetGeneration(input.newTargetGeneration) + let changed = false + for (const automation of input.automations) { + if (migrateContextHostIds(automation, oldHostId, newHostId)) { + changed = true + } + if ( + automation.executionTargetType !== 'ssh' || + automation.executionTargetId !== input.oldTargetId + ) { + // A workspace-pinned record keeps its local selector; only its capture follows the pin. + if (input.workspacePinnedAutomationIds?.has(automation.id)) { + rewriteCapturedGeneration(automation, generation) + changed = true + } + continue + } + automation.executionTargetId = input.newTargetId + rewriteCapturedGeneration(automation, generation) + changed = true + } + for (const run of input.automationRuns) { + if (migrateContextHostIds(run, oldHostId, newHostId)) { + changed = true + } + } + return changed +} + +/** Follow the re-adopted id in the persisted Automations host filter. */ +export function migrateAutomationHostFilterSshTargetId( + ui: PersistedUIState, + oldTargetId: string, + newTargetId: string +): boolean { + const filter = ui.automationHostFilter + if (filter?.kind !== 'host') { + return false + } + const host = parseHostStableKey(filter.hostKey) + // Why: a target id is unique only inside one authority, so a runtime's identical id is a different host. + if ( + host?.authority.kind !== 'desktop' || + host.selector.kind !== 'ssh' || + host.selector.targetId !== oldTargetId + ) { + return false + } + ui.automationHostFilter = { + kind: 'host', + hostKey: hostStableKey({ + authority: host.authority, + selector: { kind: 'ssh', targetId: newTargetId } + }) + } + return true +} diff --git a/src/main/automations/automation-update-host-retarget.test.ts b/src/main/automations/automation-update-host-retarget.test.ts new file mode 100644 index 00000000000..d52bd29e0c3 --- /dev/null +++ b/src/main/automations/automation-update-host-retarget.test.ts @@ -0,0 +1,248 @@ +/** + * An update may not move a record to another host unless it was asked to. + * + * Pause/resume stay enabled on orphans on purpose, so a plain `{ enabled }` + * update is the one mutation an unrunnable record still accepts. Re-deriving the + * execution target from the owning repo on that path re-adopts the record onto + * whatever registration the repo points at today: the user pauses an orphan and + * silently gets a healthy, dispatchable automation on a host they never chose. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { Automation } from '../../shared/automations-types' +import type { Repo } from '../../shared/repo-types' +import type { SshTarget } from '../../shared/ssh-types' +import { getDefaultPersistedState } from '../../shared/constants' +import { AUTOMATION_ORPHAN_ISSUES } from '../../shared/automation-list-scope' +import { AUTOMATION_OWNER_CONFLICT_CODES } from '../../shared/automation-owner-conflict' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' + +const testState = { dir: '' } + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) + +function automation(overrides: Partial): Automation { + return { + id: 'a1', + name: 'Nightly', + prompt: 'go', + precheck: null, + agentId: 'claude', + projectId: 'repo-readded', + executionTargetType: 'ssh', + executionTargetId: 'ssh-old', + executionTargetGeneration: 4, + schedulerOwner: 'ssh_bridge', + workspaceMode: 'new_per_run', + workspaceId: null, + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: 0, + enabled: true, + nextRunAt: 0, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 0, + updatedAt: 0, + ...overrides + } as Automation +} + +const REPOS: Repo[] = [ + // Re-adoption re-pointed this repo onto the freshly minted target. + { + id: 'repo-readded', + path: '/remote', + displayName: 'Remote', + badgeColor: '#000', + addedAt: 1, + connectionId: 'ssh-new' + }, + // Same target id back with a new registration: the repo resolves, the capture does not. + { + id: 'repo-same-id', + path: '/remote2', + displayName: 'Remote 2', + badgeColor: '#000', + addedAt: 2, + connectionId: 'ssh-box' + }, + { id: 'repo-local', path: '/local', displayName: 'Local', badgeColor: '#000', addedAt: 3 } +] as Repo[] + +const TARGETS: SshTarget[] = [ + { id: 'ssh-new', label: 'Box', host: 'box', port: 22, username: 'me', generation: 7 }, + { id: 'ssh-box', label: 'Box 2', host: 'box2', port: 22, username: 'me', generation: 9 } +] as SshTarget[] + +const AUTOMATIONS: Automation[] = [ + automation({ id: 'stranded' }), + automation({ + id: 'replaced', + projectId: 'repo-same-id', + executionTargetId: 'ssh-box', + executionTargetGeneration: 4 + }), + // Its project is local now, so re-deriving would strip the capture and call it Self. + automation({ + id: 'local-project', + projectId: 'repo-local', + schedulerOwner: 'local_host_service' + }), + automation({ + id: 'healthy', + projectId: 'repo-same-id', + executionTargetId: 'ssh-box', + executionTargetGeneration: 9 + }) +] + +async function createStore() { + mkdirSync(testState.dir, { recursive: true }) + writeFileSync( + join(testState.dir, 'orca-data.json'), + JSON.stringify({ + ...getDefaultPersistedState(testState.dir), + repos: REPOS, + sshTargets: TARGETS, + sshTargetGenerationCounter: 9, + automations: AUTOMATIONS + }), + 'utf-8' + ) + vi.resetModules() + installFakeAppEnvironment({ getPath: () => testState.dir }) + const { Store, initDataPath } = await import('../persistence') + initDataPath() + return new Store() +} + +type Store = Awaited> + +function storedTarget(record: Automation) { + return { + type: record.executionTargetType, + id: record.executionTargetId, + generation: record.executionTargetGeneration + } +} + +function projectedSelector(store: Store, id: string) { + return store.listAutomationsForScope().items.find((item) => item.automationId === id)?.selector +} + +const ORPHAN = { selector: { kind: 'orphan' } } as const + +beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'automation-retarget-')) +}) + +afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + vi.resetModules() +}) + +describe('pausing an orphan never re-adopts it', () => { + it('keeps the dead capture when the repo was re-pointed at a new registration', async () => { + const store = await createStore() + expect(projectedSelector(store, 'stranded')).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.targetMissing + }) + + const paused = store.updateAutomation('stranded', { enabled: false }, { expectedOwner: ORPHAN }) + + expect(paused.enabled).toBe(false) + expect(storedTarget(paused)).toEqual({ type: 'ssh', id: 'ssh-old', generation: 4 }) + expect(projectedSelector(store, 'stranded')).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.targetMissing + }) + }) + + it('leaves the record unrunnable on the host the repo now points at', async () => { + const store = await createStore() + + store.updateAutomation('stranded', { enabled: false }, { expectedOwner: ORPHAN }) + + // Run Now against the re-added registration must still be refused; a pause is not a re-adoption. + expect(() => + store.assertAutomationOwnerFence({ + id: 'stranded', + expectedOwner: { selector: { kind: 'ssh', targetId: 'ssh-new', targetGeneration: 7 } }, + operation: 'execute' + }) + ).toThrowError(expect.objectContaining({ code: AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved })) + }) + + it('keeps the stale generation when the same target id came back re-registered', async () => { + const store = await createStore() + expect(projectedSelector(store, 'replaced')).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.targetReplaced + }) + + const paused = store.updateAutomation('replaced', { enabled: false }, { expectedOwner: ORPHAN }) + + expect(storedTarget(paused)).toEqual({ type: 'ssh', id: 'ssh-box', generation: 4 }) + expect(projectedSelector(store, 'replaced')).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.targetReplaced + }) + }) + + it('does not strip the capture and rewrite an orphan to Self', async () => { + const store = await createStore() + + const paused = store.updateAutomation( + 'local-project', + { enabled: false }, + { expectedOwner: ORPHAN } + ) + + expect(storedTarget(paused)).toEqual({ type: 'ssh', id: 'ssh-old', generation: 4 }) + expect(projectedSelector(store, 'local-project')).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.targetMissing + }) + }) +}) + +describe('an update that was asked to move still moves', () => { + it('re-derives from an explicitly supplied project', async () => { + const store = await createStore() + + const moved = store.updateAutomation( + 'healthy', + { projectId: 'repo-readded' }, + { + expectedOwner: { selector: { kind: 'ssh', targetId: 'ssh-box', targetGeneration: 9 } }, + destination: { selector: { kind: 'ssh', targetId: 'ssh-new', targetGeneration: 7 } } + } + ) + + expect(storedTarget(moved)).toEqual({ type: 'ssh', id: 'ssh-new', generation: 7 }) + }) + + it('leaves a fenced in-place edit on its own host', async () => { + const store = await createStore() + + const renamed = store.updateAutomation( + 'healthy', + { name: 'Renamed' }, + { expectedOwner: { selector: { kind: 'ssh', targetId: 'ssh-box', targetGeneration: 9 } } } + ) + + expect(renamed.name).toBe('Renamed') + expect(storedTarget(renamed)).toEqual({ type: 'ssh', id: 'ssh-box', generation: 9 }) + }) +}) diff --git a/src/main/automations/automation-workspace-host-attribution.test.ts b/src/main/automations/automation-workspace-host-attribution.test.ts new file mode 100644 index 00000000000..e5d66c27dc2 --- /dev/null +++ b/src/main/automations/automation-workspace-host-attribution.test.ts @@ -0,0 +1,260 @@ +/** + * A folder workspace can pin its execution host while its repo stays local. + * The desktop authority used to project only the repo, so those records listed + * under Self while dispatch sent them to an SSH host. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { Automation } from '../../shared/automations-types' +import type { FolderWorkspace } from '../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../shared/project-group-types' +import type { ProjectHostSetup } from '../../shared/project-types' +import type { Repo } from '../../shared/repo-types' +import type { SshTarget } from '../../shared/ssh-types' +import { getDefaultPersistedState } from '../../shared/constants' +import { AUTOMATION_ORPHAN_ISSUES } from '../../shared/automation-list-scope' +import { AutomationService } from './service' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' + +const testState = { dir: '' } + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) + +const REPOS: Repo[] = [ + { + id: 'repo-local', + path: '/work/app/repo', + displayName: 'Local', + badgeColor: '#000', + addedAt: 1, + projectGroupId: 'group-1' + } +] as Repo[] + +const TARGETS: SshTarget[] = [ + { id: 'ssh-1', label: 'Box', host: 'box', port: 22, username: 'me', generation: 7 } +] as SshTarget[] + +function folderWorkspace(overrides: Partial): FolderWorkspace { + return { + id: 'fw-1', + projectGroupId: 'group-1', + name: 'App', + folderPath: '/work/app', + ...overrides + } as FolderWorkspace +} + +const GROUPS: ProjectGroup[] = [ + { id: 'group-1', name: 'Work', parentPath: '/work' } +] as ProjectGroup[] + +/** One local repo and one SSH repo in the same scope: no single host owns it. */ +const AMBIGUOUS_REPOS: Repo[] = [ + ...REPOS, + { + id: 'repo-remote', + path: '/work/app/remote', + displayName: 'Remote', + badgeColor: '#000', + addedAt: 2, + projectGroupId: 'group-1', + connectionId: 'ssh-1' + } as Repo +] + +const AUTOMATION: Automation = { + id: 'pinned-1', + name: 'Nightly', + prompt: 'go', + precheck: null, + agentId: 'claude', + projectId: 'repo-local', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'existing', + workspaceId: 'folder:fw-1', + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: 0, + enabled: true, + nextRunAt: 0, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 0, + updatedAt: 0 +} as Automation + +async function createStore( + folderWorkspaces: FolderWorkspace[], + repos: Repo[] = REPOS, + extra: { automations?: Automation[]; projectHostSetups?: ProjectHostSetup[] } = {} +) { + mkdirSync(testState.dir, { recursive: true }) + writeFileSync( + join(testState.dir, 'orca-data.json'), + JSON.stringify({ + ...getDefaultPersistedState(testState.dir), + repos, + projectGroups: GROUPS, + folderWorkspaces, + sshTargets: TARGETS, + sshTargetGenerationCounter: 7, + projectHostSetups: extra.projectHostSetups ?? [], + automations: extra.automations ?? [AUTOMATION] + }), + 'utf-8' + ) + vi.resetModules() + installFakeAppEnvironment({ getPath: () => testState.dir }) + const { Store, initDataPath } = await import('../persistence') + initDataPath() + return new Store() +} + +beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'automation-workspace-host-')) +}) + +afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) +}) + +describe('folder-workspace host attribution', () => { + // The stored pin is `connectionId`; `executionHostId` is a renderer-side stamp that + // normalization drops, so main can only ever see this form. + it('files a pinned workspace under its SSH host instead of Self', async () => { + const store = await createStore([folderWorkspace({ connectionId: 'ssh-1' })], []) + + expect(store.listAutomationsForScope({ selector: { kind: 'self' } }).automations).toEqual([]) + const scoped = store.listAutomationsForScope({ + selector: { kind: 'ssh', targetId: 'ssh-1', expectedTargetGeneration: 7 } + }) + expect(scoped.automations.map((entry) => entry.id)).toEqual(['pinned-1']) + expect(scoped.items[0]?.selector).toEqual({ + kind: 'ssh', + targetId: 'ssh-1', + targetGeneration: 7 + }) + }) + + it('orphans a workspace whose scope spans two hosts rather than showing a healthy Self row', async () => { + const store = await createStore([folderWorkspace({})], AMBIGUOUS_REPOS) + + expect(store.listAutomationsForScope({ selector: { kind: 'self' } }).automations).toEqual([]) + const orphans = store.listAutomationsForScope({ selector: { kind: 'orphan' } }) + expect(orphans.items[0]?.selector).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.workspaceHostAmbiguous + }) + }) + + it('leaves an unpinned local workspace on Self', async () => { + const store = await createStore([folderWorkspace({})]) + + expect( + store.listAutomationsForScope({ selector: { kind: 'self' } }).automations.map((e) => e.id) + ).toEqual(['pinned-1']) + }) +}) + +const AMBIGUOUS_REFUSAL = + 'The automation workspace spans more than one host, so Orca cannot tell which one to run it on.' + +/** + * Built through the store rather than seeded: load-time projection replaces any + * repo-backed setup written by hand, so a literal run context would point at a + * setup that no longer exists and be refused for that instead of the ambiguity. + */ +async function ambiguousStore() { + vi.setSystemTime(new Date('2026-05-13T08:00:00')) + const store = await createStore([folderWorkspace({})], AMBIGUOUS_REPOS, { automations: [] }) + const automation = store.createAutomation({ + name: 'Nightly', + prompt: 'go', + agentId: 'claude', + projectId: 'repo-local', + workspaceMode: 'existing', + workspaceId: 'folder:fw-1', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-12T00:00:00').getTime() + }) + return { store, automation } +} + +/** One scheduler pass. `nextRunAt` advancing is the signal that it finished. */ +async function evaluated( + seeded: Awaited>, + start: () => void +): Promise { + vi.setSystemTime(new Date('2026-05-13T09:01:00')) + start() + await vi.waitFor(() => + expect(seeded.store.listAutomations()[0]?.nextRunAt).toBeGreaterThan( + seeded.automation.nextRunAt + ) + ) +} + +/** + * The list calls this record an orphan, so the authority that owns it must refuse + * to run it. Resolution would otherwise pick one of the two hosts on its own and + * run the user's prompt somewhere they never chose — on a host they may not trust. + */ +describe('dispatch refused for an ambiguous workspace host', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'automation-ambiguous-dispatch-')) + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('refuses the desktop dispatch and records why', async () => { + const seeded = await ambiguousStore() + const send = vi.fn() + const service = new AutomationService(seeded.store, { tickMs: 60_000 }) + service.setWebContents({ isDestroyed: () => false, send } as never) + + await evaluated(seeded, () => service.setRendererReady()) + + expect(send).not.toHaveBeenCalled() + expect(seeded.store.listAutomationRuns(seeded.automation.id)[0]).toMatchObject({ + status: 'skipped_unavailable', + error: AMBIGUOUS_REFUSAL + }) + service.stop() + }) + + // Serve mode has no renderer to disable actions, so the authority is the only guard. + it('refuses the headless dispatch, which has no ambiguity check of its own', async () => { + const seeded = await ambiguousStore() + const dispatcher = vi.fn(async () => ({ workspaceId: 'folder:fw-1', terminalSessionId: 't1' })) + const service = new AutomationService(seeded.store, { + tickMs: 60_000, + headlessDispatcher: dispatcher + }) + + await evaluated(seeded, () => service.start()) + + expect(dispatcher).not.toHaveBeenCalled() + expect(seeded.store.listAutomationRuns(seeded.automation.id)[0]).toMatchObject({ + status: 'skipped_unavailable', + error: AMBIGUOUS_REFUSAL + }) + service.stop() + }) +}) diff --git a/src/main/automations/dispatch-refusal.ts b/src/main/automations/dispatch-refusal.ts new file mode 100644 index 00000000000..7d7fdc4d140 --- /dev/null +++ b/src/main/automations/dispatch-refusal.ts @@ -0,0 +1,53 @@ +/** + * What the owning authority says, and writes, when it refuses to start a run. + * + * Kept together because every refusal must be one fixed sentence: skip + * coalescing folds repeats only on byte-identical text, so a reason that varied + * per occurrence would write a row each. + */ +import type { Store } from '../persistence' +import type { Automation } from '../../shared/automations-types' +import { resolveAutomationRunTarget, type AutomationRunTargetResult } from './run-target-resolution' +import type { AutomationRunWriter } from './automation-run-writer' + +export const NO_DISPATCH_HOST = 'No Orca window was available to launch the automation.' + +/** A record the authority refuses to execute at all, with no target diagnosis of its own. */ +export const NO_RUNNABLE_HOST = 'This automation has no host to run on.' + +/** Every reason this occurrence cannot start, decided before a run row exists so + * the scheduler can fold repeats instead of writing one row each. */ +export function describeScheduledRefusal(input: { + target: AutomationRunTargetResult + canDispatch: boolean +}): string | null { + if (!input.target.ok) { + return input.target.error + } + return input.canDispatch ? null : NO_DISPATCH_HOST +} + +/** + * Records the manual attempt an execute fence refused before dispatch existed. + * + * The typed conflict answers the caller; run history is what answers the user, + * and doc:94 asks for both. Never dispatches: the reason is the one the + * scheduler would have written for the same record. + */ +export function recordRefusedAutomationRun(input: { + store: Store + runs: AutomationRunWriter + automation: Automation + allowRemoteHostScheduling: boolean +}): void { + const target = resolveAutomationRunTarget(input.store, input.automation, { + allowRemoteHostScheduling: input.allowRemoteHostScheduling + }) + const run = input.runs.createRun(input.automation, Date.now(), 'manual') + input.runs.updateRun({ + runId: run.id, + status: 'skipped_unavailable', + workspaceId: input.automation.workspaceId, + error: target.ok ? NO_RUNNABLE_HOST : target.error + }) +} diff --git a/src/main/automations/external-automation-manager-cache.test.ts b/src/main/automations/external-automation-manager-cache.test.ts new file mode 100644 index 00000000000..8b73b306209 --- /dev/null +++ b/src/main/automations/external-automation-manager-cache.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from 'vitest' +import { + ExternalAutomationManagerCache, + describeExternalManagerFailure +} from './external-automation-manager-cache' +import { ExternalAutomationProbeCancelledError } from './external-automation-probe-scheduler' +import type { ExternalAutomationManager } from '../../shared/automations-types' + +function manager(id: string): ExternalAutomationManager { + return { + id, + provider: 'hermes', + label: id, + targetLabel: 'this computer', + target: { type: 'local' }, + status: 'available', + error: null, + canManage: true, + jobs: [] + } +} + +const selfOwner = 'owner:desktop:self' +const sshOwner = 'owner:desktop:ssh:target-a:3' + +describe('ExternalAutomationManagerCache', () => { + it('keys entries per owner and per provider', () => { + const cache = new ExternalAutomationManagerCache() + + cache.write({ ownerKey: selfOwner, provider: 'hermes' }, manager('hermes:local')) + cache.writeFailure({ ownerKey: selfOwner, provider: 'openclaw' }, new Error('no openclaw')) + cache.write({ ownerKey: sshOwner, provider: 'hermes' }, manager('hermes:ssh:target-a')) + + expect(cache.read({ ownerKey: selfOwner, provider: 'hermes' })?.manager?.id).toBe( + 'hermes:local' + ) + expect(cache.read({ ownerKey: selfOwner, provider: 'openclaw' })?.error).toBe('no openclaw') + expect(cache.read({ ownerKey: selfOwner, provider: 'hermes' })?.error).toBeNull() + expect(cache.read({ ownerKey: sshOwner, provider: 'hermes' })?.manager?.id).toBe( + 'hermes:ssh:target-a' + ) + }) + + it('drops only the named host when a mutation invalidates it', () => { + const cache = new ExternalAutomationManagerCache() + cache.write({ ownerKey: selfOwner, provider: 'hermes' }, manager('hermes:local')) + cache.write({ ownerKey: sshOwner, provider: 'hermes' }, manager('hermes:ssh:target-a')) + cache.write({ ownerKey: sshOwner, provider: 'openclaw' }, manager('openclaw:ssh:target-a')) + + cache.invalidateOwner(sshOwner) + + expect(cache.read({ ownerKey: selfOwner, provider: 'hermes' })).not.toBeNull() + expect(cache.read({ ownerKey: sshOwner, provider: 'hermes' })).toBeNull() + expect(cache.read({ ownerKey: sshOwner, provider: 'openclaw' })).toBeNull() + }) + + it('treats an entry past its TTL as absent', () => { + let now = 1_000 + const cache = new ExternalAutomationManagerCache({ ttlMs: 100, now: () => now }) + cache.write({ ownerKey: selfOwner, provider: 'hermes' }, manager('hermes:local')) + + now += 100 + expect(cache.read({ ownerKey: selfOwner, provider: 'hermes' })).not.toBeNull() + now += 1 + expect(cache.read({ ownerKey: selfOwner, provider: 'hermes' })).toBeNull() + }) + + it('prunes expired entries when the cache is accessed', () => { + let now = 1_000 + const cache = new ExternalAutomationManagerCache({ ttlMs: 100, now: () => now }) + for (let index = 0; index < 10; index += 1) { + cache.write({ ownerKey: `owner-${index}`, provider: 'hermes' }, manager(String(index))) + } + now += 101 + + expect(cache.read({ ownerKey: 'unrelated', provider: 'hermes' })).toBeNull() + expect(cache.size).toBe(0) + }) + + it('serves a fresh entry without reloading, and reloads on refresh', async () => { + const cache = new ExternalAutomationManagerCache() + const load = vi.fn(() => Promise.resolve(manager('hermes:local'))) + const key = { ownerKey: selfOwner, provider: 'hermes' } + + await cache.resolve(key, load) + await cache.resolve(key, load) + expect(load).toHaveBeenCalledTimes(1) + + await cache.resolve(key, load, { refresh: true }) + expect(load).toHaveBeenCalledTimes(2) + }) + + it('records a load failure as error state rather than throwing', async () => { + const cache = new ExternalAutomationManagerCache() + + const entry = await cache.resolve({ ownerKey: sshOwner, provider: 'hermes' }, () => + Promise.reject(new Error('relay refused')) + ) + + expect(entry.manager).toBeNull() + expect(entry.error).toBe('relay refused') + }) + + it('propagates cancellation and leaves the previous entry untouched', async () => { + const cache = new ExternalAutomationManagerCache() + const key = { ownerKey: sshOwner, provider: 'hermes' } + cache.write(key, manager('hermes:ssh:target-a')) + + await expect( + cache.resolve(key, () => Promise.reject(new ExternalAutomationProbeCancelledError()), { + refresh: true + }) + ).rejects.toBeInstanceOf(ExternalAutomationProbeCancelledError) + expect(cache.read(key)?.manager?.id).toBe('hermes:ssh:target-a') + }) +}) + +describe('describeExternalManagerFailure', () => { + it('keeps only a bounded message and never the thrown payload', () => { + expect(describeExternalManagerFailure({ prompt: 'secret prompt', jobs: [1, 2] })).toBe( + 'External automation manager could not be read.' + ) + expect(describeExternalManagerFailure(new Error(' '))).toBe( + 'External automation manager could not be read.' + ) + + const long = describeExternalManagerFailure(new Error('x'.repeat(400))) + expect(long).toHaveLength(301) + expect(long.endsWith('…')).toBe(true) + }) +}) diff --git a/src/main/automations/external-automation-manager-cache.ts b/src/main/automations/external-automation-manager-cache.ts new file mode 100644 index 00000000000..701da22a2f1 --- /dev/null +++ b/src/main/automations/external-automation-manager-cache.ts @@ -0,0 +1,148 @@ +/** + * Per-`{owner, provider}` manager and error state. + * + * Deliberately holds no reference to Orca automation storage: a provider that is + * missing, wedged, or returning garbage is a fact about that manager only, and + * must never be able to mark the host's Orca store unavailable. + */ + +import type { ExternalAutomationManager } from '../../shared/automations-types' +import { isExternalAutomationProbeCancelled } from './external-automation-probe-scheduler' + +export type ExternalAutomationManagerCacheKey = { + ownerKey: string + provider: string +} + +export type ExternalAutomationManagerCacheEntry = { + /** Null once a probe succeeded and found no manager configured for this scope. */ + manager: ExternalAutomationManager | null + error: string | null + updatedAt: number +} + +const DEFAULT_CACHE_TTL_MS = 30_000 +const MAX_CACHED_ERROR_LENGTH = 300 + +/** + * Bounded, provider-agnostic failure text. Provider payloads can carry prompts, + * job bodies, and run output, so only an `Error.message` is ever kept and it is + * truncated; anything else is reduced to a fixed string. + */ +export function describeExternalManagerFailure(error: unknown): string { + const message = error instanceof Error ? error.message.trim() : '' + if (!message) { + return 'External automation manager could not be read.' + } + return message.length > MAX_CACHED_ERROR_LENGTH + ? `${message.slice(0, MAX_CACHED_ERROR_LENGTH)}…` + : message +} + +export function externalAutomationManagerCacheKey(key: ExternalAutomationManagerCacheKey): string { + // Why: ownerKey is already separator-escaped, so appending the provider stays unambiguous. + return `${key.ownerKey}|${key.provider}` +} + +export class ExternalAutomationManagerCache { + private readonly entries = new Map() + private readonly ttlMs: number + private readonly now: () => number + + constructor(options?: { ttlMs?: number; now?: () => number }) { + this.ttlMs = Math.max(0, options?.ttlMs ?? DEFAULT_CACHE_TTL_MS) + this.now = options?.now ?? Date.now + } + + get size(): number { + return this.entries.size + } + + /** Fresh entry for this scope, or null when absent or past its TTL. */ + read(key: ExternalAutomationManagerCacheKey): ExternalAutomationManagerCacheEntry | null { + this.pruneExpired() + const entry = this.entries.get(externalAutomationManagerCacheKey(key)) + if (!entry) { + return null + } + return this.now() - entry.updatedAt <= this.ttlMs ? entry : null + } + + write( + key: ExternalAutomationManagerCacheKey, + manager: ExternalAutomationManager | null + ): ExternalAutomationManagerCacheEntry { + return this.store(key, { manager, error: null, updatedAt: this.now() }) + } + + writeFailure( + key: ExternalAutomationManagerCacheKey, + error: unknown + ): ExternalAutomationManagerCacheEntry { + return this.store(key, { + manager: null, + error: describeExternalManagerFailure(error), + updatedAt: this.now() + }) + } + + invalidate(key: ExternalAutomationManagerCacheKey): void { + this.entries.delete(externalAutomationManagerCacheKey(key)) + } + + /** Drops every provider entry for one host, e.g. after a mutation on it. */ + invalidateOwner(ownerKey: string): void { + for (const key of this.entries.keys()) { + if (key.startsWith(`${ownerKey}|`)) { + this.entries.delete(key) + } + } + } + + clear(): void { + this.entries.clear() + } + + /** + * Returns a fresh entry or loads one. A cancelled probe propagates and leaves + * any existing entry untouched, so leaving a scope never records a failure. + */ + async resolve( + key: ExternalAutomationManagerCacheKey, + load: () => Promise, + options?: { refresh?: boolean } + ): Promise { + if (!options?.refresh) { + const cached = this.read(key) + if (cached) { + return cached + } + } + try { + return this.write(key, await load()) + } catch (error) { + if (isExternalAutomationProbeCancelled(error)) { + throw error + } + return this.writeFailure(key, error) + } + } + + private store( + key: ExternalAutomationManagerCacheKey, + entry: ExternalAutomationManagerCacheEntry + ): ExternalAutomationManagerCacheEntry { + this.pruneExpired() + this.entries.set(externalAutomationManagerCacheKey(key), entry) + return entry + } + + private pruneExpired(): void { + const now = this.now() + for (const [key, entry] of this.entries) { + if (now - entry.updatedAt > this.ttlMs) { + this.entries.delete(key) + } + } + } +} diff --git a/src/main/automations/external-automation-owner-guard.test.ts b/src/main/automations/external-automation-owner-guard.test.ts new file mode 100644 index 00000000000..f018f8b0b22 --- /dev/null +++ b/src/main/automations/external-automation-owner-guard.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest' +import { resolveExternalAutomationScope } from './external-automation-owner-guard' +import { AutomationOwnerConflictError } from '../../shared/automation-owner-conflict' +import type { AutomationOwnerRef } from '../../shared/automation-owner-ref' +import { + EXTERNAL_AUTOMATION_SCOPE_CODES, + ExternalAutomationScopeError +} from '../../shared/external-automation-scope' +import type { ExternalAutomationProvider } from '../../shared/automations-types' +import type { SshTarget } from '../../shared/ssh-types' + +function sshTarget(overrides: Partial = {}): SshTarget { + return { + id: 'target-a', + label: 'Build box', + host: 'build.example', + port: 22, + username: 'orca', + generation: 3, + ...overrides + } +} + +function registry(targets: SshTarget[]): { getSshTargets: () => SshTarget[] } { + return { getSshTargets: () => targets } +} + +const desktopSelf: AutomationOwnerRef = { + authority: { kind: 'desktop' }, + selector: { kind: 'self' } +} + +function desktopSsh(targetId: string, targetGeneration: number): AutomationOwnerRef { + return { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId, targetGeneration } } +} + +describe('resolveExternalAutomationScope', () => { + it('resolves desktop + self to the local target', () => { + const scope = resolveExternalAutomationScope( + { owner: desktopSelf, provider: 'hermes' }, + registry([]) + ) + + expect(scope.target).toEqual({ type: 'local' }) + expect(scope.sshTarget).toBeNull() + expect(scope.managerId).toBe('hermes:local') + }) + + it('resolves a desktop SSH host whose registration generation still matches', () => { + const target = sshTarget() + + const scope = resolveExternalAutomationScope( + { owner: desktopSsh('target-a', 3), provider: 'openclaw' }, + registry([target]) + ) + + expect(scope.target).toEqual({ type: 'ssh', connectionId: 'target-a' }) + expect(scope.sshTarget).toBe(target) + expect(scope.managerId).toBe('openclaw:ssh:target-a') + }) + + it('excludes a runtime-owned target instead of exposing it', () => { + const target = sshTarget({ owner: { type: 'on-demand-runtime', runtimeId: 'rt-1' } }) + + expect(() => + resolveExternalAutomationScope( + { owner: desktopSsh('target-a', 3), provider: 'hermes' }, + registry([target]) + ) + ).toThrow(ExternalAutomationScopeError) + try { + resolveExternalAutomationScope( + { owner: desktopSsh('target-a', 3), provider: 'hermes' }, + registry([target]) + ) + } catch (error) { + expect((error as ExternalAutomationScopeError).code).toBe( + EXTERNAL_AUTOMATION_SCOPE_CODES.targetHidden + ) + } + }) + + it('hides a runtime-owned target even when the captured generation is wrong', () => { + const target = sshTarget({ + generation: 9, + owner: { type: 'on-demand-runtime', runtimeId: 'rt-1' } + }) + + try { + resolveExternalAutomationScope( + { owner: desktopSsh('target-a', 3), provider: 'hermes' }, + registry([target]) + ) + expect.unreachable('hidden target must not resolve') + } catch (error) { + expect((error as ExternalAutomationScopeError).code).toBe( + EXTERNAL_AUTOMATION_SCOPE_CODES.targetHidden + ) + } + }) + + it('fails closed with the host-changed conflict on a stale generation', () => { + try { + resolveExternalAutomationScope( + { owner: desktopSsh('target-a', 2), provider: 'hermes' }, + registry([sshTarget({ generation: 3 })]) + ) + expect.unreachable('stale generation must not resolve') + } catch (error) { + expect(error).toBeInstanceOf(AutomationOwnerConflictError) + expect((error as AutomationOwnerConflictError).code).toBe('automation_owner_changed') + } + }) + + it('fails closed when the registration carries no usable generation', () => { + try { + resolveExternalAutomationScope( + { owner: desktopSsh('target-a', 1), provider: 'hermes' }, + registry([sshTarget({ generation: undefined })]) + ) + expect.unreachable('unstamped registration must not resolve') + } catch (error) { + expect((error as AutomationOwnerConflictError).code).toBe('automation_owner_changed') + } + }) + + it('reports a removed SSH host as a target-removed conflict', () => { + try { + resolveExternalAutomationScope( + { owner: desktopSsh('gone', 1), provider: 'hermes' }, + registry([sshTarget()]) + ) + expect.unreachable('missing target must not resolve') + } catch (error) { + expect((error as AutomationOwnerConflictError).code).toBe('automation_target_removed') + } + }) + + it('rejects a runtime authority rather than tunnelling it to the desktop surface', () => { + const runtimeOwner: AutomationOwnerRef = { + authority: { kind: 'runtime', environmentId: 'env-1', pairingRevision: 4 }, + selector: { kind: 'self' } + } + + try { + resolveExternalAutomationScope({ owner: runtimeOwner, provider: 'hermes' }, registry([])) + expect.unreachable('runtime authority must not resolve') + } catch (error) { + expect((error as ExternalAutomationScopeError).code).toBe( + EXTERNAL_AUTOMATION_SCOPE_CODES.authorityNotSupported + ) + } + }) + + it('rejects a provider outside the allowlist', () => { + try { + resolveExternalAutomationScope( + { owner: desktopSelf, provider: 'cron' as ExternalAutomationProvider }, + registry([]) + ) + expect.unreachable('unknown provider must not resolve') + } catch (error) { + expect((error as ExternalAutomationScopeError).code).toBe( + EXTERNAL_AUTOMATION_SCOPE_CODES.providerNotAllowed + ) + } + }) +}) diff --git a/src/main/automations/external-automation-owner-guard.ts b/src/main/automations/external-automation-owner-guard.ts new file mode 100644 index 00000000000..db69ff5a7a3 --- /dev/null +++ b/src/main/automations/external-automation-owner-guard.ts @@ -0,0 +1,92 @@ +/** + * The fail-closed checks every scoped external-manager call re-applies *before* + * contacting a relay or launching a provider command. + * + * Resolution is fully synchronous on purpose: a rejected scope has performed no + * probe, because there is no await between the request arriving and the throw. + */ + +import { + EXTERNAL_AUTOMATION_SCOPE_CODES, + ExternalAutomationScopeError, + externalAutomationHostChangedError, + externalAutomationManagerId, + externalAutomationTargetForOwner, + externalAutomationTargetRemovedError, + isExternalAutomationProvider, + type ScopedExternalAutomationRequest +} from '../../shared/external-automation-scope' +import { ownerKey } from '../../shared/automation-owner-key' +import { sanitizeSshTargetGeneration } from '../../shared/ssh-target-generation' +import type { + ExternalAutomationProvider, + ExternalAutomationTarget +} from '../../shared/automations-types' +import type { SshTarget } from '../../shared/ssh-types' +import { isRuntimeOwnedSshTarget } from '../ssh/ssh-connection-store' + +/** Current SSH registrations, hidden ones included so the guard can reject them itself. */ +export type DesktopSshTargetRegistry = { + getSshTargets: () => SshTarget[] +} + +export type ResolvedExternalAutomationScope = { + provider: ExternalAutomationProvider + target: ExternalAutomationTarget + /** Set only for SSH scopes, already proven desktop-visible and current. */ + sshTarget: SshTarget | null + managerId: string + /** Cache, coalescing, and cancellation key for this `{owner, provider}`. */ + ownerKey: string +} + +function resolveSshScope( + targetId: string, + capturedGeneration: number, + registry: DesktopSshTargetRegistry +): SshTarget { + const target = registry.getSshTargets().find((entry) => entry.id === targetId) + if (!target) { + throw externalAutomationTargetRemovedError() + } + // Why: checked before the generation compare so a hidden target reveals nothing about its registration. + if (isRuntimeOwnedSshTarget(target)) { + throw new ExternalAutomationScopeError(EXTERNAL_AUTOMATION_SCOPE_CODES.targetHidden) + } + const current = sanitizeSshTargetGeneration(target.generation) + if (current === undefined || current !== capturedGeneration) { + throw externalAutomationHostChangedError() + } + return target +} + +/** + * Validates provider, authority, and host incarnation for one scoped request. + * Throws `ExternalAutomationScopeError` or `AutomationOwnerConflictError`; never probes. + */ +export function resolveExternalAutomationScope( + request: ScopedExternalAutomationRequest, + registry: DesktopSshTargetRegistry +): ResolvedExternalAutomationScope { + if (!isExternalAutomationProvider(request.provider)) { + throw new ExternalAutomationScopeError(EXTERNAL_AUTOMATION_SCOPE_CODES.providerNotAllowed) + } + const { owner } = request + if (owner.authority.kind !== 'desktop') { + throw new ExternalAutomationScopeError(EXTERNAL_AUTOMATION_SCOPE_CODES.authorityNotSupported) + } + if (owner.selector.kind !== 'self' && owner.selector.kind !== 'ssh') { + throw new ExternalAutomationScopeError(EXTERNAL_AUTOMATION_SCOPE_CODES.authorityNotSupported) + } + const sshTarget = + owner.selector.kind === 'ssh' + ? resolveSshScope(owner.selector.targetId, owner.selector.targetGeneration, registry) + : null + return { + provider: request.provider, + target: externalAutomationTargetForOwner(owner), + sshTarget, + managerId: externalAutomationManagerId(owner, request.provider), + ownerKey: ownerKey(owner) + } +} diff --git a/src/main/automations/external-automation-probe-scheduler.test.ts b/src/main/automations/external-automation-probe-scheduler.test.ts new file mode 100644 index 00000000000..ce3d9dc1124 --- /dev/null +++ b/src/main/automations/external-automation-probe-scheduler.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi } from 'vitest' +import { + ExternalAutomationProbeCancelledError, + ExternalAutomationProbeScheduler, + isExternalAutomationProbeCancelled +} from './external-automation-probe-scheduler' + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } +} + +describe('ExternalAutomationProbeScheduler', () => { + it('never runs more probes at once than its bound allows', async () => { + const scheduler = new ExternalAutomationProbeScheduler({ concurrency: 2 }) + const gates = [deferred(), deferred(), deferred()] + const started: string[] = [] + + const promises = gates.map((gate, index) => + scheduler.schedule({ + key: `probe-${index}`, + scopeKey: 'owner:desktop:self', + run: () => { + started.push(`probe-${index}`) + return gate.promise + } + }) + ) + + expect(started).toEqual(['probe-0', 'probe-1']) + expect(scheduler.queued).toBe(1) + + gates[0]?.resolve('a') + await promises[0] + expect(started).toEqual(['probe-0', 'probe-1', 'probe-2']) + + gates[1]?.resolve('b') + gates[2]?.resolve('c') + await Promise.all(promises) + }) + + it('shares one probe between concurrent requests for the same key', async () => { + const scheduler = new ExternalAutomationProbeScheduler() + const run = vi.fn(() => Promise.resolve('manager')) + + const [first, second] = await Promise.all([ + scheduler.schedule({ key: 'hermes@self', scopeKey: 'owner:desktop:self', run }), + scheduler.schedule({ key: 'hermes@self', scopeKey: 'owner:desktop:self', run }) + ]) + + expect(run).toHaveBeenCalledTimes(1) + expect(first).toBe('manager') + expect(second).toBe('manager') + }) + + it('parks queued probes while Orca automation work holds priority', async () => { + const scheduler = new ExternalAutomationProbeScheduler({ concurrency: 4 }) + const run = vi.fn(() => Promise.resolve('manager')) + + const release = scheduler.beginPriorityWork() + const probe = scheduler.schedule({ key: 'hermes@self', scopeKey: 'owner:desktop:self', run }) + + expect(run).not.toHaveBeenCalled() + expect(scheduler.queued).toBe(1) + + release() + await probe + expect(run).toHaveBeenCalledTimes(1) + }) + + it('cancels queued and in-flight probes whose scope is no longer selected', async () => { + const scheduler = new ExternalAutomationProbeScheduler({ concurrency: 1 }) + const never = deferred() + const queuedRun = vi.fn(() => Promise.resolve('queued')) + const keptRun = vi.fn(() => Promise.resolve('local')) + + const inFlight = scheduler.schedule({ + key: 'hermes@ssh', + scopeKey: 'owner:desktop:ssh:target-a:3', + run: () => never.promise + }) + const queued = scheduler.schedule({ + key: 'openclaw@ssh', + scopeKey: 'owner:desktop:ssh:target-a:3', + run: queuedRun + }) + const kept = scheduler.schedule({ + key: 'hermes@self', + scopeKey: 'owner:desktop:self', + run: keptRun + }) + + scheduler.retainScopes(['owner:desktop:self']) + + await expect(inFlight).rejects.toBeInstanceOf(ExternalAutomationProbeCancelledError) + await expect(queued).rejects.toSatisfy(isExternalAutomationProbeCancelled) + expect(queuedRun).not.toHaveBeenCalled() + expect(keptRun).not.toHaveBeenCalled() + expect(scheduler.inFlight).toBe(1) + + never.resolve('ignored') + await expect(kept).resolves.toBe('local') + expect(scheduler.inFlight).toBe(0) + }) + + it('cancels everything on demand', async () => { + const scheduler = new ExternalAutomationProbeScheduler({ concurrency: 1 }) + const never = deferred() + const probe = scheduler.schedule({ + key: 'hermes@self', + scopeKey: 'owner:desktop:self', + run: () => never.promise + }) + + scheduler.cancelAll() + + await expect(probe).rejects.toBeInstanceOf(ExternalAutomationProbeCancelledError) + expect(scheduler.inFlight).toBe(1) + never.resolve('ignored') + await vi.waitFor(() => expect(scheduler.inFlight).toBe(0)) + }) + + it('does not start a duplicate key while cancelled provider work is still settling', async () => { + const scheduler = new ExternalAutomationProbeScheduler({ concurrency: 2 }) + const never = deferred() + const run = vi.fn(() => never.promise) + const first = scheduler.schedule({ key: 'hermes@self', scopeKey: 'self', run }) + + scheduler.cancelAll() + const second = scheduler.schedule({ key: 'hermes@self', scopeKey: 'self', run }) + + await expect(first).rejects.toBeInstanceOf(ExternalAutomationProbeCancelledError) + await expect(second).rejects.toBeInstanceOf(ExternalAutomationProbeCancelledError) + expect(run).toHaveBeenCalledTimes(1) + + never.resolve('ignored') + await vi.waitFor(() => expect(scheduler.inFlight).toBe(0)) + }) +}) diff --git a/src/main/automations/external-automation-probe-scheduler.ts b/src/main/automations/external-automation-probe-scheduler.ts new file mode 100644 index 00000000000..afcad7058de --- /dev/null +++ b/src/main/automations/external-automation-probe-scheduler.ts @@ -0,0 +1,150 @@ +/** + * Bounded pool for `{host, provider}` external-manager probes. + * + * Probes are always secondary to Orca automation list and mutation traffic, so + * the pool parks its queue while that work holds a priority lease. Nothing is + * scheduled speculatively: a caller schedules exactly the scopes it selected, + * which is what keeps a Local view from reaching out to every SSH target. + */ + +export class ExternalAutomationProbeCancelledError extends Error { + constructor() { + super('External automation probe was cancelled.') + this.name = 'ExternalAutomationProbeCancelledError' + } +} + +export function isExternalAutomationProbeCancelled(error: unknown): boolean { + return error instanceof ExternalAutomationProbeCancelledError +} + +export type ExternalAutomationProbeJob = { + /** `{owner, provider}` identity; a duplicate while one is in flight shares that probe. */ + key: string + /** Owner key, so leaving a host's scope cancels its probes across providers. */ + scopeKey: string + run: (signal: AbortSignal) => Promise +} + +type QueuedProbe = { + key: string + scopeKey: string + controller: AbortController + start: () => void + reject: (error: unknown) => void +} + +const DEFAULT_PROBE_CONCURRENCY = 4 + +export class ExternalAutomationProbeScheduler { + private readonly concurrency: number + private readonly queue: QueuedProbe[] = [] + private readonly active = new Set() + private readonly shared = new Map>() + private priorityHolds = 0 + + constructor(options?: { concurrency?: number }) { + this.concurrency = Math.max(1, options?.concurrency ?? DEFAULT_PROBE_CONCURRENCY) + } + + get inFlight(): number { + return this.active.size + } + + get queued(): number { + return this.queue.length + } + + schedule(job: ExternalAutomationProbeJob): Promise { + const existing = this.shared.get(job.key) + if (existing) { + return existing as Promise + } + const controller = new AbortController() + let settled!: Promise + const promise = new Promise((resolve, reject) => { + const entry: QueuedProbe = { + key: job.key, + scopeKey: job.scopeKey, + controller, + reject, + start: () => { + this.active.add(entry) + // Why: settle on abort even when a provider call cannot honour the signal. + controller.signal.addEventListener( + 'abort', + () => reject(new ExternalAutomationProbeCancelledError()), + { once: true } + ) + void job + .run(controller.signal) + .then(resolve, reject) + .finally(() => { + this.active.delete(entry) + if (this.shared.get(entry.key) === settled) { + this.shared.delete(entry.key) + } + this.pump() + }) + } + } + this.queue.push(entry) + }) + settled = promise.finally(() => { + this.pump() + }) + this.shared.set(job.key, settled) + this.pump() + return settled + } + + /** Held for the duration of Orca list/mutation work; queued probes wait behind it. */ + beginPriorityWork(): () => void { + this.priorityHolds += 1 + let released = false + return () => { + if (released) { + return + } + released = true + this.priorityHolds = Math.max(0, this.priorityHolds - 1) + this.pump() + } + } + + /** Drops queued and in-flight probes whose scope is no longer selected. */ + retainScopes(activeScopeKeys: Iterable): void { + const retained = new Set(activeScopeKeys) + this.cancelWhere((scopeKey) => !retained.has(scopeKey)) + } + + cancelAll(): void { + this.cancelWhere(() => true) + } + + private cancelWhere(shouldCancel: (scopeKey: string) => boolean): void { + for (let index = this.queue.length - 1; index >= 0; index -= 1) { + const entry = this.queue[index] + if (entry && shouldCancel(entry.scopeKey)) { + this.queue.splice(index, 1) + entry.reject(new ExternalAutomationProbeCancelledError()) + this.shared.delete(entry.key) + } + } + for (const entry of this.active) { + if (shouldCancel(entry.scopeKey)) { + entry.controller.abort() + } + } + } + + private pump(): void { + while (this.priorityHolds === 0 && this.active.size < this.concurrency) { + const next = this.queue.shift() + if (!next) { + return + } + next.start() + } + } +} diff --git a/src/main/automations/external-manager-scoped.test.ts b/src/main/automations/external-manager-scoped.test.ts new file mode 100644 index 00000000000..5e4d5e6bdb5 --- /dev/null +++ b/src/main/automations/external-manager-scoped.test.ts @@ -0,0 +1,268 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createScopedExternalAutomations } from './external-manager' +import { ExternalAutomationManagerCache } from './external-automation-manager-cache' +import { ExternalAutomationProbeScheduler } from './external-automation-probe-scheduler' +import { getActiveMultiplexer } from '../ssh/ssh-target-registry' +import { AutomationOwnerConflictError } from '../../shared/automation-owner-conflict' +import type { AutomationOwnerRef } from '../../shared/automation-owner-ref' +import { + EXTERNAL_AUTOMATION_SCOPE_CODES, + ExternalAutomationScopeError +} from '../../shared/external-automation-scope' +import type { ExternalAutomationProvider } from '../../shared/automations-types' +import type { SshTarget } from '../../shared/ssh-types' +import type * as Fs from 'node:fs' + +const runProcessMock = vi.hoisted(() => vi.fn()) +const existsSyncMock = vi.hoisted(() => vi.fn(() => false)) + +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) +vi.mock('fs', async () => { + const actual = await vi.importActual('fs') + return { ...actual, existsSync: existsSyncMock } +}) +vi.mock('../ssh/ssh-target-registry', () => ({ getActiveMultiplexer: vi.fn() })) + +function sshTarget(overrides: Partial = {}): SshTarget { + return { + id: 'target-a', + label: 'Build box', + host: 'build.example', + port: 22, + username: 'orca', + generation: 3, + ...overrides + } +} + +const desktopSelf: AutomationOwnerRef = { + authority: { kind: 'desktop' }, + selector: { kind: 'self' } +} + +function desktopSsh(targetId: string, targetGeneration: number): AutomationOwnerRef { + return { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId, targetGeneration } } +} + +/** Stands in for the Orca automation store: readable, but never markable unavailable from here. */ +function orcaStoreHealth() { + return { read: vi.fn<() => void>(), markUnavailable: vi.fn<() => void>() } +} + +function buildEngine(targets: SshTarget[]) { + return createScopedExternalAutomations({ + registry: { getSshTargets: () => targets }, + scheduler: new ExternalAutomationProbeScheduler(), + cache: new ExternalAutomationManagerCache({ ttlMs: 0 }) + }) +} + +function relay(response: unknown): { + isDisposed: () => boolean + request: ReturnType +} { + return { isDisposed: () => false, request: vi.fn().mockResolvedValue(response) } +} + +beforeEach(() => { + runProcessMock.mockReset() + runProcessMock.mockResolvedValue({ + code: 0, + signal: null, + stdout: '', + stderr: '', + timedOut: false + }) + existsSyncMock.mockReturnValue(false) + vi.mocked(getActiveMultiplexer).mockReset() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('scoped external automations', () => { + // The probe's own timeout, not the caller's: a provider binary that never + // returns must still resolve the entry, or the page waits forever on one host. + it('settles when a local command lookup hangs', async () => { + vi.useFakeTimers() + runProcessMock.mockResolvedValue({ + code: null, + signal: null, + stdout: '', + stderr: '', + timedOut: true + }) + const promise = buildEngine([]).listManager({ owner: desktopSelf, provider: 'hermes' }) + let settled = false + void promise.finally(() => { + settled = true + }) + + await expect(promise).resolves.toMatchObject({ manager: null }) + expect(settled).toBe(true) + }) + + it('probes only local managers when Local is selected', async () => { + const engine = buildEngine([sshTarget(), sshTarget({ id: 'target-b', generation: 1 })]) + + const entry = await engine.listManager({ owner: desktopSelf, provider: 'hermes' }) + + expect(entry.manager?.id).toBe('hermes:local') + expect(getActiveMultiplexer).not.toHaveBeenCalled() + expect(runProcessMock).toHaveBeenCalledTimes(1) + expect(runProcessMock.mock.calls[0]?.[0]).toEqual({ + program: 'which', + args: ['hermes'], + timeoutMs: 5_000 + }) + }) + + it('does not reach out to any SSH target from a Local view', async () => { + const engine = buildEngine([sshTarget(), sshTarget({ id: 'target-b', generation: 1 })]) + + await engine.listManager({ owner: desktopSelf, provider: 'hermes' }) + await engine.listManager({ owner: desktopSelf, provider: 'openclaw' }) + + expect(getActiveMultiplexer).not.toHaveBeenCalled() + }) + + it('probes only the selected SSH host', async () => { + vi.mocked(getActiveMultiplexer).mockReturnValue( + relay({ jobs: [], hermesAvailable: true }) as unknown as ReturnType< + typeof getActiveMultiplexer + > + ) + const engine = buildEngine([sshTarget(), sshTarget({ id: 'target-b', generation: 1 })]) + + const entry = await engine.listManager({ + owner: desktopSsh('target-a', 3), + provider: 'hermes' + }) + + expect(entry.manager?.id).toBe('hermes:ssh:target-a') + expect(getActiveMultiplexer).toHaveBeenCalledTimes(1) + expect(getActiveMultiplexer).toHaveBeenCalledWith('target-a') + }) + + it('fails closed on a stale generation without probing', async () => { + const engine = buildEngine([sshTarget({ generation: 4 })]) + + await expect( + engine.listManager({ owner: desktopSsh('target-a', 3), provider: 'hermes' }) + ).rejects.toBeInstanceOf(AutomationOwnerConflictError) + expect(getActiveMultiplexer).not.toHaveBeenCalled() + expect(runProcessMock).not.toHaveBeenCalled() + }) + + it('excludes a runtime-owned target without probing it', async () => { + const engine = buildEngine([ + sshTarget({ owner: { type: 'on-demand-runtime', runtimeId: 'rt-1' } }) + ]) + + await expect( + engine.listManager({ owner: desktopSsh('target-a', 3), provider: 'hermes' }) + ).rejects.toBeInstanceOf(ExternalAutomationScopeError) + expect(getActiveMultiplexer).not.toHaveBeenCalled() + expect(runProcessMock).not.toHaveBeenCalled() + }) + + it('rejects a runtime authority before any probe', async () => { + const engine = buildEngine([]) + + await expect( + engine.listManager({ + owner: { + authority: { kind: 'runtime', environmentId: 'env-1', pairingRevision: 2 }, + selector: { kind: 'self' } + }, + provider: 'hermes' + }) + ).rejects.toThrow(EXTERNAL_AUTOMATION_SCOPE_CODES.authorityNotSupported) + expect(runProcessMock).not.toHaveBeenCalled() + expect(getActiveMultiplexer).not.toHaveBeenCalled() + }) + + it('rejects an unknown provider before launching any command', async () => { + const engine = buildEngine([sshTarget()]) + const unknown = 'cron' as ExternalAutomationProvider + + await expect( + engine.runAction({ + owner: desktopSelf, + provider: unknown, + jobId: 'job-1', + action: 'delete' + }) + ).rejects.toThrow(EXTERNAL_AUTOMATION_SCOPE_CODES.providerNotAllowed) + expect(runProcessMock).not.toHaveBeenCalled() + expect(getActiveMultiplexer).not.toHaveBeenCalled() + }) + + it('fails a scoped mutation closed when the host changed under the dialog', async () => { + const engine = buildEngine([sshTarget({ generation: 5 })]) + + await expect( + engine.update({ + owner: desktopSsh('target-a', 3), + provider: 'hermes', + jobId: 'job-1', + name: 'Nightly', + prompt: 'do the thing', + schedule: '0 2 * * *', + workdir: null + }) + ).rejects.toBeInstanceOf(AutomationOwnerConflictError) + expect(runProcessMock).not.toHaveBeenCalled() + expect(getActiveMultiplexer).not.toHaveBeenCalled() + }) + + it('confines a manager failure to its own scope and never to Orca store health', async () => { + const health = orcaStoreHealth() + vi.mocked(getActiveMultiplexer).mockReturnValue( + undefined as unknown as ReturnType + ) + const engine = createScopedExternalAutomations({ + registry: { + getSshTargets: () => { + health.read() + return [sshTarget()] + } + }, + scheduler: new ExternalAutomationProbeScheduler(), + cache: new ExternalAutomationManagerCache({ ttlMs: 0 }) + }) + + const failed = await engine.listManager({ + owner: desktopSsh('target-a', 3), + provider: 'hermes' + }) + const healthy = await engine.listManager({ owner: desktopSelf, provider: 'hermes' }) + + expect(failed.manager?.status).toBe('unavailable') + expect(failed.manager?.error).toBe('SSH target is not connected.') + expect(healthy.manager?.status).toBe('available') + expect(health.markUnavailable).not.toHaveBeenCalled() + }) + + it('routes scoped runs through the selected host without accepting a target from the caller', async () => { + const requestMock = vi.fn().mockResolvedValue({ total: 2, runs: [] }) + vi.mocked(getActiveMultiplexer).mockReturnValue({ + isDisposed: () => false, + request: requestMock + } as unknown as ReturnType) + const engine = buildEngine([sshTarget()]) + + const page = await engine.listRuns({ + owner: desktopSsh('target-a', 3), + provider: 'hermes', + jobId: 'job-1', + page: 1, + pageSize: 10 + }) + + expect(page.target).toEqual({ type: 'ssh', connectionId: 'target-a' }) + expect(page.managerId).toBe('hermes:ssh:target-a') + expect(page.total).toBe(2) + }) +}) diff --git a/src/main/automations/external-manager.test.ts b/src/main/automations/external-manager.test.ts index 994d5cab560..fdbbcc8dac1 100644 --- a/src/main/automations/external-manager.test.ts +++ b/src/main/automations/external-manager.test.ts @@ -1,38 +1,23 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createExternalAutomation, - listExternalAutomationManagers, listExternalAutomationRuns, runExternalAutomationAction, updateExternalAutomation } from './external-manager' import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers' +import { EXTERNAL_AUTOMATION_SCOPE_CODES } from '../../shared/external-automation-scope' import { getActiveMultiplexer } from '../ssh/ssh-target-registry' -import type { Store } from '../persistence' +import type { + ExternalAutomationAction, + ExternalAutomationProvider +} from '../../shared/automations-types' import type * as Fs from 'node:fs' -const execFileMock = vi.hoisted(() => - vi.fn((...args: unknown[]) => { - const callback = args.at(-1) - if (typeof callback === 'function') { - const execCallback = callback as (error: Error | null, stdout: string, stderr: string) => void - execCallback(null, '', '') - } - return { kill: vi.fn() } - }) -) +const runProcessMock = vi.hoisted(() => vi.fn()) const existsSyncMock = vi.hoisted(() => vi.fn(() => false)) -function resolveExecFileMock(...args: unknown[]) { - const callback = args.at(-1) - if (typeof callback === 'function') { - const execCallback = callback as (error: Error | null, stdout: string, stderr: string) => void - execCallback(null, '', '') - } - return { kill: vi.fn() } -} - -vi.mock('child_process', () => ({ execFile: execFileMock })) +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) vi.mock('fs', async () => { const actual = await vi.importActual('fs') return { @@ -46,8 +31,14 @@ vi.mock('../ssh/ssh-target-registry', () => ({ })) beforeEach(() => { - execFileMock.mockReset() - execFileMock.mockImplementation(resolveExecFileMock) + runProcessMock.mockReset() + runProcessMock.mockResolvedValue({ + code: 0, + signal: null, + stdout: '', + stderr: '', + timedOut: false + }) existsSyncMock.mockReturnValue(false) vi.mocked(getActiveMultiplexer).mockReset() }) @@ -56,26 +47,6 @@ afterEach(() => { vi.useRealTimers() }) -describe('listExternalAutomationManagers', () => { - it('settles when a local command lookup hangs', async () => { - vi.useFakeTimers() - execFileMock.mockImplementation(() => ({ kill: vi.fn() })) - const promise = listExternalAutomationManagers({ - getSshTargets: () => [] - } as unknown as Store) - let settled = false - void promise.finally(() => { - settled = true - }) - - await vi.advanceTimersByTimeAsync(5_000) - await Promise.resolve() - - expect(settled).toBe(true) - await expect(promise).resolves.toEqual([]) - }) -}) - describe('mapHermesJobs', () => { it('normalizes Hermes cron jobs into external automation rows', () => { const jobs = mapHermesJobs('hermes:local', [ @@ -209,9 +180,9 @@ describe('createExternalAutomation', () => { workdir: '/repo' }) - expect(execFileMock).toHaveBeenCalledWith( - 'hermes', - [ + expect(runProcessMock).toHaveBeenCalledWith({ + program: 'hermes', + args: [ 'cron', 'create', '0 9 * * 1-5', @@ -223,15 +194,18 @@ describe('createExternalAutomation', () => { '--workdir', '/repo' ], - { encoding: 'utf-8', timeout: 30_000 }, - expect.any(Function) - ) + timeoutMs: 30_000 + }) }) it('settles when local Hermes cron creation hangs', async () => { - vi.useFakeTimers() - const killMock = vi.fn() - execFileMock.mockImplementation(() => ({ kill: killMock })) + runProcessMock.mockResolvedValue({ + code: null, + signal: null, + stdout: '', + stderr: '', + timedOut: true + }) const promise = createExternalAutomation({ managerId: 'hermes:local', @@ -249,12 +223,8 @@ describe('createExternalAutomation', () => { settled = true }) - await vi.advanceTimersByTimeAsync(30_000) - await Promise.resolve() - - expect(settled).toBe(true) await expect(promise).rejects.toThrow('Local automation command timed out') - expect(killMock).toHaveBeenCalled() + expect(settled).toBe(true) }) it('updates local Hermes cron jobs through the CLI', async () => { @@ -269,9 +239,9 @@ describe('createExternalAutomation', () => { workdir: '/repo' }) - expect(execFileMock).toHaveBeenCalledWith( - 'hermes', - [ + expect(runProcessMock).toHaveBeenCalledWith({ + program: 'hermes', + args: [ 'cron', 'edit', 'job-1', @@ -284,9 +254,8 @@ describe('createExternalAutomation', () => { '--workdir', '/repo' ], - { encoding: 'utf-8', timeout: 30_000 }, - expect.any(Function) - ) + timeoutMs: 30_000 + }) }) }) @@ -300,12 +269,11 @@ describe('runExternalAutomationAction', () => { action: 'run' }) - expect(execFileMock).toHaveBeenCalledWith( - 'hermes', - ['cron', 'run', 'job-1'], - { encoding: 'utf-8', timeout: 30_000 }, - expect.any(Function) - ) + expect(runProcessMock).toHaveBeenCalledWith({ + program: 'hermes', + args: ['cron', 'run', 'job-1'], + timeoutMs: 30_000 + }) }) it('rejects job IDs that could be parsed as CLI options', async () => { @@ -319,7 +287,49 @@ describe('runExternalAutomationAction', () => { }) ).rejects.toThrow('Invalid external automation job ID.') - expect(execFileMock).not.toHaveBeenCalled() + expect(runProcessMock).not.toHaveBeenCalled() + }) + + it('rejects an action name that is not in the provider table', async () => { + await expect( + runExternalAutomationAction({ + managerId: 'hermes:local', + provider: 'hermes', + target: { type: 'local' }, + jobId: 'job-1', + action: 'purge' as ExternalAutomationAction + }) + ).rejects.toThrow('Unsupported external automation action.') + + expect(runProcessMock).not.toHaveBeenCalled() + }) + + it('rejects an action inherited from the prototype chain', async () => { + await expect( + runExternalAutomationAction({ + managerId: 'hermes:local', + provider: 'hermes', + target: { type: 'local' }, + jobId: 'job-1', + action: 'toString' as unknown as ExternalAutomationAction + }) + ).rejects.toThrow('Unsupported external automation action.') + + expect(runProcessMock).not.toHaveBeenCalled() + }) + + it('rejects a provider that has no action table', async () => { + await expect( + runExternalAutomationAction({ + managerId: 'hermes:local', + provider: 'shellcat' as ExternalAutomationProvider, + target: { type: 'local' }, + jobId: 'job-1', + action: 'run' + }) + ).rejects.toThrow('Unsupported external automation action.') + + expect(runProcessMock).not.toHaveBeenCalled() }) it('maps OpenClaw lifecycle actions through its cron CLI names', async () => { @@ -331,12 +341,11 @@ describe('runExternalAutomationAction', () => { action: 'pause' }) - expect(execFileMock).toHaveBeenCalledWith( - 'openclaw', - ['cron', 'disable', 'job-1'], - { encoding: 'utf-8', timeout: 30_000 }, - expect.any(Function) - ) + expect(runProcessMock).toHaveBeenCalledWith({ + program: 'openclaw', + args: ['cron', 'disable', 'job-1'], + timeoutMs: 30_000 + }) }) }) @@ -395,6 +404,48 @@ describe('listExternalAutomationRuns', () => { pageSize: 10 }) }) + + it('reports a structured -32601 as an unsupported runs endpoint', async () => { + // Shaped like the multiplexer's rejection: a non-enumerable `code` that no + // amount of message parsing on the far side of IPC could recover. + const rejection = new Error('Method not found: externalAutomations.runs') + Object.defineProperty(rejection, 'code', { value: -32601 }) + vi.mocked(getActiveMultiplexer).mockReturnValue({ + isDisposed: () => false, + request: vi.fn().mockRejectedValue(rejection) + } as unknown as ReturnType) + + await expect( + listExternalAutomationRuns({ + managerId: 'hermes:ssh:ssh-1', + provider: 'hermes', + target: { type: 'ssh', connectionId: 'ssh-1' }, + jobId: 'job-1', + page: 1, + pageSize: 10 + }) + ).rejects.toMatchObject({ code: EXTERNAL_AUTOMATION_SCOPE_CODES.runsUnsupported }) + }) + + it('propagates a relay failure that only reads like a missing method', async () => { + // No code, so the peer never declined the method — this is a broken call, and + // reporting it as "no runs endpoint" would show a truncated list as complete. + vi.mocked(getActiveMultiplexer).mockReturnValue({ + isDisposed: () => false, + request: vi.fn().mockRejectedValue(new Error('relay crashed: method not found in registry')) + } as unknown as ReturnType) + + await expect( + listExternalAutomationRuns({ + managerId: 'hermes:ssh:ssh-1', + provider: 'hermes', + target: { type: 'ssh', connectionId: 'ssh-1' }, + jobId: 'job-1', + page: 1, + pageSize: 10 + }) + ).rejects.toThrow('relay crashed: method not found in registry') + }) }) describe('mapOpenClawJobs', () => { diff --git a/src/main/automations/external-manager.ts b/src/main/automations/external-manager.ts index eb2f340322b..0905f1943de 100644 --- a/src/main/automations/external-manager.ts +++ b/src/main/automations/external-manager.ts @@ -1,10 +1,10 @@ /* eslint-disable max-lines -- Why: external automation discovery, pagination, * and lifecycle routing share provider/target validation and remote relay fallbacks. */ -import { execFile } from 'node:child_process' import { existsSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' +import { runProcess } from '../../shared/child-process/run-process' import type { ExternalAutomationAction, ExternalAutomationActionInput, @@ -15,10 +15,31 @@ import type { ExternalAutomationRunsPage, ExternalAutomationUpdateInput } from '../../shared/automations-types' +import { + EXTERNAL_AUTOMATION_SCOPE_CODES, + ExternalAutomationScopeError, + type ScopedExternalAutomationRequest, + type ScopedExternalManagerActionRequest, + type ScopedExternalManagerCreateRequest, + type ScopedExternalManagerListRequest, + type ScopedExternalManagerMutationFields, + type ScopedExternalManagerRunsRequest, + type ScopedExternalManagerUpdateRequest +} from '../../shared/external-automation-scope' import type { SshTarget } from '../../shared/ssh-types' -import type { Store } from '../persistence' import { getActiveMultiplexer } from '../ssh/ssh-target-registry' -import { isRuntimeOwnedSshTarget } from '../ssh/ssh-connection-store' +import { JsonRpcErrorCode } from '../ssh/relay-protocol' +import { + resolveExternalAutomationScope, + type DesktopSshTargetRegistry, + type ResolvedExternalAutomationScope +} from './external-automation-owner-guard' +import { + externalAutomationManagerCacheKey, + type ExternalAutomationManagerCache, + type ExternalAutomationManagerCacheEntry +} from './external-automation-manager-cache' +import type { ExternalAutomationProbeScheduler } from './external-automation-probe-scheduler' import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers' import { clearHermesCronOutputRunCountCache, @@ -32,97 +53,53 @@ const OPENCLAW_JOBS_FILE = join(homedir(), '.openclaw', 'cron', 'jobs.json') const EXTERNAL_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/ const LOCAL_COMMAND_LOOKUP_TIMEOUT_MS = 5_000 const LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS = 30_000 +const PROVIDER_LABELS: Record = { + hermes: 'Hermes', + openclaw: 'OpenClaw' +} function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } +function runLocalProviderCommand( + command: string, + args: string[], + options: { timeoutMs: number; timeoutMessage: string } +): Promise { + return runProcess({ program: command, args, timeoutMs: options.timeoutMs }).then((result) => { + if (result.timedOut) { + throw new Error(options.timeoutMessage) + } + if (result.code !== 0) { + throw new Error( + result.stderr.trim() || `Command exited with code ${result.code ?? 'unknown'}.` + ) + } + }) +} + async function isCommandOnPath(command: string): Promise { const finder = process.platform === 'win32' ? 'where' : 'which' try { - await runLocalCommandLookup(finder, [command]) + // Why: these probes run while loading Automations; a wedged PATH shim must + // not keep the list IPC pending forever. + await runLocalProviderCommand(finder, [command], { + timeoutMs: LOCAL_COMMAND_LOOKUP_TIMEOUT_MS, + timeoutMessage: `Command lookup timed out after ${LOCAL_COMMAND_LOOKUP_TIMEOUT_MS}ms.` + }) return true } catch { return false } } -function runLocalCommandLookup(command: string, args: string[]): Promise { - return new Promise((resolve, reject) => { - let child: ReturnType | null = null - let settled = false - - const finish = (error: Error | null): void => { - if (settled) { - return - } - settled = true - clearTimeout(timeout) - if (error) { - reject(error) - return - } - resolve() - } - - // Why: these probes run while loading Automations; a wedged PATH shim must - // not keep the list IPC pending forever. - const timeout = setTimeout(() => { - child?.kill() - finish(new Error(`Command lookup timed out after ${LOCAL_COMMAND_LOOKUP_TIMEOUT_MS}ms.`)) - }, LOCAL_COMMAND_LOOKUP_TIMEOUT_MS) - - try { - child = execFile(command, args, { encoding: 'utf-8' }, (error) => { - finish(error ?? null) - }) - } catch (error) { - finish(error instanceof Error ? error : new Error(String(error))) - } - }) -} - +// Why: local automation mutations back UI actions; a wedged CLI must not keep +// create/edit/run/delete pending after Node signals a timeout. function runLocalAutomationCommand(command: string, args: string[]): Promise { - return new Promise((resolve, reject) => { - let child: ReturnType | null = null - let settled = false - - const finish = (error: Error | null): void => { - if (settled) { - return - } - settled = true - clearTimeout(timeout) - if (error) { - reject(error) - return - } - resolve() - } - - // Why: local automation mutations back UI actions; a wedged CLI must not - // keep create/edit/run/delete pending after Node signals a timeout. - const timeout = setTimeout(() => { - child?.kill() - finish( - new Error( - `Local automation command timed out after ${LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS}ms.` - ) - ) - }, LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS) - - try { - child = execFile( - command, - args, - { encoding: 'utf-8', timeout: LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS }, - (error) => { - finish(error ?? null) - } - ) - } catch (error) { - finish(error instanceof Error ? error : new Error(String(error))) - } + return runLocalProviderCommand(command, args, { + timeoutMs: LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS, + timeoutMessage: `Local automation command timed out after ${LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS}ms.` }) } @@ -165,83 +142,84 @@ async function readLocalOpenClawJobs(): Promise { return isRecord(parsed) && Array.isArray(parsed.jobs) ? parsed.jobs : [] } -async function listLocalHermesManager(): Promise { - const [hermesAvailableResult, jobsResult] = await Promise.allSettled([ - isCommandOnPath('hermes'), - readLocalHermesJobs() +function mapProviderJobs( + provider: ExternalAutomationProvider, + managerId: string, + jobs: unknown[] +): ExternalAutomationManager['jobs'] { + return provider === 'hermes' ? mapHermesJobs(managerId, jobs) : mapOpenClawJobs(managerId, jobs) +} + +async function listLocalManager( + provider: ExternalAutomationProvider +): Promise { + const [availableResult, jobsResult] = await Promise.allSettled([ + isCommandOnPath(provider), + provider === 'hermes' ? readLocalHermesJobs() : readLocalOpenClawJobs() ]) - const hermesAvailable = - hermesAvailableResult.status === 'fulfilled' && hermesAvailableResult.value + const available = availableResult.status === 'fulfilled' && availableResult.value const jobs = jobsResult.status === 'fulfilled' ? jobsResult.value : [] const readError = jobsResult.status === 'rejected' ? String(jobsResult.reason) : null - if (!hermesAvailable && jobs.length === 0 && !readError) { + if (!available && jobs.length === 0 && !readError) { return null } - const managerId = 'hermes:local' + const managerId = `${provider}:local` + const providerLabel = PROVIDER_LABELS[provider] return { id: managerId, - provider: 'hermes', - label: 'Hermes on this computer', + provider, + label: `${providerLabel} on this computer`, targetLabel: 'this computer', target: { type: 'local' }, status: readError ? 'unavailable' : 'available', error: readError ?? - (hermesAvailable ? null : 'Hermes jobs were found, but the hermes CLI is not on PATH.'), - canManage: !readError && hermesAvailable, - jobs: mapHermesJobs(managerId, jobs) + (available + ? null + : `${providerLabel} jobs were found, but the ${provider} CLI is not on PATH.`), + canManage: !readError && available, + jobs: mapProviderJobs(provider, managerId, jobs) } } -async function listLocalOpenClawManager(): Promise { - const [openClawAvailableResult, jobsResult] = await Promise.allSettled([ - isCommandOnPath('openclaw'), - readLocalOpenClawJobs() - ]) - const openClawAvailable = - openClawAvailableResult.status === 'fulfilled' && openClawAvailableResult.value - const jobs = jobsResult.status === 'fulfilled' ? jobsResult.value : [] - const readError = jobsResult.status === 'rejected' ? String(jobsResult.reason) : null - if (!openClawAvailable && jobs.length === 0 && !readError) { - return null - } - const managerId = 'openclaw:local' - return { - id: managerId, - provider: 'openclaw', - label: 'OpenClaw on this computer', - targetLabel: 'this computer', - target: { type: 'local' }, - status: readError ? 'unavailable' : 'available', - error: - readError ?? - (openClawAvailable ? null : 'OpenClaw jobs were found, but the openclaw CLI is not on PATH.'), - canManage: !readError && openClawAvailable, - jobs: mapOpenClawJobs(managerId, jobs) +function requireMultiplexer( + connectionId: string +): NonNullable> { + const mux = getActiveMultiplexer(connectionId) + if (!mux || mux.isDisposed()) { + throw new Error(`SSH target "${connectionId}" is not connected.`) } + return mux +} + +/** + * True only for a structured JSON-RPC `-32601`, never for error prose. + * + * A `-32601` object means the peer parsed the request and declined the method, + * so it is positive evidence the connection is healthy. Matching the words + * "method not found" instead would let a genuinely broken relay be reported as + * a missing capability, hiding a dead connection behind a capability notice. + */ +function isRelayMethodNotFoundError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + (error as { code?: unknown }).code === JsonRpcErrorCode.MethodNotFound + ) } function remoteRelayErrorMessage(error: unknown): string { - const message = error instanceof Error ? error.message : String(error) - if (message.includes('-32601') || /method not found/i.test(message)) { + if (isRelayMethodNotFoundError(error)) { return 'Remote relay does not support external automation management. Reconnect the SSH target to deploy the latest relay.' } - return message -} - -async function listRemoteHermesManager(target: SshTarget): Promise { - return listRemoteManager(target, 'hermes') -} - -async function listRemoteOpenClawManager(target: SshTarget): Promise { - return listRemoteManager(target, 'openclaw') + return error instanceof Error ? error.message : String(error) } async function listRemoteManager( target: SshTarget, provider: ExternalAutomationProvider ): Promise { - const providerLabel = provider === 'hermes' ? 'Hermes' : 'OpenClaw' + const providerLabel = PROVIDER_LABELS[provider] const managerProviderId = `${provider}:ssh:${target.id}` const mux = getActiveMultiplexer(target.id) if (!mux || mux.isDisposed()) { @@ -277,10 +255,7 @@ async function listRemoteManager( error: readError ?? (commandAvailable ? null : `${providerLabel} CLI is not on the remote PATH.`), canManage: !readError && commandAvailable, - jobs: - provider === 'hermes' - ? mapHermesJobs(managerProviderId, result.jobs ?? []) - : mapOpenClawJobs(managerProviderId, result.jobs ?? []) + jobs: mapProviderJobs(provider, managerProviderId, result.jobs ?? []) } } catch (error) { return { @@ -297,26 +272,30 @@ async function listRemoteManager( } } -export async function listExternalAutomationManagers( - store: Store -): Promise { - const [localHermes, localOpenClaw, remote] = await Promise.all([ - listLocalHermesManager(), - listLocalOpenClawManager(), - Promise.all( - store - .getSshTargets() - // Why: runtime-owned hidden targets are excluded from SSH/run-target - // surfaces; don't probe them for external automations either. - .filter((target) => !isRuntimeOwnedSshTarget(target)) - .flatMap((target) => [listRemoteHermesManager(target), listRemoteOpenClawManager(target)]) - ) - ]) - return [ - ...(localHermes ? [localHermes] : []), - ...(localOpenClaw ? [localOpenClaw] : []), - ...remote - ] +/** + * Runs the relay's paged runs call, translating "no such method" into a code the + * renderer can act on. + * + * The structured code exists only here: Electron IPC preserves nothing but the + * message, so a renderer that re-derived this from the host's prose would be + * guessing. Translating at the boundary that can still see the code is what lets + * the caller fall back to job-carried runs without matching free text. + */ +async function requestRemoteExternalRuns( + connectionId: string, + params: { provider: ExternalAutomationProvider; jobId: string; page: number; pageSize: number } +): Promise<{ total?: number; runs?: unknown[] }> { + try { + return (await requireMultiplexer(connectionId).request('externalAutomations.runs', params)) as { + total?: number + runs?: unknown[] + } + } catch (error) { + if (isRelayMethodNotFoundError(error)) { + throw new ExternalAutomationScopeError(EXTERNAL_AUTOMATION_SCOPE_CODES.runsUnsupported) + } + throw error + } } export async function listExternalAutomationRuns( @@ -329,78 +308,55 @@ export async function listExternalAutomationRuns( const pageSize = Number.isFinite(input.pageSize) ? Math.min(100, Math.max(1, Math.floor(input.pageSize))) : 25 - if (input.provider !== 'hermes') { - return { - managerId: input.managerId, - provider: input.provider, - target: input.target, - jobId: input.jobId, - page, - pageSize, - total: 0, - runs: [] - } - } - if (input.target.type === 'local') { - const result = await readHermesCronOutputRunsPage(input.jobId, { page, pageSize }) - return { - managerId: input.managerId, - provider: input.provider, - target: input.target, - jobId: input.jobId, - page, - pageSize, - total: result.total, - runs: mapHermesJobs(input.managerId, [{ id: input.jobId, runs: result.runs }])[0]?.runs ?? [] - } - } - const mux = getActiveMultiplexer(input.target.connectionId) - if (!mux || mux.isDisposed()) { - throw new Error(`SSH target "${input.target.connectionId}" is not connected.`) - } - const result = (await mux.request('externalAutomations.runs', { - provider: input.provider, - jobId: input.jobId, - page, - pageSize - })) as { total?: number; runs?: unknown[] } - return { + const identity = { managerId: input.managerId, provider: input.provider, target: input.target, jobId: input.jobId, page, - pageSize, + pageSize + } + if (input.provider !== 'hermes') { + return { ...identity, total: 0, runs: [] } + } + const result = + input.target.type === 'local' + ? await readHermesCronOutputRunsPage(input.jobId, { page, pageSize }) + : await requestRemoteExternalRuns(input.target.connectionId, { + provider: input.provider, + jobId: input.jobId, + page, + pageSize + }) + return { + ...identity, total: typeof result.total === 'number' && Number.isFinite(result.total) ? result.total : 0, runs: mapHermesJobs(input.managerId, [{ id: input.jobId, runs: result.runs ?? [] }])[0]?.runs ?? [] } } -function hermesCommandForAction(action: ExternalAutomationAction): string { - switch (action) { - case 'pause': - return 'pause' - case 'resume': - return 'resume' - case 'run': - return 'run' - case 'delete': - return 'remove' - } +const PROVIDER_ACTION_COMMANDS: Record< + ExternalAutomationProvider, + Record +> = { + hermes: { pause: 'pause', resume: 'resume', run: 'run', delete: 'remove' }, + openclaw: { pause: 'disable', resume: 'enable', run: 'run', delete: 'rm' } } -function openClawCommandForAction(action: ExternalAutomationAction): string { - switch (action) { - case 'pause': - return 'disable' - case 'resume': - return 'enable' - case 'run': - return 'run' - case 'delete': - return 'rm' +/** Fails closed like its neighbours: an unlisted or inherited key is not an action. */ +function providerActionCommand( + provider: ExternalAutomationProvider, + action: ExternalAutomationAction +): string { + const commands = Object.hasOwn(PROVIDER_ACTION_COMMANDS, provider) + ? PROVIDER_ACTION_COMMANDS[provider] + : null + const command = commands && Object.hasOwn(commands, action) ? commands[action] : null + if (typeof command !== 'string') { + throw new Error('Unsupported external automation action.') } + return command } function normalizeHermesCronMutationInput(input: ExternalAutomationCreateInput): { @@ -430,48 +386,18 @@ function normalizeHermesCronMutationInput(input: ExternalAutomationCreateInput): } } -function hermesCronCreateArgs(input: { - name: string - prompt: string - schedule: string - workdir: string | null -}): string[] { - const args = [ - 'cron', - 'create', - input.schedule, - input.prompt, - '--name', - input.name, - '--deliver', - 'local' - ] - if (input.workdir) { - args.push('--workdir', input.workdir) - } - return args -} - -function hermesCronEditArgs( - jobId: string, - input: { - name: string - prompt: string - schedule: string - workdir: string | null - } +/** Edit args when `jobId` is given, create args otherwise; both are argv, never a shell string. */ +function hermesCronMutationArgs( + jobId: string | null, + input: { name: string; prompt: string; schedule: string; workdir: string | null } ): string[] { - const args = [ - 'cron', - 'edit', - jobId, - '--schedule', - input.schedule, - '--prompt', - input.prompt, - '--name', - input.name - ] + const args = jobId + ? ['cron', 'edit', jobId, '--schedule', input.schedule, '--prompt', input.prompt] + : ['cron', 'create', input.schedule, input.prompt] + args.push('--name', input.name) + if (!jobId) { + args.push('--deliver', 'local') + } if (input.workdir) { args.push('--workdir', input.workdir) } @@ -483,15 +409,11 @@ export async function createExternalAutomation( ): Promise { const normalized = normalizeHermesCronMutationInput(input) if (input.target.type === 'local') { - await runLocalAutomationCommand('hermes', hermesCronCreateArgs(normalized)) + await runLocalAutomationCommand('hermes', hermesCronMutationArgs(null, normalized)) clearHermesCronOutputRunCountCache() return } - const mux = getActiveMultiplexer(input.target.connectionId) - if (!mux || mux.isDisposed()) { - throw new Error(`SSH target "${input.target.connectionId}" is not connected.`) - } - await mux.request('externalAutomations.create', { + await requireMultiplexer(input.target.connectionId).request('externalAutomations.create', { provider: input.provider, ...normalized }) @@ -505,15 +427,11 @@ export async function updateExternalAutomation( } const normalized = normalizeHermesCronMutationInput(input) if (input.target.type === 'local') { - await runLocalAutomationCommand('hermes', hermesCronEditArgs(input.jobId, normalized)) + await runLocalAutomationCommand('hermes', hermesCronMutationArgs(input.jobId, normalized)) clearHermesCronOutputRunCountCache(input.jobId) return } - const mux = getActiveMultiplexer(input.target.connectionId) - if (!mux || mux.isDisposed()) { - throw new Error(`SSH target "${input.target.connectionId}" is not connected.`) - } - await mux.request('externalAutomations.update', { + await requireMultiplexer(input.target.connectionId).request('externalAutomations.update', { provider: input.provider, jobId: input.jobId, ...normalized @@ -526,10 +444,7 @@ export async function runExternalAutomationAction( if (!EXTERNAL_JOB_ID_PATTERN.test(input.jobId)) { throw new Error('Invalid external automation job ID.') } - const command = - input.provider === 'hermes' - ? hermesCommandForAction(input.action) - : openClawCommandForAction(input.action) + const command = providerActionCommand(input.provider, input.action) if (input.target.type === 'local') { await runLocalAutomationCommand(input.provider, ['cron', command, input.jobId]) if (input.provider === 'hermes') { @@ -537,13 +452,101 @@ export async function runExternalAutomationAction( } return } - const mux = getActiveMultiplexer(input.target.connectionId) - if (!mux || mux.isDisposed()) { - throw new Error(`SSH target "${input.target.connectionId}" is not connected.`) - } - await mux.request('externalAutomations.act', { + await requireMultiplexer(input.target.connectionId).request('externalAutomations.act', { provider: input.provider, action: input.action, jobId: input.jobId }) } + +export type ScopedExternalAutomationDeps = { + registry: DesktopSshTargetRegistry + scheduler: ExternalAutomationProbeScheduler + cache: ExternalAutomationManagerCache +} + +export type ScopedExternalAutomations = { + listManager: ( + request: ScopedExternalManagerListRequest + ) => Promise + listRuns: (request: ScopedExternalManagerRunsRequest) => Promise + create: (request: ScopedExternalManagerCreateRequest) => Promise + update: (request: ScopedExternalManagerUpdateRequest) => Promise + runAction: (request: ScopedExternalManagerActionRequest) => Promise +} + +/** + * Scoped entry points: one captured desktop owner in, one host's managers out. + * Every call re-resolves its scope before any probe, so a host that changed under + * a held-open dialog fails closed instead of acting on the new incarnation. + */ +export function createScopedExternalAutomations( + deps: ScopedExternalAutomationDeps +): ScopedExternalAutomations { + const resolve = (request: ScopedExternalAutomationRequest): ResolvedExternalAutomationScope => + resolveExternalAutomationScope(request, deps.registry) + const mutationInput = ( + scope: ResolvedExternalAutomationScope, + fields: ScopedExternalManagerMutationFields + ): ExternalAutomationCreateInput => ({ + managerId: scope.managerId, + provider: scope.provider, + target: scope.target, + name: fields.name, + prompt: fields.prompt, + schedule: fields.schedule, + workdir: fields.workdir + }) + + return { + async listManager(request) { + const scope = resolve(request) + const key = { ownerKey: scope.ownerKey, provider: scope.provider } + return await deps.cache.resolve( + key, + () => + deps.scheduler.schedule({ + key: externalAutomationManagerCacheKey(key), + scopeKey: scope.ownerKey, + run: () => + scope.sshTarget + ? listRemoteManager(scope.sshTarget, scope.provider) + : listLocalManager(scope.provider) + }), + { refresh: request.refresh } + ) + }, + async listRuns(request) { + const scope = resolve(request) + return await listExternalAutomationRuns({ + managerId: scope.managerId, + provider: scope.provider, + target: scope.target, + jobId: request.jobId, + page: request.page, + pageSize: request.pageSize + }) + }, + async create(request) { + const scope = resolve(request) + await createExternalAutomation(mutationInput(scope, request)) + deps.cache.invalidateOwner(scope.ownerKey) + }, + async update(request) { + const scope = resolve(request) + await updateExternalAutomation({ ...mutationInput(scope, request), jobId: request.jobId }) + deps.cache.invalidateOwner(scope.ownerKey) + }, + async runAction(request) { + const scope = resolve(request) + await runExternalAutomationAction({ + managerId: scope.managerId, + provider: scope.provider, + target: scope.target, + jobId: request.jobId, + action: request.action + }) + deps.cache.invalidateOwner(scope.ownerKey) + } + } +} diff --git a/src/main/automations/headless-dispatch-runner.ts b/src/main/automations/headless-dispatch-runner.ts new file mode 100644 index 00000000000..8d4e3aed290 --- /dev/null +++ b/src/main/automations/headless-dispatch-runner.ts @@ -0,0 +1,94 @@ +import type { + Automation, + AutomationDispatchResult, + AutomationPrecheckResult, + AutomationRun +} from '../../shared/automations-types' +import { + didAutomationPrecheckPass, + formatAutomationPrecheckFailure +} from '../../shared/automation-precheck' +import type { HeadlessAutomationDispatcher } from './headless-dispatch' +import type { AutomationRunTargetResult } from './run-target-resolution' +import type { AutomationRunWriter } from './automation-run-writer' + +export type HeadlessAutomationDispatchContext = { + automation: Automation + run: AutomationRun + target: Extract + dispatcher: HeadlessAutomationDispatcher + runs: AutomationRunWriter + runPrecheck: () => Promise + markDispatchResult: (result: AutomationDispatchResult) => Promise + watchRun: (run: AutomationRun) => void +} + +function describeDispatchError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +export async function runHeadlessAutomationDispatch( + ctx: HeadlessAutomationDispatchContext +): Promise { + const { automation, run, target, runs } = ctx + const precheckResult = + run.trigger === 'scheduled' && automation.precheck ? await ctx.runPrecheck() : null + if (precheckResult && !didAutomationPrecheckPass(precheckResult)) { + return runs.updateRun({ + runId: run.id, + status: 'skipped_precheck', + workspaceId: automation.workspaceId, + precheckResult, + error: formatAutomationPrecheckFailure(precheckResult) + }) + } + try { + const launch = await ctx.dispatcher({ automation, run, target }) + const launchRunTarget = { + workspaceId: launch.workspaceId, + workspaceDisplayName: launch.workspaceDisplayName ?? null, + terminalSessionId: launch.terminalSessionId, + terminalPaneKey: launch.terminalPaneKey ?? null, + terminalPtyId: launch.terminalPtyId ?? null + } + const updated = runs.updateRun({ + runId: run.id, + status: 'dispatched', + ...launchRunTarget, + error: null + }) + if (!launch.completion) { + // Why: a dispatcher that reports no completion promise would otherwise + // leave the run at 'dispatched' for the process lifetime. + ctx.watchRun(updated) + return updated + } + void launch.completion + .then((completion) => + ctx.markDispatchResult({ + runId: run.id, + status: completion.status, + ...launchRunTarget, + precheckResult, + outputSnapshot: completion.outputSnapshot ?? null, + error: completion.error ?? null + }) + ) + .catch((error) => + ctx.markDispatchResult({ + runId: run.id, + status: 'dispatch_failed', + ...launchRunTarget, + error: describeDispatchError(error) + }) + ) + return updated + } catch (error) { + return runs.updateRun({ + runId: run.id, + status: 'dispatch_failed', + workspaceId: automation.workspaceId, + error: describeDispatchError(error) + }) + } +} diff --git a/src/main/automations/refused-manual-run.test.ts b/src/main/automations/refused-manual-run.test.ts new file mode 100644 index 00000000000..2e6b4a16cdf --- /dev/null +++ b/src/main/automations/refused-manual-run.test.ts @@ -0,0 +1,167 @@ +/** + * doc:94 pairs "return the typed `target_removed` conflict" with "record a + * skipped-run reason". The scheduler already did both; a manual attempt used to + * throw and write nothing, so the same automation left a row when a schedule + * refused it and no trace at all when the user asked by hand. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { Automation, AutomationRun } from '../../shared/automations-types' +import type { Repo } from '../../shared/repo-types' +import type { SshTarget } from '../../shared/ssh-types' +import { getDefaultPersistedState } from '../../shared/constants' +import { AUTOMATION_OWNER_CONFLICT_CODES } from '../../shared/automation-owner-conflict' +import type { AutomationOwnerPrecondition } from '../../shared/automation-owner-precondition' +import { AutomationService } from './service' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' + +const testState = { dir: '' } +const ipcHandlers = new Map unknown>() + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + ipcMain: { + handle: (channel: string, handler: (event: unknown, args: unknown) => unknown) => { + ipcHandlers.set(channel, handler) + } + }, + safeStorage: { isEncryptionAvailable: () => false } +})) +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) + +function automation(overrides: Partial): Automation { + return { + id: 'a1', + name: 'Nightly', + prompt: 'go', + precheck: null, + agentId: 'claude', + projectId: 'repo-ssh', + executionTargetType: 'ssh', + executionTargetId: 'ssh-1', + executionTargetGeneration: 7, + schedulerOwner: 'local_host_service', + workspaceMode: 'new_per_run', + workspaceId: null, + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: 0, + enabled: true, + nextRunAt: 0, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 0, + updatedAt: 0, + ...overrides + } as Automation +} + +const REPOS: Repo[] = [ + { + id: 'repo-ssh', + path: '/remote', + displayName: 'Remote', + badgeColor: '#000', + addedAt: 2, + connectionId: 'ssh-1' + } +] as Repo[] + +const TARGETS: SshTarget[] = [ + { id: 'ssh-1', label: 'Box', host: 'box', port: 22, username: 'me', generation: 7 } +] as SshTarget[] + +const AUTOMATIONS: Automation[] = [ + automation({ id: 'live-1' }), + automation({ id: 'orphan-1', executionTargetId: 'ssh-gone', executionTargetGeneration: 3 }) +] + +async function createStore() { + mkdirSync(testState.dir, { recursive: true }) + writeFileSync( + join(testState.dir, 'orca-data.json'), + JSON.stringify({ + ...getDefaultPersistedState(testState.dir), + repos: REPOS, + sshTargets: TARGETS, + sshTargetGenerationCounter: 7, + automations: AUTOMATIONS + }), + 'utf-8' + ) + vi.resetModules() + installFakeAppEnvironment({ getPath: () => testState.dir }) + const { Store, initDataPath } = await import('../persistence') + initDataPath() + const store = new Store() + const service = new AutomationService(store, { tickMs: 60_000 }) + // Manual runs arrive over the shared runtime RPC surface for every transport. + const { OrcaRuntimeService } = await import('../runtime/orca-runtime') + const runtime = new OrcaRuntimeService(store as never) + runtime.setAutomationService(service) + return { store, service, runtime } +} + +type RuntimeWithRunNow = { + runAutomationNow: ( + id: string, + expectedOwner?: AutomationOwnerPrecondition + ) => Promise +} + +const runNow = async ( + runtime: RuntimeWithRunNow, + id: string, + expectedOwner?: AutomationOwnerPrecondition +): Promise => await runtime.runAutomationNow(id, expectedOwner) + +describe('manual run refused before dispatch', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'automation-refused-manual-')) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('answers the caller with the conflict and still leaves the user a run record', async () => { + const { store, runtime } = await createStore() + + await expect(runNow(runtime, 'orphan-1')).rejects.toMatchObject({ + code: AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved + }) + + // Same sentence the scheduler writes for this record, so run history reads + // the same whether the refusal came from a schedule or from the user. + expect(store.listAutomationRuns('orphan-1')).toMatchObject([ + { + status: 'skipped_unavailable', + trigger: 'manual', + error: 'The automation host is no longer registered, so this automation has nowhere to run.' + } + ]) + }) + + /** + * `owner_changed` says the CALLER is stale, not that the record is unrunnable. + * A client that re-reads the owner and retries would otherwise fill run history + * with rows for runs the user never saw refused. + */ + it('writes nothing when the refusal is about a stale caller, not a lost host', async () => { + const { store, runtime } = await createStore() + const stale = { + selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 6 } + } as const satisfies AutomationOwnerPrecondition + + await expect(runNow(runtime, 'live-1', stale)).rejects.toMatchObject({ + code: AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged + }) + + expect(store.listAutomationRuns('live-1')).toEqual([]) + }) +}) diff --git a/src/main/automations/refused-manual-run.ts b/src/main/automations/refused-manual-run.ts new file mode 100644 index 00000000000..f6a103408c2 --- /dev/null +++ b/src/main/automations/refused-manual-run.ts @@ -0,0 +1,42 @@ +/** + * The execute fence, plus the run-history row a refused manual attempt leaves. + * + * doc:94 pairs "return the typed `target_removed` conflict" with "record a + * skipped-run reason", and the scheduled path already does both. Without this + * the same automation produces a row when a schedule refuses it and nothing at + * all when the user asks by hand, so run history contradicts itself. + * + * Only `target_removed` records: it is the verdict that the record has no host + * at all. `owner_changed` and `fencing_required` say the CALLER is stale, and a + * client re-reading and retrying must not litter history on the way. + */ + +import type { AutomationRun } from '../../shared/automations-types' +import { + AUTOMATION_OWNER_CONFLICT_CODES, + AutomationOwnerConflictError +} from '../../shared/automation-owner-conflict' + +type RefusableAutomationService = { + runNow: (automationId: string) => Promise + recordRefusedRun: (automationId: string) => void +} + +export async function runAutomationNowFenced(input: { + fence: () => void + service: RefusableAutomationService + automationId: string +}): Promise { + try { + input.fence() + } catch (error) { + if ( + error instanceof AutomationOwnerConflictError && + error.code === AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved + ) { + input.service.recordRefusedRun(input.automationId) + } + throw error + } + return await input.service.runNow(input.automationId) +} diff --git a/src/main/automations/retained-run-reconciliation.test.ts b/src/main/automations/retained-run-reconciliation.test.ts new file mode 100644 index 00000000000..57afd5ba963 --- /dev/null +++ b/src/main/automations/retained-run-reconciliation.test.ts @@ -0,0 +1,252 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { Repo } from '../../shared/repo-types' +import { + isFinalAutomationRunStatus, + type Automation, + type AutomationRun +} from '../../shared/automations-types' +import { AutomationService } from './service' +import type { + AutomationRunCompletionObservation, + AutomationRunTerminalObserver +} from './run-completion-watcher' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' + +const testState = { dir: '' } + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + ipcMain: { handle: () => {} }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length) + } +})) + +async function createStore() { + vi.resetModules() + installFakeAppEnvironment({ getPath: () => testState.dir }) + const { Store, initDataPath } = await import('../persistence') + initDataPath() + return new Store() +} + +type TestStore = Awaited> + +const makeRepo = (): Repo => ({ + id: 'r1', + path: '/repo', + displayName: 'test', + badgeColor: '#fff', + addedAt: 1 +}) + +const LAUNCH_TARGET = { + workspaceId: 'wt1', + terminalSessionId: 'tab-1', + terminalPaneKey: 'tab-1:11111111-2222-4333-8444-555555555555', + terminalPtyId: 'pty-1' +} + +/** Well past the reconciler's post-ready settle window. */ +const AFTER_SETTLE_MS = 10 * 60 * 1000 + +function createAutomation(store: TestStore): Automation { + store.addRepo(makeRepo()) + return store.createAutomation({ + name: 'Nightly check', + prompt: 'Check the repo', + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'existing', + workspaceId: 'wt1', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-12T00:00:00Z').getTime() + }) +} + +/** A run left behind by a quit while its agent was mid-task. */ +function retainDispatchedRun(store: TestStore, automation: Automation): AutomationRun { + const run = store.createAutomationRun(automation, Date.now() - 60_000, 'manual') + return store.updateAutomationRun({ + runId: run.id, + status: 'dispatched', + ...LAUNCH_TARGET, + error: null + }) +} + +function readRun(store: TestStore, automationId: string, runId: string): AutomationRun { + const run = store.listAutomationRuns(automationId).find((entry) => entry.id === runId) + if (!run) { + throw new Error('run missing') + } + return run +} + +/** Stands in for the window graph: null until the pane it describes is mounted. */ +function createPaneSurface(): { + observer: AutomationRunTerminalObserver + mountPane: () => void + settleObservation: (value: AutomationRunCompletionObservation) => void + observedHandles: string[] +} { + const state = { mounted: false } + const observedHandles: string[] = [] + let resolveObservation: ((value: AutomationRunCompletionObservation) => void) | null = null + return { + observer: { + resolveRunTerminal: (run) => (state.mounted && run.terminalPaneKey ? 'handle-1' : null), + observeCompletion: (handle) => { + observedHandles.push(handle) + return new Promise((resolve) => { + resolveObservation = resolve + }) + } + }, + mountPane: () => { + state.mounted = true + }, + settleObservation: (value) => resolveObservation?.(value), + observedHandles + } +} + +describe('reconciling retained runs against a graph that has not published yet', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-retained-run-')) + vi.useFakeTimers() + // Away from the automation's 09:00 UTC occurrence so timer advances stay inert. + vi.setSystemTime(new Date('2026-06-01T12:00:00Z')) + }) + + afterEach(() => { + vi.useRealTimers() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('does not fail a desktop-startup retained run while the window graph is empty', async () => { + // Desktop arms automations inside openMainWindow, before the renderer has + // loaded — every pane lookup returns null and none of it is evidence. + const store = await createStore() + const automation = createAutomation(store) + const retained = retainDispatchedRun(store, automation) + const surface = createPaneSurface() + const service = new AutomationService(store, { terminalObserver: surface.observer }) + + service.setWebContents({ isDestroyed: () => false } as never) + service.start() + await vi.advanceTimersByTimeAsync(AFTER_SETTLE_MS) + + expect(readRun(store, automation.id, retained.id).status).toBe('dispatched') + expect(readRun(store, automation.id, retained.id).error).toBeNull() + service.stop() + }) + + it('re-attaches to the run once the restored pane mounts seconds later', async () => { + const store = await createStore() + const automation = createAutomation(store) + const retained = retainDispatchedRun(store, automation) + const surface = createPaneSurface() + const service = new AutomationService(store, { terminalObserver: surface.observer }) + + service.start() + service.setRendererReady() + await vi.advanceTimersByTimeAsync(5_000) + expect(surface.observedHandles).toEqual([]) + + surface.mountPane() + await vi.advanceTimersByTimeAsync(5_000) + expect(surface.observedHandles).toEqual(['handle-1']) + + surface.settleObservation({ status: 'completed', error: null }) + await vi.advanceTimersByTimeAsync(0) + expect(readRun(store, automation.id, retained.id).status).toBe('completed') + service.stop() + }) + + it('persists the terminal status exactly once when the restored pane finishes the run', async () => { + const store = await createStore() + const automation = createAutomation(store) + const retained = retainDispatchedRun(store, automation) + const surface = createPaneSurface() + const service = new AutomationService(store, { terminalObserver: surface.observer }) + + const transitions: string[] = [] + const persist = store.updateAutomationRun.bind(store) + vi.spyOn(store, 'updateAutomationRun').mockImplementation((update) => { + const before = store.listAutomationRuns(automation.id).find((r) => r.id === update.runId) + if (before && !isFinalAutomationRunStatus(before.status)) { + if (isFinalAutomationRunStatus(update.status)) { + transitions.push(update.status) + } + } + return persist(update) + }) + + service.start() + service.setRendererReady() + await vi.advanceTimersByTimeAsync(5_000) + + // The restored pane comes back and its renderer reports the real outcome. + surface.mountPane() + await service.markDispatchResult({ + runId: retained.id, + status: 'completed', + ...LAUNCH_TARGET, + error: null + }) + await vi.advanceTimersByTimeAsync(AFTER_SETTLE_MS) + + expect(transitions).toEqual(['completed']) + expect(readRun(store, automation.id, retained.id).status).toBe('completed') + expect(readRun(store, automation.id, retained.id).error).toBeNull() + service.stop() + }) + + it('does not fail a serve-startup retained run whose host pane has not reattached', async () => { + // Serve publishes an explicitly empty graph and adopts SSH panes only after + // the connection comes up, so its surface answers null at start() too. + const store = await createStore() + const automation = createAutomation(store) + const retained = retainDispatchedRun(store, automation) + const surface = createPaneSurface() + const service = new AutomationService(store, { + headlessDispatcher: async () => ({ ...LAUNCH_TARGET }), + terminalObserver: surface.observer + }) + + service.start() + await vi.advanceTimersByTimeAsync(30_000) + expect(readRun(store, automation.id, retained.id).status).toBe('dispatched') + + surface.mountPane() + await vi.advanceTimersByTimeAsync(5_000) + expect(surface.observedHandles).toEqual(['handle-1']) + expect(readRun(store, automation.id, retained.id).status).toBe('dispatched') + service.stop() + }) + + it('still closes out a run the ready surface cannot find after the settle window', async () => { + const store = await createStore() + const automation = createAutomation(store) + const retained = retainDispatchedRun(store, automation) + const surface = createPaneSurface() + const service = new AutomationService(store, { terminalObserver: surface.observer }) + + service.start() + service.setRendererReady() + await vi.advanceTimersByTimeAsync(AFTER_SETTLE_MS) + + expect(readRun(store, automation.id, retained.id).status).toBe('dispatch_failed') + expect(readRun(store, automation.id, retained.id).error).toBe( + 'Orca lost the terminal for this run before it reported completion.' + ) + service.stop() + }) +}) diff --git a/src/main/automations/retained-run-reconciliation.ts b/src/main/automations/retained-run-reconciliation.ts new file mode 100644 index 00000000000..eeeb6d39b79 --- /dev/null +++ b/src/main/automations/retained-run-reconciliation.ts @@ -0,0 +1,103 @@ +import type { AutomationRun } from '../../shared/automations-types' + +/** Cadence for re-checking a retained run whose pane has not remounted yet. */ +const RETRY_INTERVAL_MS = 2_000 +/** Grace after the terminal surface reports ready before an unresolvable run is + * called lost. Covers restored-pane remount and SSH/WSL reattach, and matches + * the run observer's agent-start deadline. */ +const SURFACE_SETTLE_MS = 2 * 60 * 1000 + +export type RetainedRunReconcilerDeps = { + /** Attaches a watcher; false while the terminal surface cannot answer yet. */ + attach: (run: AutomationRun) => boolean + /** False once the run reached a terminal status by some other path. */ + stillRetained: (run: AutomationRun) => boolean + strand: (run: AutomationRun) => void +} + +/** + * Startup reconciliation of retained non-terminal runs. + * + * Why staged rather than one synchronous pass: at authority startup no window + * graph has published yet, so "this pane has no terminal" means "not known yet", + * never "the terminal is gone" — the same positive-evidence rule the design doc + * applies to ownership. Only a surface that has reported ready and still cannot + * resolve the pane after a grace window is evidence of a lost run. + */ +export class RetainedRunReconciler { + private readonly deps: RetainedRunReconcilerDeps + private readonly pending = new Map() + private timer: ReturnType | null = null + private surfaceReadyAt: number | null = null + private disposed = false + + constructor(deps: RetainedRunReconcilerDeps) { + this.deps = deps + } + + reconcile(runs: readonly AutomationRun[]): void { + if (this.disposed) { + return + } + for (const run of runs) { + if (!this.deps.attach(run)) { + this.pending.set(run.id, run) + } + } + this.sweep() + } + + /** The authority's terminal surface can now answer pane lookups. */ + markSurfaceReady(): void { + if (this.disposed) { + return + } + this.surfaceReadyAt ??= Date.now() + this.sweep() + } + + dispose(): void { + this.disposed = true + this.pending.clear() + this.disarm() + } + + private sweep(): void { + const strandAt = this.surfaceReadyAt === null ? null : this.surfaceReadyAt + SURFACE_SETTLE_MS + // Deleting the current entry mid-iteration is defined for Map; nothing here + // re-enters the reconciler, so no snapshot is needed. + for (const [runId, run] of this.pending) { + if (!this.deps.stillRetained(run) || this.deps.attach(run)) { + this.pending.delete(runId) + continue + } + if (strandAt !== null && Date.now() >= strandAt) { + this.pending.delete(runId) + this.deps.strand(run) + } + } + if (this.pending.size === 0) { + this.disarm() + return + } + this.arm() + } + + private arm(): void { + if (this.timer || this.disposed) { + return + } + const timer = setInterval(() => this.sweep(), RETRY_INTERVAL_MS) + // Why: a pending retry must never be the reason a process stays alive. + ;(timer as { unref?: () => void }).unref?.() + this.timer = timer + } + + private disarm(): void { + if (!this.timer) { + return + } + clearInterval(this.timer) + this.timer = null + } +} diff --git a/src/main/automations/run-completion-watcher.test.ts b/src/main/automations/run-completion-watcher.test.ts new file mode 100644 index 00000000000..31c1aea0bb4 --- /dev/null +++ b/src/main/automations/run-completion-watcher.test.ts @@ -0,0 +1,383 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { Repo } from '../../shared/repo-types' +import type { Automation, AutomationRun } from '../../shared/automations-types' +import type { AutomationsChangedPayload } from '../../shared/runtime-client-events' +import { AutomationService } from './service' +import type { + AutomationRunCompletionObservation, + AutomationRunTerminalObserver +} from './run-completion-watcher' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' + +const testState = { dir: '' } +const ipcHandlers = new Map unknown>() + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.dir + }, + ipcMain: { + handle: (channel: string, handler: (event: unknown, args: unknown) => unknown) => { + ipcHandlers.set(channel, handler) + } + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length) + } +})) + +async function createStore() { + vi.resetModules() + installFakeAppEnvironment({ getPath: () => testState.dir }) + const { Store, initDataPath } = await import('../persistence') + initDataPath() + return new Store() +} + +const makeRepo = (overrides: Partial = {}): Repo => ({ + id: 'r1', + path: '/repo', + displayName: 'test', + badgeColor: '#fff', + addedAt: 1, + ...overrides +}) + +type TestStore = Awaited> + +function createAutomation(store: TestStore): Automation { + store.addRepo(makeRepo()) + return store.createAutomation({ + name: 'Nightly check', + prompt: 'Check the repo', + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'existing', + workspaceId: 'wt1', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-12T00:00:00Z').getTime() + }) +} + +const LAUNCH_TARGET = { + workspaceId: 'wt1', + terminalSessionId: 'tab-1', + terminalPaneKey: 'tab-1:11111111-2222-4333-8444-555555555555', + terminalPtyId: 'pty-1' +} + +function readRun(store: TestStore, automationId: string, runId: string): AutomationRun { + const run = store.listAutomationRuns(automationId).find((entry) => entry.id === runId) + if (!run) { + throw new Error('run missing') + } + return run +} + +function createObserver( + observe: (signal: AbortSignal) => Promise, + resolveRunTerminal: (run: AutomationRun) => string | null = (run) => + run.terminalPaneKey ? 'handle-1' : null +): AutomationRunTerminalObserver { + return { + resolveRunTerminal, + observeCompletion: (_handle, { signal }) => observe(signal) + } +} + +describe('authority-owned automation run completion', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-automation-completion-')) + ipcHandlers.clear() + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('brings a headless dispatched run to a terminal state with no renderer attached', async () => { + const store = await createStore() + const automation = createAutomation(store) + const service = new AutomationService(store, { + // Why: a dispatcher without a completion promise is exactly the case that + // used to strand a run at 'dispatched' for the process lifetime. + headlessDispatcher: async () => ({ ...LAUNCH_TARGET }), + terminalObserver: createObserver(async () => ({ status: 'completed', error: null })) + }) + + const run = await service.runNow(automation.id) + expect(run.status).toBe('dispatched') + await vi.waitFor(() => { + expect(readRun(store, automation.id, run.id).status).toBe('completed') + }) + service.stop() + }) + + it('leaves a headless dispatched run alone when the authority cannot observe it', async () => { + const store = await createStore() + const automation = createAutomation(store) + const service = new AutomationService(store, { + headlessDispatcher: async () => ({ ...LAUNCH_TARGET }), + terminalObserver: createObserver( + async () => ({ status: 'completed' }), + () => null + ) + }) + + const run = await service.runNow(automation.id) + expect(readRun(store, automation.id, run.id).status).toBe('dispatched') + service.stop() + }) + + /** + * The throws on this path carry transport tokens, not sentences. Every other + * refusal in run history is one fixed sentence, so leaking `terminal_handle_stale` + * into the row a user reads breaks the only convention that surface has. + */ + it('reports a failed observation in the same fixed sentence every other refusal uses', async () => { + const store = await createStore() + const automation = createAutomation(store) + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}) + const service = new AutomationService(store, { + headlessDispatcher: async () => ({ ...LAUNCH_TARGET }), + terminalObserver: createObserver(async () => { + throw new Error('terminal_handle_stale') + }) + }) + + const run = await service.runNow(automation.id) + await vi.waitFor(() => { + expect(readRun(store, automation.id, run.id).status).toBe('dispatch_failed') + }) + + expect(readRun(store, automation.id, run.id).error).toBe( + 'Orca stopped watching this run before it reported completion.' + ) + // The token is still recoverable where it is actually useful. + expect(logged.mock.calls.flat().map(String).join(' ')).toContain('terminal_handle_stale') + logged.mockRestore() + service.stop() + }) + + it('reconciles stranded runs on startup without claiming completion', async () => { + const store = await createStore() + const automation = createAutomation(store) + const dispatched = store.createAutomationRun(automation, 1_000, 'manual') + store.updateAutomationRun({ + runId: dispatched.id, + status: 'dispatched', + ...LAUNCH_TARGET, + error: null + }) + const dispatching = store.createAutomationRun(automation, 2_000, 'manual') + store.updateAutomationRun({ + runId: dispatching.id, + status: 'dispatching', + workspaceId: 'wt1', + error: null + }) + + const service = new AutomationService(store, { + terminalObserver: createObserver( + async () => ({ status: 'completed' }), + () => null + ) + }) + vi.useFakeTimers() + service.start() + // Stranding needs a surface that reported ready and still cannot find the pane. + service.setRendererReady() + await vi.advanceTimersByTimeAsync(10 * 60 * 1000) + + expect(readRun(store, automation.id, dispatched.id).status).toBe('dispatch_failed') + expect(readRun(store, automation.id, dispatching.id).status).toBe('dispatch_failed') + expect(readRun(store, automation.id, dispatched.id).error).toContain('terminal') + expect(readRun(store, automation.id, dispatching.id).error).toContain('agent started') + service.stop() + vi.useRealTimers() + }) + + it('re-attaches a watcher on startup when the session survived', async () => { + const store = await createStore() + const automation = createAutomation(store) + const run = store.createAutomationRun(automation, 1_000, 'manual') + store.updateAutomationRun({ + runId: run.id, + status: 'dispatched', + ...LAUNCH_TARGET, + error: null + }) + + let resolveObservation: ((value: AutomationRunCompletionObservation) => void) | null = null + const service = new AutomationService(store, { + terminalObserver: createObserver( + () => + new Promise((resolve) => { + resolveObservation = resolve + }) + ) + }) + service.start() + + await vi.waitFor(() => expect(resolveObservation).not.toBeNull()) + expect(readRun(store, automation.id, run.id).status).toBe('dispatched') + resolveObservation!({ status: 'completed', error: null }) + await vi.waitFor(() => { + expect(readRun(store, automation.id, run.id).status).toBe('completed') + }) + service.stop() + }) + + it('persists the terminal status once when the renderer wins the race', async () => { + const store = await createStore() + const automation = createAutomation(store) + let resolveObservation: ((value: AutomationRunCompletionObservation) => void) | null = null + const service = new AutomationService(store, { + headlessDispatcher: async () => ({ ...LAUNCH_TARGET }), + terminalObserver: createObserver( + () => + new Promise((resolve) => { + resolveObservation = resolve + }) + ) + }) + + const run = await service.runNow(automation.id) + await vi.waitFor(() => expect(resolveObservation).not.toBeNull()) + + // The renderer's own dispatch observer reports first. + await service.markDispatchResult({ + runId: run.id, + status: 'completed', + ...LAUNCH_TARGET, + error: null + }) + const afterRenderer = readRun(store, automation.id, run.id) + const writeSpy = vi.spyOn(store, 'updateAutomationRun') + + resolveObservation!({ status: 'dispatch_failed', error: 'watcher lost the terminal' }) + await new Promise((resolve) => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(writeSpy).not.toHaveBeenCalled() + const afterWatcher = readRun(store, automation.id, run.id) + expect(afterWatcher.status).toBe('completed') + expect(afterWatcher.error).toBeNull() + expect(afterWatcher.usage).toEqual(afterRenderer.usage) + writeSpy.mockRestore() + service.stop() + }) + + it('disposes watchers on stop', async () => { + const store = await createStore() + const automation = createAutomation(store) + const aborted: boolean[] = [] + const service = new AutomationService(store, { + headlessDispatcher: async () => ({ ...LAUNCH_TARGET }), + terminalObserver: createObserver( + (signal) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { + aborted.push(true) + reject(new Error('aborted')) + }) + }) + ) + }) + + const run = await service.runNow(automation.id) + service.stop() + await vi.waitFor(() => expect(aborted).toEqual([true])) + expect(readRun(store, automation.id, run.id).status).toBe('dispatched') + }) + + it('keeps a run dispatched against an authority with no completion watching', async () => { + // Why: the New client | Old runtime matrix row — the old authority publishes + // no event and reconciles nothing, so the client must not crash or invent a + // terminal status; TTL/focus revalidation stays the only refresh path. + const store = await createStore() + const automation = createAutomation(store) + const publish = vi.fn() + const service = new AutomationService(store, { + headlessDispatcher: async () => ({ ...LAUNCH_TARGET }), + onAutomationsChanged: publish + }) + + const run = await service.runNow(automation.id) + service.start() + await Promise.resolve() + expect(readRun(store, automation.id, run.id).status).toBe('dispatched') + service.stop() + }) +}) + +describe('automationsChanged publication', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-automation-events-')) + ipcHandlers.clear() + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('publishes run and usage changes after the write is persisted', async () => { + const store = await createStore() + const automation = createAutomation(store) + const seen: { payload: AutomationsChangedPayload; status: string | undefined }[] = [] + const service = new AutomationService(store, { + headlessDispatcher: async () => ({ ...LAUNCH_TARGET }), + onAutomationsChanged: (payload) => { + seen.push({ + payload, + status: store.listAutomationRuns(automation.id)[0]?.status + }) + } + }) + + const run = await service.runNow(automation.id) + expect(seen.map((entry) => entry.payload.reason)).toEqual(['run', 'run']) + expect(seen.at(-1)?.status).toBe('dispatched') + + await service.markDispatchResult({ + runId: run.id, + status: 'completed', + ...LAUNCH_TARGET, + error: null + }) + expect(seen.map((entry) => entry.payload.reason)).toEqual(['run', 'run', 'run', 'usage']) + expect(seen.at(-1)?.status).toBe('completed') + // Every run/usage write names its own host, so one automation's run cannot + // invalidate the rest of the authority. + expect(seen.every((entry) => entry.payload.selector?.kind === 'self')).toBe(true) + service.stop() + }) + + // Definition-change publication from the shared runtime methods is covered by + // src/main/runtime/automation-change-publication.test.ts against the real store. + + it('changes nothing for a consumer that ignores the unknown event type', async () => { + const store = await createStore() + const automation = createAutomation(store) + // An old client's event switch has no automationsChanged branch at all. + const oldClientState = { repoRefreshes: 0 } + const service = new AutomationService(store, { + headlessDispatcher: async () => ({ ...LAUNCH_TARGET }), + onAutomationsChanged: (payload) => { + if ((payload as { type?: string }).type === 'reposChanged') { + oldClientState.repoRefreshes += 1 + } + } + }) + + const run = await service.runNow(automation.id) + expect(oldClientState.repoRefreshes).toBe(0) + expect(readRun(store, automation.id, run.id).status).toBe('dispatched') + service.stop() + }) +}) diff --git a/src/main/automations/run-completion-watcher.ts b/src/main/automations/run-completion-watcher.ts new file mode 100644 index 00000000000..4ac125082fb --- /dev/null +++ b/src/main/automations/run-completion-watcher.ts @@ -0,0 +1,182 @@ +import { + isFinalAutomationRunStatus, + type AutomationDispatchResult, + type AutomationRun, + type AutomationRunOutputSnapshot +} from '../../shared/automations-types' +import { RetainedRunReconciler } from './retained-run-reconciliation' + +export type AutomationRunCompletionObservation = { + status: 'completed' | 'dispatch_failed' + outputSnapshot?: AutomationRunOutputSnapshot | null + error?: string | null +} + +/** The owning authority's view of a dispatched run's own terminal session. */ +export type AutomationRunTerminalObserver = { + /** Control handle for the run's terminal, or null once the authority lost it. */ + resolveRunTerminal: (run: AutomationRun) => string | null + observeCompletion: ( + handle: string, + options: { signal: AbortSignal } + ) => Promise +} + +/** Truthful reason for a run this authority can no longer observe; never claims completion. */ +export function describeStrandedAutomationRun(run: AutomationRun): string { + if (run.status === 'dispatching') { + return 'Orca stopped before this run reported that its agent started.' + } + return 'Orca lost the terminal for this run before it reported completion.' +} + +/** Why a fixed sentence: the throws here carry internal transport tokens + * (`terminal_handle_stale`, `terminal_not_found`, `request_aborted`), and a run + * history row is user copy. The token stays in the log, where it is useful. */ +function describeObservationError(error: unknown): string { + console.error('[automations] run completion observation failed:', error) + return 'Orca stopped watching this run before it reported completion.' +} + +export class AutomationRunCompletionWatcher { + private readonly observer: AutomationRunTerminalObserver + private readonly readRun: (automationId: string, runId: string) => AutomationRun | null + private readonly markDispatchResult: (result: AutomationDispatchResult) => Promise + private readonly watching = new Map() + private readonly reconciler: RetainedRunReconciler + private disposed = false + + constructor(opts: { + observer: AutomationRunTerminalObserver + readRun: (automationId: string, runId: string) => AutomationRun | null + markDispatchResult: (result: AutomationDispatchResult) => Promise + }) { + this.observer = opts.observer + this.readRun = opts.readRun + this.markDispatchResult = opts.markDispatchResult + this.reconciler = new RetainedRunReconciler({ + attach: (run) => this.attachRetainedRun(run), + stillRetained: (run) => { + const current = this.readRun(run.automationId, run.id) + return Boolean(current && !isFinalAutomationRunStatus(current.status)) + }, + strand: (run) => { + void this.finalize(run, { + status: 'dispatch_failed', + error: describeStrandedAutomationRun(run) + }).catch((error) => { + console.error('[automations] failed to reconcile stranded run:', error) + }) + } + }) + } + + /** Observes a just-dispatched run. A run whose terminal is not resolvable yet + * is left alone; startup reconciliation is what resolves stranded runs. */ + watch(run: AutomationRun): void { + if (this.disposed || this.watching.has(run.id)) { + return + } + const handle = this.observer.resolveRunTerminal(run) + if (handle) { + this.startWatch(run, handle) + } + } + + private attachRetainedRun(run: AutomationRun): boolean { + if (this.disposed) { + return false + } + if (this.watching.has(run.id)) { + return true + } + const handle = this.observer.resolveRunTerminal(run) + if (!handle) { + return false + } + this.startWatch(run, handle) + return true + } + + private startWatch(run: AutomationRun, handle: string): void { + const controller = new AbortController() + this.watching.set(run.id, controller) + void this.observeUntilTerminal(run, handle, controller).finally(() => { + if (this.watching.get(run.id) === controller) { + this.watching.delete(run.id) + } + }) + } + + private async observeUntilTerminal( + run: AutomationRun, + handle: string, + controller: AbortController + ): Promise { + let observation: AutomationRunCompletionObservation + try { + observation = await this.observer.observeCompletion(handle, { signal: controller.signal }) + } catch (error) { + if (controller.signal.aborted) { + return + } + observation = { status: 'dispatch_failed', error: describeObservationError(error) } + } + try { + await this.finalize(run, observation) + } catch (error) { + console.error('[automations] failed to persist watched run completion:', error) + } + } + + forget(runId: string): void { + this.watching.get(runId)?.abort() + this.watching.delete(runId) + } + + /** Startup pass over retained runs: re-attach where the session survived, and + * stage the rest — a run is only closed out once the terminal surface has + * reported ready and still cannot find it. */ + reconcileRetainedRuns(runs: readonly AutomationRun[]): void { + this.reconciler.reconcile( + runs.filter((run) => run.status === 'dispatched' || run.status === 'dispatching') + ) + } + + /** The authority's terminal surface can now answer pane lookups: desktop + * renderer attached, or headless serve finished adopting its PTYs. */ + markTerminalSurfaceReady(): void { + this.reconciler.markSurfaceReady() + } + + dispose(): void { + this.disposed = true + this.reconciler.dispose() + for (const controller of this.watching.values()) { + controller.abort() + } + this.watching.clear() + } + + private async finalize( + run: AutomationRun, + observation: AutomationRunCompletionObservation + ): Promise { + // Why: the renderer's dispatch observer races this watcher for the same run; + // re-reading immediately before the write keeps the terminal status single. + const current = this.readRun(run.automationId, run.id) + if (!current || isFinalAutomationRunStatus(current.status)) { + return + } + await this.markDispatchResult({ + runId: run.id, + status: observation.status, + workspaceId: current.workspaceId, + terminalSessionId: current.terminalSessionId, + terminalPaneKey: current.terminalPaneKey, + terminalPtyId: current.terminalPtyId, + outputSnapshot: observation.outputSnapshot ?? null, + error: observation.error ?? null + }) + } +} diff --git a/src/main/automations/run-target-resolution.test.ts b/src/main/automations/run-target-resolution.test.ts index 73820e316c8..d7d8620e3b7 100644 --- a/src/main/automations/run-target-resolution.test.ts +++ b/src/main/automations/run-target-resolution.test.ts @@ -4,6 +4,11 @@ import type { Automation } from '../../shared/automations-types' import type { WorkspaceRunContext } from '../../shared/task-source-context' import type { ProjectHostSetup } from '../../shared/project-types' import type { Repo } from '../../shared/repo-types' +import { + projectAutomationSelector, + type AutomationProjectionContext +} from '../../shared/automation-list-scope' +import { toAutomationOwnerPrecondition } from '../../shared/automation-owner-precondition' import { resolveAutomationRunTarget } from './run-target-resolution' function makeRepo(overrides: Partial = {}): Repo { @@ -46,7 +51,10 @@ function makeRunContext(overrides: Partial = {}): Workspace } } -function makeAutomation(runContext: WorkspaceRunContext): Automation { +function makeAutomation( + runContext: WorkspaceRunContext | null, + overrides: Partial = {} +): Automation { return { id: 'automation-1', name: 'Nightly', @@ -69,17 +77,64 @@ function makeAutomation(runContext: WorkspaceRunContext): Automation { missedRunGraceMinutes: 720, createdAt: 1, updatedAt: 1, - runContext + runContext, + ...overrides } as Automation } -function makeStore(setups: ProjectHostSetup[], repos: Repo[]): Store { +function makeStore( + setups: ProjectHostSetup[], + repos: Repo[], + projectedAutomation?: Automation +): Store { + const projectionContext: AutomationProjectionContext = { + storageAuthority: 'desktop', + sshTargetGeneration: () => 7, + repoConnectionId: (repoId) => { + const repo = repos.find((entry) => entry.id === repoId) + return repo ? repo.connectionId?.trim() || null : undefined + } + } return { getProjectHostSetups: () => setups, - getRepo: (id: string) => repos.find((repo) => repo.id === id) + getRepo: (id: string) => repos.find((repo) => repo.id === id), + automationOwnerPrecondition: () => + projectedAutomation + ? toAutomationOwnerPrecondition( + projectAutomationSelector(projectedAutomation, projectionContext) + ) + : { selector: { kind: 'self' } }, + automationCapturedHostIssue: () => null } as unknown as Store } +describe('resolveAutomationRunTarget owner projection', () => { + it('refuses a context-less local selector whose SSH-backed repo makes it malformed', () => { + const repo = makeRepo({ connectionId: 'ssh-1' }) + const automation = makeAutomation(null) + const store = makeStore([], [repo], automation) + + expect(resolveAutomationRunTarget(store, automation)).toEqual({ + ok: false, + error: 'This automation has no host to run on.' + }) + }) + + it('keeps a generation-less legacy SSH selector runnable when its host is current', () => { + const repo = makeRepo({ connectionId: 'ssh-1' }) + const automation = makeAutomation(null, { + executionTargetType: 'ssh', + executionTargetId: 'ssh-1' + }) + const store = makeStore([], [repo], automation) + + expect(resolveAutomationRunTarget(store, automation)).toMatchObject({ + ok: true, + cwd: '/repo' + }) + }) +}) + describe('resolveAutomationRunTarget projectId drift', () => { it('resolves when only the derived projectId tier differs (repo: snapshot vs github: setup)', () => { const store = makeStore([makeSetup()], [makeRepo()]) diff --git a/src/main/automations/run-target-resolution.ts b/src/main/automations/run-target-resolution.ts index fcd96007ea0..0fbe694bf26 100644 --- a/src/main/automations/run-target-resolution.ts +++ b/src/main/automations/run-target-resolution.ts @@ -1,6 +1,10 @@ import type { Store } from '../persistence' import type { Automation } from '../../shared/automations-types' import { getAutomationLegacyRepoId } from '../../shared/automation-run-identity' +import { + AUTOMATION_ORPHAN_ISSUES, + type AutomationCapturedHostIssue +} from '../../shared/automation-list-scope' import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host' import type { ProjectHostSetup } from '../../shared/project-types' import type { Repo } from '../../shared/repo-types' @@ -10,6 +14,23 @@ export type AutomationRunTargetResult = | { ok: true; cwd: string; repo: Repo; setup?: ProjectHostSetup } | { ok: false; error: string } +/** + * One fixed sentence per diagnosis, and never the same sentence twice: run + * coalescing folds repeats on identical error text, so a message that varied per + * occurrence would write a row each, and a shared one would merge a host that is + * gone with a host that was replaced. + */ +const CAPTURED_HOST_REFUSALS: Record = { + [AUTOMATION_ORPHAN_ISSUES.targetMissing]: + 'The automation host is no longer registered, so this automation has nowhere to run.', + [AUTOMATION_ORPHAN_ISSUES.targetReplaced]: + 'The automation host was removed and re-registered, so this automation must be re-adopted before it can run.', + [AUTOMATION_ORPHAN_ISSUES.workspaceHostAmbiguous]: + 'The automation workspace spans more than one host, so Orca cannot tell which one to run it on.' +} + +const NO_RUNNABLE_HOST = 'This automation has no host to run on.' + type AutomationRunTargetOptions = { allowRemoteHostScheduling?: boolean } @@ -24,6 +45,14 @@ function getLegacyPrecheckCwd(store: Store, automation: Automation): string | nu return store.getRepo(getAutomationLegacyRepoId(automation))?.path ?? null } +function resolveAutomationOwnerRefusal(store: Store, automation: Automation): string | null { + if (store.automationOwnerPrecondition(automation.id)?.selector.kind !== 'orphan') { + return null + } + const issue = store.automationCapturedHostIssue(automation) + return issue ? CAPTURED_HOST_REFUSALS[issue] : NO_RUNNABLE_HOST +} + export function resolveAutomationRunTarget( store: Store, automation: Automation, @@ -31,6 +60,10 @@ export function resolveAutomationRunTarget( ): AutomationRunTargetResult { const context = automation.runContext ?? null if (!context) { + const ownerRefusal = resolveAutomationOwnerRefusal(store, automation) + if (ownerRefusal) { + return { ok: false, error: ownerRefusal } + } const repo = store.getRepo(getAutomationLegacyRepoId(automation)) const cwd = getLegacyPrecheckCwd(store, automation) if (!repo || !cwd) { @@ -50,6 +83,16 @@ export function resolveAutomationRunTarget( } } + // Why: removing a host — or removing and re-registering it under the same id — + // keeps hostId, repoId and path intact, so every check below still passes while + // the host is gone or is a different machine. Asks the list's projection for the + // verdict rather than re-deriving one, so the row the user sees as orphaned can + // never be the row that quietly keeps firing. + const hostIssue = store.automationCapturedHostIssue(automation) + if (hostIssue) { + return { ok: false, error: CAPTURED_HOST_REFUSALS[hostIssue] } + } + const setup = store .getProjectHostSetups() .find((candidate) => candidate.id === context.projectHostSetupId) diff --git a/src/main/automations/runtime-terminal-run-observer.test.ts b/src/main/automations/runtime-terminal-run-observer.test.ts new file mode 100644 index 00000000000..a9b307c3298 --- /dev/null +++ b/src/main/automations/runtime-terminal-run-observer.test.ts @@ -0,0 +1,195 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createRuntimeAutomationRunTerminalObserver } from './runtime-terminal-run-observer' +import type { AutomationRunTerminalHost } from './runtime-terminal-run-observer' +import type { AutomationRunCompletionObservation } from './run-completion-watcher' + +const HANDLE = 'terminal-1' +const RUNTIME_TUI_IDLE_TIMEOUT_MS = 5 * 60 * 1000 + +/** The three shapes the runtime satisfies tui-idle from. `lastAgentStatus` is the + * sticky pty record, `paneTitle` the leaf branch's inlined title read, `preview` + * the ready-shell-prompt match. All three are level, none require an edge. */ +type FakePane = { + lastAgentStatus: 'idle' | 'working' | 'permission' | null + paneTitle: string | null + preview: string +} + +type FakeWaiter = { + resolve: (value: { satisfied: boolean; blockedReason?: string }) => void + reject: (error: Error) => void + timer: ReturnType +} + +function createFakeRuntime(initial: Partial) { + const pane: FakePane = { + lastAgentStatus: null, + paneTitle: null, + preview: '', + ...initial + } + const waiters = new Set() + let waitCalls = 0 + + // Mirrors the runtime's tui-idle satisfaction: sticky status, idle title, or a + // ready shell prompt — whichever is true at the instant of the read. + const satisfiedNow = (): boolean => + pane.lastAgentStatus === 'idle' || + (pane.paneTitle?.toLowerCase().includes('idle') ?? false) || + pane.preview.trimEnd().endsWith('$') + + const runtime: AutomationRunTerminalHost & { + setPane: (next: Partial) => void + waitCalls: () => number + } = { + getTerminalHandleForPaneKey: () => HANDLE, + readTerminal: async () => ({ tail: ['previous run output'] }), + waitForTerminal: (_handle, options) => { + waitCalls += 1 + if (options?.signal?.aborted) { + return Promise.reject(new Error('request_aborted')) + } + if (satisfiedNow()) { + return Promise.resolve({ satisfied: true }) + } + return new Promise((resolve, reject) => { + const waiter: FakeWaiter = { + resolve, + reject, + timer: setTimeout(() => { + waiters.delete(waiter) + reject(new Error('timeout')) + }, options?.timeoutMs ?? RUNTIME_TUI_IDLE_TIMEOUT_MS) + } + waiters.add(waiter) + }) + }, + setPane: (next) => { + Object.assign(pane, next) + if (!satisfiedNow()) { + return + } + for (const waiter of waiters) { + waiters.delete(waiter) + clearTimeout(waiter.timer) + waiter.resolve({ satisfied: true }) + } + }, + waitCalls: () => waitCalls + } + return runtime +} + +function observe(runtime: AutomationRunTerminalHost) { + const controller = new AbortController() + const settled: AutomationRunCompletionObservation[] = [] + const errors: unknown[] = [] + const observer = createRuntimeAutomationRunTerminalObserver(runtime) + const promise = observer + .observeCompletion(HANDLE, { signal: controller.signal }) + .then((observation) => { + settled.push(observation) + }) + .catch((error: unknown) => { + errors.push(error) + }) + return { controller, settled, errors, promise } +} + +describe('createRuntimeAutomationRunTerminalObserver', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('does not complete a reused run from the previous run idle status', async () => { + const runtime = createFakeRuntime({ lastAgentStatus: 'idle' }) + const run = observe(runtime) + + await vi.advanceTimersByTimeAsync(10_000) + expect(run.settled).toEqual([]) + expect(run.errors).toEqual([]) + + runtime.setPane({ lastAgentStatus: 'working' }) + await vi.advanceTimersByTimeAsync(1_000) + expect(run.settled).toEqual([]) + + runtime.setPane({ lastAgentStatus: 'idle' }) + await vi.advanceTimersByTimeAsync(1_000) + expect(run.settled[0]?.status).toBe('completed') + await run.promise + }) + + it('does not complete from a stale idle pane title (leaf branch shape)', async () => { + const runtime = createFakeRuntime({ lastAgentStatus: null, paneTitle: '✳ Claude — idle' }) + const run = observe(runtime) + + await vi.advanceTimersByTimeAsync(10_000) + expect(run.settled).toEqual([]) + + runtime.setPane({ paneTitle: '✳ Claude — working' }) + await vi.advanceTimersByTimeAsync(1_000) + expect(run.settled).toEqual([]) + + runtime.setPane({ paneTitle: '✳ Claude — idle' }) + await vi.advanceTimersByTimeAsync(1_000) + expect(run.settled[0]?.status).toBe('completed') + await run.promise + }) + + it('does not complete from a ready shell prompt left over at dispatch', async () => { + const runtime = createFakeRuntime({ preview: 'user@host repo %\n$' }) + const run = observe(runtime) + + await vi.advanceTimersByTimeAsync(10_000) + expect(run.settled).toEqual([]) + + runtime.setPane({ preview: 'claude is thinking…' }) + await vi.advanceTimersByTimeAsync(1_000) + expect(run.settled).toEqual([]) + + runtime.setPane({ lastAgentStatus: 'idle' }) + await vi.advanceTimersByTimeAsync(1_000) + expect(run.settled[0]?.status).toBe('completed') + await run.promise + }) + + it('fails the run truthfully when the pane never leaves its pre-dispatch state', async () => { + const runtime = createFakeRuntime({ lastAgentStatus: 'idle' }) + const run = observe(runtime) + + await vi.advanceTimersByTimeAsync(2 * 60 * 1000 + 1_000) + expect(run.settled[0]?.status).toBe('dispatch_failed') + expect(run.settled[0]?.error).toContain('never started') + await run.promise + }) + + it('completes a fresh launch that was never idle at dispatch', async () => { + const runtime = createFakeRuntime({ lastAgentStatus: 'working' }) + const run = observe(runtime) + + await vi.advanceTimersByTimeAsync(1_000) + expect(run.settled).toEqual([]) + + runtime.setPane({ lastAgentStatus: 'idle' }) + await vi.advanceTimersByTimeAsync(10) + expect(run.settled[0]?.status).toBe('completed') + expect(run.settled[0]?.outputSnapshot?.content).toContain('previous run output') + await run.promise + }) + + it('stops re-arming the tui-idle wait instead of looping for the process lifetime', async () => { + const runtime = createFakeRuntime({ lastAgentStatus: 'working' }) + const run = observe(runtime) + + await vi.advanceTimersByTimeAsync(6 * 60 * 60 * 1000 + RUNTIME_TUI_IDLE_TIMEOUT_MS) + expect(run.settled[0]?.status).toBe('dispatch_failed') + expect(run.settled[0]?.error).toContain('without a completion signal') + // 6h of 5-minute waits, not an unbounded re-arm. + expect(runtime.waitCalls()).toBeLessThanOrEqual(80) + await run.promise + }) +}) diff --git a/src/main/automations/runtime-terminal-run-observer.ts b/src/main/automations/runtime-terminal-run-observer.ts new file mode 100644 index 00000000000..0dc6750e8de --- /dev/null +++ b/src/main/automations/runtime-terminal-run-observer.ts @@ -0,0 +1,198 @@ +import { createHeadlessAutomationOutputSnapshotBuffer } from './headless-dispatch' +import type { + AutomationRunCompletionObservation, + AutomationRunTerminalObserver +} from './run-completion-watcher' +import type { AutomationRunOutputSnapshot } from '../../shared/automations-types' + +const TERMINAL_SNAPSHOT_LIMIT = 2_000 + +/** Cadence for re-probing a pane that already satisfied tui-idle at dispatch. */ +const AGENT_START_POLL_INTERVAL_MS = 250 +/** Kept under the runtime's 2s tui-idle fallback poll so a probe waiter is torn + * down before it can start a foreground-process poll of its own. */ +const AGENT_START_PROBE_TIMEOUT_MS = 250 +/** How long a pane may stay in its pre-dispatch state before we stop believing + * the prompt reached an agent. Covers the deliberate pre-Enter delay plus agent + * spin-up over SSH; past that, silence is not evidence of work. */ +const AGENT_START_DEADLINE_MS = 2 * 60 * 1000 +/** Total observation budget. Each tui-idle wait expires on the runtime's own + * 5-minute schedule and a live agent legitimately outlives many of them, so the + * bound is wall-clock and generous; it exists so a pane whose agent is never + * detected stops re-arming for the process lifetime with its run stuck at + * `dispatched`. Startup reconciliation re-attaches, so this is not a hard cap on + * how long a surviving run may be watched across restarts. */ +const OBSERVE_DEADLINE_MS = 6 * 60 * 60 * 1000 + +/** The runtime surface an authority uses to observe its own terminals. */ +export type AutomationRunTerminalHost = { + getTerminalHandleForPaneKey(paneKey: string): string | null + waitForTerminal( + handle: string, + options?: { condition?: 'tui-idle'; timeoutMs?: number; signal?: AbortSignal } + ): Promise<{ satisfied: boolean; blockedReason?: string }> + readTerminal(handle: string, opts?: { limit?: number }): Promise<{ tail: string[] }> +} + +function isTerminalWaitTimeout(error: unknown): boolean { + return error instanceof Error && error.message === 'timeout' +} + +async function sleep(ms: number, signal: AbortSignal): Promise { + await new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new Error('request_aborted')) + return + } + const onAbort = (): void => { + clearTimeout(timer) + reject(new Error('request_aborted')) + } + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve() + }, ms) + signal.addEventListener('abort', onAbort, { once: true }) + }) +} + +/** Whether tui-idle is satisfiable from evidence the pane holds RIGHT NOW. + * Why a real wait with a short timeout rather than a status read: the runtime + * satisfies tui-idle from a sticky agent status, an idle pane title, or a ready + * shell prompt, across two different code shapes (pty vs leaf). Asking it the + * question it already answers keeps every one of those in scope. */ +async function isTuiIdleSatisfiedNow( + runtime: AutomationRunTerminalHost, + handle: string, + signal: AbortSignal +): Promise { + try { + const wait = await runtime.waitForTerminal(handle, { + condition: 'tui-idle', + timeoutMs: AGENT_START_PROBE_TIMEOUT_MS, + signal + }) + // A blocked pane is not "already finished"; let the real wait report it. + return wait.satisfied + } catch (error) { + if (isTerminalWaitTimeout(error)) { + return false + } + throw error + } +} + +/** Polls until the pane stops satisfying tui-idle — the edge that proves this + * run's agent, not the previous one, owns the pane. */ +async function waitForAgentStart( + runtime: AutomationRunTerminalHost, + handle: string, + signal: AbortSignal, + deadlineAt: number +): Promise { + while (Date.now() < deadlineAt) { + await sleep(AGENT_START_POLL_INTERVAL_MS, signal) + if (!(await isTuiIdleSatisfiedNow(runtime, handle, signal))) { + return true + } + } + return false +} + +async function readTerminalSnapshot( + runtime: AutomationRunTerminalHost, + handle: string +): Promise { + const snapshotBuffer = createHeadlessAutomationOutputSnapshotBuffer() + try { + const read = await runtime.readTerminal(handle, { limit: TERMINAL_SNAPSHOT_LIMIT }) + snapshotBuffer.append(read.tail.join('\n')) + } catch { + // Why: the terminal can exit between the wait resolving and the tail read; + // a missing snapshot must not turn a satisfied wait into a failure. + } + return snapshotBuffer.snapshot() +} + +async function buildObservation( + runtime: AutomationRunTerminalHost, + handle: string, + wait: { satisfied: boolean; blockedReason?: string } +): Promise { + const outputSnapshot = await readTerminalSnapshot(runtime, handle) + if (wait.satisfied) { + return { status: 'completed', outputSnapshot, error: null } + } + return { + status: 'dispatch_failed', + outputSnapshot, + error: wait.blockedReason + ? `Automation agent is blocked: ${wait.blockedReason}.` + : 'Automation agent did not report completion.' + } +} + +/** Closes a run out without claiming a completion nobody observed. */ +async function buildUnobservedObservation( + runtime: AutomationRunTerminalHost, + handle: string, + error: string +): Promise { + return { + status: 'dispatch_failed', + outputSnapshot: await readTerminalSnapshot(runtime, handle), + error + } +} + +export function createRuntimeAutomationRunTerminalObserver( + runtime: AutomationRunTerminalHost +): AutomationRunTerminalObserver { + return { + resolveRunTerminal: (run) => + run.terminalPaneKey ? runtime.getTerminalHandleForPaneKey(run.terminalPaneKey) : null, + observeCompletion: async (handle, { signal }) => { + const startedAt = Date.now() + // Why: tui-idle is level-triggered, so a reused pane still idle from the + // PREVIOUS run satisfies it before this run's agent has typed a character. + // Evidence that predates dispatch proves nothing about this run, so require + // the pane to leave that state first — the busy edge the renderer's own + // dispatch observer requires on reuse (requireWorkingAfterStart). + if (await isTuiIdleSatisfiedNow(runtime, handle, signal)) { + const started = await waitForAgentStart( + runtime, + handle, + signal, + startedAt + AGENT_START_DEADLINE_MS + ) + if (!started) { + return await buildUnobservedObservation( + runtime, + handle, + 'Automation agent never started after the prompt was submitted.' + ) + } + } + const deadlineAt = startedAt + OBSERVE_DEADLINE_MS + for (;;) { + try { + const wait = await runtime.waitForTerminal(handle, { condition: 'tui-idle', signal }) + return await buildObservation(runtime, handle, wait) + } catch (error) { + // Why: tui-idle waits expire on their own schedule; an agent still + // working past that window is live, so re-arm rather than fail it. + if (signal.aborted || !isTerminalWaitTimeout(error)) { + throw error + } + if (Date.now() >= deadlineAt) { + return await buildUnobservedObservation( + runtime, + handle, + 'Orca stopped watching this run after 6h without a completion signal.' + ) + } + } + } + } + } +} diff --git a/src/main/automations/service-precheck.test.ts b/src/main/automations/service-precheck.test.ts index a9f7d336b78..c13359b546d 100644 --- a/src/main/automations/service-precheck.test.ts +++ b/src/main/automations/service-precheck.test.ts @@ -1,9 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import type { Repo } from '../../shared/repo-types' import { AutomationService } from './service' +import { runHeadlessAutomationDispatch } from './headless-dispatch-runner' +import { createAutomationRunWriter } from './automation-run-writer' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' const runAutomationPrecheckMock = vi.hoisted(() => vi.fn()) const testState = { dir: '' } @@ -25,6 +28,7 @@ vi.mock('./precheck-runner', () => ({ async function createStore() { vi.resetModules() + installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('../persistence') initDataPath() return new Store() @@ -39,6 +43,19 @@ const makeRepo = (overrides: Partial = {}): Repo => ({ ...overrides }) +/** Simulate registry drift after a record was stored; the create path derives contexts itself. */ +function mutateDataFile( + mutate: (state: { + automations: Record[] + automationRuns: Record[] + }) => void +): void { + const file = join(testState.dir, 'orca-data.json') + const state = JSON.parse(readFileSync(file, 'utf-8')) + mutate(state) + writeFileSync(file, JSON.stringify(state, null, 2), 'utf-8') +} + describe('AutomationService prechecks', () => { beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-automations-test-')) @@ -115,21 +132,26 @@ describe('AutomationService prechecks', () => { }, agentId: 'claude', projectId: 'r1', - runContext: { - kind: 'workspace-run', - projectId: setup.projectId, - hostId: setup.hostId, - projectHostSetupId: setup.id, - repoId: setup.repoId, - path: '/repo/old' - }, workspaceMode: 'new_per_run', timezone: 'UTC', rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', dtstart: new Date('2026-05-14T00:00:00Z').getTime() }) const run = store.createAutomationRun(automation, Date.now(), 'scheduled') - const service = new AutomationService(store, { tickMs: 60_000 }) + const staleRunContext = { + kind: 'workspace-run', + projectId: setup.projectId, + hostId: setup.hostId, + projectHostSetupId: setup.id, + repoId: setup.repoId, + path: '/repo/old' + } + mutateDataFile((state) => { + state.automations[0].runContext = staleRunContext + state.automationRuns[0].runContext = staleRunContext + }) + const reloaded = await createStore() + const service = new AutomationService(reloaded, { tickMs: 60_000 }) const result = await service.runPrecheck(automation.id, run.id) @@ -204,20 +226,16 @@ describe('AutomationService prechecks', () => { headlessDispatcher }) const run = store.createAutomationRun(automation, Date.now(), 'scheduled') - const requestHeadlessDispatch = ( - service as unknown as { - requestHeadlessDispatch: ( - automationArg: typeof automation, - runArg: typeof run, - targetArg: { ok: true; cwd: string; repo: Repo } - ) => Promise - } - ).requestHeadlessDispatch.bind(service) - await requestHeadlessDispatch(automation, run, { - ok: true, - cwd: '/repo', - repo: store.getRepo('r1')! + await runHeadlessAutomationDispatch({ + automation, + run, + target: { ok: true, cwd: '/repo', repo: store.getRepo('r1')! }, + dispatcher: headlessDispatcher, + runs: createAutomationRunWriter(store, null), + runPrecheck: () => service.runPrecheck(automation.id, run.id), + markDispatchResult: (result) => service.markDispatchResult(result), + watchRun: () => {} }) expect(headlessDispatcher).not.toHaveBeenCalled() diff --git a/src/main/automations/service.test.ts b/src/main/automations/service.test.ts index 0b546d5a46e..773a3f3d9ba 100644 --- a/src/main/automations/service.test.ts +++ b/src/main/automations/service.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import type { Repo } from '../../shared/repo-types' @@ -29,6 +29,14 @@ async function createStore() { return new Store() } +/** Simulate registry drift after a record was stored; the create path derives contexts itself. */ +function mutateDataFile(mutate: (state: { automations: Record[] }) => void): void { + const file = join(testState.dir, 'orca-data.json') + const state = JSON.parse(readFileSync(file, 'utf-8')) + mutate(state) + writeFileSync(file, JSON.stringify(state, null, 2), 'utf-8') +} + const makeRepo = (overrides: Partial = {}): Repo => ({ id: 'r1', path: '/repo', @@ -133,21 +141,24 @@ describe('AutomationService', () => { prompt: 'Check the repo', agentId: 'claude', projectId: 'r1', - runContext: { + workspaceMode: 'new_per_run', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-14T00:00:00Z').getTime() + }) + mutateDataFile((state) => { + state.automations[0].runContext = { kind: 'workspace-run', projectId: 'project-1', hostId: 'local', projectHostSetupId: 'missing-setup', repoId: 'r1', path: '/repo' - }, - workspaceMode: 'new_per_run', - timezone: 'UTC', - rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', - dtstart: new Date('2026-05-14T00:00:00Z').getTime() + } }) + const reloaded = await createStore() const send = vi.fn() - const service = new AutomationService(store, { tickMs: 60_000 }) + const service = new AutomationService(reloaded, { tickMs: 60_000 }) service.setWebContents({ isDestroyed: () => false, send @@ -171,21 +182,24 @@ describe('AutomationService', () => { prompt: 'Check the repo', agentId: 'claude', projectId: 'r1', - runContext: { + workspaceMode: 'new_per_run', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-14T00:00:00Z').getTime() + }) + mutateDataFile((state) => { + state.automations[0].runContext = { kind: 'workspace-run', projectId: setup.projectId, hostId: setup.hostId, projectHostSetupId: setup.id, repoId: setup.repoId, path: '/repo/old' - }, - workspaceMode: 'new_per_run', - timezone: 'UTC', - rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', - dtstart: new Date('2026-05-14T00:00:00Z').getTime() + } }) + const reloaded = await createStore() const send = vi.fn() - const service = new AutomationService(store, { tickMs: 60_000 }) + const service = new AutomationService(reloaded, { tickMs: 60_000 }) service.setWebContents({ isDestroyed: () => false, send diff --git a/src/main/automations/service.ts b/src/main/automations/service.ts index 91b70ff6088..9ed9564e5e5 100644 --- a/src/main/automations/service.ts +++ b/src/main/automations/service.ts @@ -15,10 +15,21 @@ import { resolveAutomationRunTarget, type AutomationRunTargetResult } from './ru import { collectAutomationRunUsage } from './run-usage-collection' import type { HeadlessAutomationDispatcher } from './headless-dispatch' import { clearAutomationDispatchTokens, createAutomationDispatchToken } from './dispatch-tokens' +import { runHeadlessAutomationDispatch } from './headless-dispatch-runner' import { - didAutomationPrecheckPass, - formatAutomationPrecheckFailure -} from '../../shared/automation-precheck' + AutomationRunCompletionWatcher, + type AutomationRunTerminalObserver +} from './run-completion-watcher' +import { createAutomationRunWriter, type AutomationRunWriter } from './automation-run-writer' +import { + describeScheduledRefusal, + recordRefusedAutomationRun, + NO_DISPATCH_HOST +} from './dispatch-refusal' +import type { + AutomationsChangedPayload, + PublishAutomationsChanged +} from '../../shared/runtime-client-events' const DEFAULT_TICK_MS = 60 * 1000 @@ -33,6 +44,13 @@ export class AutomationService { private readonly codexUsage: CodexUsageStore | null private readonly allowRemoteHostScheduling: boolean private readonly headlessDispatcher: HeadlessAutomationDispatcher | null + private readonly publish: PublishAutomationsChanged | null + private readonly runs: AutomationRunWriter + private readonly completionWatcher: AutomationRunCompletionWatcher | null + /** Installed by desktop IPC registration, where external probes live; null on + * runtime servers. Orca's own automation traffic parks queued external + * probes behind this lease, whichever transport carried it. */ + externalProbePriority: ((run: () => T) => T) | null = null constructor( store: Store, @@ -42,6 +60,8 @@ export class AutomationService { codexUsage?: CodexUsageStore allowRemoteHostScheduling?: boolean headlessDispatcher?: HeadlessAutomationDispatcher + terminalObserver?: AutomationRunTerminalObserver + onAutomationsChanged?: PublishAutomationsChanged } = {} ) { this.store = store @@ -50,6 +70,22 @@ export class AutomationService { this.codexUsage = opts.codexUsage ?? null this.allowRemoteHostScheduling = opts.allowRemoteHostScheduling ?? false this.headlessDispatcher = opts.headlessDispatcher ?? null + this.publish = opts.onAutomationsChanged ?? null + this.runs = createAutomationRunWriter(store, this.publish) + this.completionWatcher = opts.terminalObserver + ? new AutomationRunCompletionWatcher({ + observer: opts.terminalObserver, + readRun: (automationId, runId) => + this.store.listAutomationRuns(automationId).find((entry) => entry.id === runId) ?? null, + markDispatchResult: (result) => this.markDispatchResult(result) + }) + : null + } + + /** CRUD callers publish through the service so every authority write lands on + * the same local + runtime client-event pair. */ + publishAutomationsChanged(payload: AutomationsChangedPayload = {}): void { + this.publish?.(payload) } setWebContents(webContents: WebContents | null): void { @@ -59,6 +95,9 @@ export class AutomationService { setRendererReady(): void { this.rendererReady = true + // Why: the renderer publishes the desktop window graph, so only after it + // attaches can an unresolvable pane mean a lost terminal rather than "not yet". + this.completionWatcher?.markTerminalSurfaceReady() void this.evaluateDueRuns() } @@ -69,14 +108,19 @@ export class AutomationService { this.timer = setInterval(() => { void this.evaluateDueRuns() }, this.tickMs) + this.completionWatcher?.reconcileRetainedRuns(this.store.listAutomationRuns()) // Why: headless serve never gets a renderer-ready IPC, but due runs still // need the same startup catch-up pass desktop gets after renderer attach. if (this.rendererReady || this.headlessDispatcher) { + // Serve adopts its daemon PTYs and publishes its graph before start(), so + // its terminal surface is already as answerable as it will get. + this.completionWatcher?.markTerminalSurfaceReady() void this.evaluateDueRuns() } } stop(): void { + this.completionWatcher?.dispose() if (!this.timer) { return } @@ -89,8 +133,21 @@ export class AutomationService { if (!automation) { throw new Error('Automation not found.') } - const run = this.store.createAutomationRun(automation, Date.now(), 'manual') - return await this.requestDispatch(automation, run) + const run = this.runs.createRun(automation, Date.now(), 'manual') + return await this.requestDispatch(automation, run, this.resolveTarget(automation)) + } + + /** The run-history row doc:94 pairs with the typed refusal an execute fence throws. */ + recordRefusedRun(automationId: string): void { + const automation = this.store.listAutomations().find((entry) => entry.id === automationId) + if (automation) { + recordRefusedAutomationRun({ + store: this.store, + runs: this.runs, + automation, + allowRemoteHostScheduling: this.allowRemoteHostScheduling + }) + } } async runPrecheck(automationId: string, runId: string): Promise { @@ -105,9 +162,7 @@ export class AutomationService { if (run.trigger !== 'scheduled' || !automation.precheck) { return null } - const target = resolveAutomationRunTarget(this.store, automation, { - allowRemoteHostScheduling: this.allowRemoteHostScheduling - }) + const target = this.resolveTarget(automation) if (!target.ok) { return { command: automation.precheck.command, @@ -133,11 +188,15 @@ export class AutomationService { } async markDispatchResult(result: AutomationDispatchResult): Promise { - const run = this.store.updateAutomationRun(result) + const run = this.runs.updateRun(result) clearAutomationDispatchTokens(run.automationId, run.id) if (!isFinalAutomationRunStatus(run.status)) { + if (run.status === 'dispatched') { + this.completionWatcher?.watch(run) + } return run } + this.completionWatcher?.forget(run.id) // Why: the renderer's mark-completed effect can re-fire for the same run // before refresh() flips its status snapshot off 'dispatched'. Re-running // collectRunUsage advances the attribution window and can rewrite an @@ -156,7 +215,7 @@ export class AutomationService { if (!this.store.listAutomationRuns(run.automationId).some((entry) => entry.id === run.id)) { return run } - return this.store.updateAutomationRun({ + return this.runs.updateRun({ runId: run.id, status: run.status, workspaceId: run.workspaceId, @@ -190,11 +249,11 @@ export class AutomationService { this.store.advanceAutomationNextRun(automation.id, now) return } - const run = this.store.createAutomationRun(automation, scheduledFor) const graceMs = automation.missedRunGraceMinutes * 60 * 1000 if (now - scheduledFor > graceMs) { - this.store.updateAutomationRun({ - runId: run.id, + const missed = this.runs.createRun(automation, scheduledFor) + this.runs.updateRun({ + runId: missed.id, status: 'skipped_missed', workspaceId: automation.workspaceId, error: 'Orca was unavailable during the missed-run grace window.' @@ -203,38 +262,70 @@ export class AutomationService { return } - await this.requestDispatch(automation, run) + // Resolved before the run exists: a refusal repeats every occurrence, and a + // */5 automation would otherwise write ~288 identical rows a day — past + // retention, which would evict the automation's real history. + const target = this.resolveTarget(automation) + const refusal = describeScheduledRefusal({ target, canDispatch: this.canDispatch() }) + if (refusal && this.runs.repeatSkip(automation.id, refusal, scheduledFor)) { + this.store.advanceAutomationNextRun(automation.id, now) + return + } + + await this.requestDispatch(automation, this.runs.createRun(automation, scheduledFor), target) this.store.advanceAutomationNextRun(automation.id, now) } + private resolveTarget(automation: Automation): AutomationRunTargetResult { + return resolveAutomationRunTarget(this.store, automation, { + allowRemoteHostScheduling: this.allowRemoteHostScheduling + }) + } + + private canDispatchToRenderer(): boolean { + const webContents = this.webContents + return Boolean(webContents && !webContents.isDestroyed() && this.rendererReady) + } + + /** Headless serve counts: it launches runs with no window at all. */ + private canDispatch(): boolean { + return this.canDispatchToRenderer() || Boolean(this.headlessDispatcher) + } + private async requestDispatch( automation: Automation, - run: AutomationRun + run: AutomationRun, + target: AutomationRunTargetResult ): Promise { - const target = resolveAutomationRunTarget(this.store, automation, { - allowRemoteHostScheduling: this.allowRemoteHostScheduling - }) if (!target.ok) { - return this.store.updateAutomationRun({ + return this.runs.updateRun({ runId: run.id, status: 'skipped_unavailable', workspaceId: automation.workspaceId, error: target.error }) } - const webContents = this.webContents - if (!webContents || webContents.isDestroyed() || !this.rendererReady) { + if (!this.canDispatchToRenderer()) { if (this.headlessDispatcher) { - return await this.requestHeadlessDispatch(automation, run, target) + return await runHeadlessAutomationDispatch({ + automation, + run, + target, + dispatcher: this.headlessDispatcher, + runs: this.runs, + runPrecheck: () => this.runPrecheck(automation.id, run.id), + markDispatchResult: (result) => this.markDispatchResult(result), + watchRun: (dispatched) => this.completionWatcher?.watch(dispatched) + }) } - return this.store.updateAutomationRun({ + return this.runs.updateRun({ runId: run.id, status: 'skipped_unavailable', workspaceId: automation.workspaceId, - error: 'No Orca window was available to launch the automation.' + error: NO_DISPATCH_HOST }) } - const updated = this.store.updateAutomationRun({ + const updated = this.runs.updateRun({ runId: run.id, status: 'dispatching', workspaceId: automation.workspaceId, @@ -245,72 +336,7 @@ export class AutomationService { run: updated, dispatchToken: createAutomationDispatchToken(automation.id, updated.id) } - webContents.send('automations:dispatchRequested', payload) + this.webContents?.send('automations:dispatchRequested', payload) return updated } - - private async requestHeadlessDispatch( - automation: Automation, - run: AutomationRun, - target: Extract - ): Promise { - const precheckResult = - run.trigger === 'scheduled' && automation.precheck - ? await this.runPrecheck(automation.id, run.id) - : null - if (precheckResult && !didAutomationPrecheckPass(precheckResult)) { - return this.store.updateAutomationRun({ - runId: run.id, - status: 'skipped_precheck', - workspaceId: automation.workspaceId, - precheckResult, - error: formatAutomationPrecheckFailure(precheckResult) - }) - } - try { - const launch = await this.headlessDispatcher!({ automation, run, target }) - const launchRunTarget = { - workspaceId: launch.workspaceId, - workspaceDisplayName: launch.workspaceDisplayName ?? null, - terminalSessionId: launch.terminalSessionId, - terminalPaneKey: launch.terminalPaneKey ?? null, - terminalPtyId: launch.terminalPtyId ?? null - } - const updated = this.store.updateAutomationRun({ - runId: run.id, - status: 'dispatched', - ...launchRunTarget, - error: null - }) - if (launch.completion) { - void launch.completion - .then((completion) => - this.markDispatchResult({ - runId: run.id, - status: completion.status, - ...launchRunTarget, - precheckResult, - outputSnapshot: completion.outputSnapshot ?? null, - error: completion.error ?? null - }) - ) - .catch((error) => - this.markDispatchResult({ - runId: run.id, - status: 'dispatch_failed', - ...launchRunTarget, - error: error instanceof Error ? error.message : String(error) - }) - ) - } - return updated - } catch (error) { - return this.store.updateAutomationRun({ - runId: run.id, - status: 'dispatch_failed', - workspaceId: automation.workspaceId, - error: error instanceof Error ? error.message : String(error) - }) - } - } } diff --git a/src/main/index.ts b/src/main/index.ts index f3d1230d4b6..ab0782c9a25 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -319,6 +319,7 @@ import { setUnreadDockBadgeCount } from './dock/unread-badge' import { AutomationService } from './automations/service' import { createHeadlessAutomationOutputSnapshotBuffer } from './automations/headless-dispatch' import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headless-workspace-create' +import { createRuntimeAutomationRunTerminalObserver } from './automations/runtime-terminal-run-observer' import { AgentAwakeService } from './agent-awake-service' import { normalizeComputerAwakeMode } from '../shared/computer-awake-mode' import { registerSystemResumeBroadcast } from './system-resume-broadcast' @@ -2324,7 +2325,10 @@ void app.whenReady().then(async () => { // Why this early: the first window stamps the hosting id into its renderer's argv, so the durable // read has to have happened by then or the renderer and the browser-host lease disagree. initializeBrowserClientHostId(activeOrcaProfile.profileDirectory) - store = new Store({ dataFile: activeOrcaProfile.dataFile }) + store = new Store({ + dataFile: activeOrcaProfile.dataFile, + storageAuthority: isServeMode ? 'runtime' : 'desktop' + }) // Why here and not at install time: the report remembers what it last said, and that // state lives beside the profile data file, which does not exist until now. reportSecretProtectionGap({ @@ -2779,6 +2783,8 @@ void app.whenReady().then(async () => { automations = new AutomationService(store, { claudeUsage, codexUsage, + terminalObserver: createRuntimeAutomationRunTerminalObserver(runtimeService), + onAutomationsChanged: (payload) => runtimeService.notifyAutomationsChanged(payload), // Why: desktop clients mirror remote-host automations, but only a server process should execute remote_host_service-owned schedules. allowRemoteHostScheduling: isServeMode, headlessDispatcher: isServeMode diff --git a/src/main/ipc/automations-external-scope.test.ts b/src/main/ipc/automations-external-scope.test.ts new file mode 100644 index 00000000000..7613fdfbb1f --- /dev/null +++ b/src/main/ipc/automations-external-scope.test.ts @@ -0,0 +1,230 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { isExternalAutomationProbeCancelled } from '../automations/external-automation-probe-scheduler' +import { EXTERNAL_AUTOMATION_SCOPE_CODES } from '../../shared/external-automation-scope' +import { AUTOMATION_OWNER_CONFLICT_CODES } from '../../shared/automation-owner-conflict' +import type { AutomationOwnerRef } from '../../shared/automation-owner-ref' +import type { SshTarget } from '../../shared/ssh-types' +import type { AutomationService } from '../automations/service' +import type { Store } from '../persistence' + +type IpcHandler = (event: unknown, ...args: unknown[]) => unknown + +const ipcHandlers = new Map() + +vi.mock('electron', () => ({ + ipcMain: { + handle: (channel: string, handler: IpcHandler) => { + ipcHandlers.set(channel, handler) + } + } +})) + +// The relay is the only seam a scoped SSH probe crosses, so it stands in for the host. +const relay = { + request: vi.fn<(method: string, params: unknown) => Promise>(), + isDisposed: () => false +} +vi.mock('../ssh/ssh-target-registry', () => ({ + getActiveMultiplexer: () => relay +})) + +function sshTarget(overrides: Partial = {}): SshTarget { + return { + id: 't1', + label: 'build-box', + host: 'build.example', + user: 'orca', + generation: 3, + ...overrides + } as SshTarget +} + +function desktopSsh(targetId = 't1', targetGeneration = 3): AutomationOwnerRef { + return { + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId, targetGeneration } + } +} + +const state: { + targets: SshTarget[] + /** The lease registration installs on the service, for the runtime methods to take. */ + service: { externalProbePriority: ((run: () => T) => T) | null } +} = { + targets: [sshTarget()], + service: { externalProbePriority: null } +} + +/** Async like the real bridge: Electron turns a handler's sync throw into a rejection. */ +async function invoke(channel: string, payload?: unknown): Promise { + const handler = ipcHandlers.get(channel) + if (!handler) { + throw new Error(`no handler registered for ${channel}`) + } + return await handler(null, payload) +} + +beforeEach(async () => { + ipcHandlers.clear() + relay.request.mockReset() + relay.request.mockResolvedValue({ jobs: [], hermesAvailable: true, error: null }) + state.targets = [sshTarget()] + state.service = { externalProbePriority: null } + const { registerAutomationHandlers } = await import('./automations') + registerAutomationHandlers( + { + getSshTargets: () => state.targets, + assertAutomationOwnerFence: () => undefined + } as unknown as Store, + state.service as unknown as AutomationService + ) +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('scoped external-manager IPC', () => { + it('derives the target from the captured owner rather than the payload', async () => { + const entry = (await invoke('automations:listExternalManagerForOwner', { + owner: desktopSsh(), + provider: 'hermes' + })) as { manager: { id: string; target: unknown } | null; error: string | null } + + expect(relay.request).toHaveBeenCalledWith('externalAutomations.list', { provider: 'hermes' }) + expect(entry.manager?.id).toBe('hermes:ssh:t1') + expect(entry.manager?.target).toEqual({ type: 'ssh', connectionId: 't1' }) + expect(entry.error).toBeNull() + }) + + it('refuses a request with no captured owner without probing', async () => { + await expect( + invoke('automations:listExternalManagerForOwner', { provider: 'hermes' }) + ).rejects.toThrow(/automation owner is required/i) + await expect(invoke('automations:runExternalActionForOwner', {})).rejects.toThrow( + /automation owner is required/i + ) + expect(relay.request).not.toHaveBeenCalled() + }) + + it('refuses a runtime authority, so runtime hosts are never tunnelled', async () => { + await expect( + invoke('automations:listExternalManagerForOwner', { + owner: { + authority: { kind: 'runtime', environmentId: 'env-1', pairingRevision: 2 }, + selector: { kind: 'self' } + }, + provider: 'hermes' + }) + ).rejects.toThrow(EXTERNAL_AUTOMATION_SCOPE_CODES.authorityNotSupported) + expect(relay.request).not.toHaveBeenCalled() + }) + + it('refuses a runtime-owned SSH target as hidden rather than as a probe failure', async () => { + state.targets = [sshTarget({ owner: { type: 'on-demand-runtime', runtimeId: 'env-1' } })] + await expect( + invoke('automations:listExternalManagerForOwner', { owner: desktopSsh(), provider: 'hermes' }) + ).rejects.toThrow(EXTERNAL_AUTOMATION_SCOPE_CODES.targetHidden) + expect(relay.request).not.toHaveBeenCalled() + }) + + it('refuses a stale host incarnation with the shared conflict vocabulary', async () => { + state.targets = [sshTarget({ generation: 4 })] + await expect( + invoke('automations:listExternalManagerForOwner', { + owner: desktopSsh('t1', 3), + provider: 'hermes' + }) + ).rejects.toThrow(AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged) + expect(relay.request).not.toHaveBeenCalled() + }) + + it('keeps one cache across requests, so a repeat read costs no second probe', async () => { + const request = { owner: desktopSsh(), provider: 'hermes' } + await invoke('automations:listExternalManagerForOwner', request) + await invoke('automations:listExternalManagerForOwner', request) + expect(relay.request).toHaveBeenCalledTimes(1) + + await invoke('automations:listExternalManagerForOwner', { ...request, refresh: true }) + expect(relay.request).toHaveBeenCalledTimes(2) + }) + + it('invalidates that host after a mutation through the scoped path', async () => { + const request = { owner: desktopSsh(), provider: 'hermes' } + await invoke('automations:listExternalManagerForOwner', request) + await invoke('automations:createExternalForOwner', { + ...request, + name: 'Nightly', + prompt: 'sweep', + schedule: '0 9 * * *', + workdir: null + }) + await invoke('automations:listExternalManagerForOwner', request) + + // create + the re-probe after invalidation, on top of the first list. + expect(relay.request).toHaveBeenCalledTimes(3) + }) +}) + +describe('probe scope retention', () => { + it('cancels an in-flight probe once its host leaves the retained set', async () => { + const probe: { release: () => void } = { release: () => undefined } + relay.request.mockImplementation( + () => + new Promise((resolve) => { + probe.release = () => resolve({ jobs: [], hermesAvailable: true, error: null }) + }) + ) + const pending = invoke('automations:listExternalManagerForOwner', { + owner: desktopSsh(), + provider: 'hermes' + }) as Promise + const settled = pending.catch((error: unknown) => error) + await Promise.resolve() + + await invoke('automations:retainExternalScopes', { owners: [] }) + + const error = await settled + expect(isExternalAutomationProbeCancelled(error)).toBe(true) + probe.release() + }) + + it('keeps probing a host that is still retained', async () => { + await invoke('automations:retainExternalScopes', { owners: [desktopSsh()] }) + const entry = (await invoke('automations:listExternalManagerForOwner', { + owner: desktopSsh(), + provider: 'hermes' + })) as { manager: unknown } + expect(entry.manager).not.toBeNull() + }) +}) + +describe('Orca automation traffic priority', () => { + it('parks queued probes while Orca automation work holds the installed lease', async () => { + // Orca CRUD and dispatch arrive through the runtime methods, which take the + // lease through the hook this registration installed on the service. + const lease = state.service.externalProbePriority + expect(lease).not.toBeNull() + let finish: () => void = () => undefined + const dispatch = lease!( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + + const probe = invoke('automations:listExternalManagerForOwner', { + owner: desktopSsh(), + provider: 'hermes' + }) as Promise + await Promise.resolve() + // Why: the probe is queued, not started — this is the only thing keeping + // external discovery from competing with the work the user is waiting on. + expect(relay.request).not.toHaveBeenCalled() + + finish() + await dispatch + await probe + expect(relay.request).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/ipc/automations.ts b/src/main/ipc/automations.ts index 7a523addec8..af3614d0e57 100644 --- a/src/main/ipc/automations.ts +++ b/src/main/ipc/automations.ts @@ -2,71 +2,118 @@ import { ipcMain } from 'electron' import type { Store } from '../persistence' import type { AutomationService } from '../automations/service' import type { - Automation, - AutomationCreateInput, AutomationDispatchResult, AutomationPrecheckResult, - ExternalAutomationCreateInput, - ExternalAutomationActionInput, - ExternalAutomationManager, - ExternalAutomationRunsInput, ExternalAutomationRunsPage, - ExternalAutomationUpdateInput, - AutomationRun, - AutomationUpdateInput + AutomationRun } from '../../shared/automations-types' +import { createScopedExternalAutomations } from '../automations/external-manager' import { - createExternalAutomation, - listExternalAutomationManagers, - listExternalAutomationRuns, - runExternalAutomationAction, - updateExternalAutomation -} from '../automations/external-manager' + ExternalAutomationManagerCache, + type ExternalAutomationManagerCacheEntry +} from '../automations/external-automation-manager-cache' +import { ExternalAutomationProbeScheduler } from '../automations/external-automation-probe-scheduler' +import { ownerKey } from '../../shared/automation-owner-key' +import type { AutomationOwnerRef } from '../../shared/automation-owner-ref' +import type { + ScopedExternalManagerActionRequest, + ScopedExternalManagerCreateRequest, + ScopedExternalManagerListRequest, + ScopedExternalManagerRunsRequest, + ScopedExternalManagerUpdateRequest +} from '../../shared/external-automation-scope' + +/** + * Fail closed: an external request with no captured owner is refused rather than + * resolved against whichever host happens to be active. Malformed input keeps + * the engine's plain-Error convention; it is not an ownership *conflict*. + */ +function requireCapturedOwner( + request: T | null | undefined +): T { + const owner = request?.owner + if (!request || !owner || !owner.authority || !owner.selector) { + throw new Error('An automation owner is required for external automation requests.') + } + return request +} + +/** + * Holds the probe pool's priority lease for the duration of Orca's own automation + * work. Without this, a queued external probe competes with the list and mutation + * traffic the user is actually waiting on. + */ +function underOrcaPriority(scheduler: ExternalAutomationProbeScheduler, run: () => T): T { + const release = scheduler.beginPriorityWork() + let pending = false + try { + const result = run() + if (result instanceof Promise) { + pending = true + return result.finally(release) as T + } + return result + } finally { + if (!pending) { + release() + } + } +} export function registerAutomationHandlers(store: Store, service: AutomationService): void { - ipcMain.handle('automations:list', (): Automation[] => store.listAutomations()) - ipcMain.handle( - 'automations:listRuns', - (_event, args?: { automationId?: string }): AutomationRun[] => - store.listAutomationRuns(args?.automationId) - ) - ipcMain.handle('automations:listExternalManagers', (): Promise => { - return listExternalAutomationManagers(store) + // One long-lived pair per registration: the cache TTL and the probe pool's + // concurrency ceiling only mean anything if they outlive a single request. + const probeScheduler = new ExternalAutomationProbeScheduler() + const managerCache = new ExternalAutomationManagerCache() + const scopedExternal = createScopedExternalAutomations({ + // Hidden runtime-owned targets are passed through on purpose: the guard + // rejects them itself, so pre-filtering here would leak a different error. + registry: { getSshTargets: () => store.getSshTargets() }, + scheduler: probeScheduler, + cache: managerCache }) + // Why: Orca automation CRUD now arrives over the local runtime RPC surface, + // so the runtime methods take the lease through this hook instead of an arm here. + service.externalProbePriority = (run) => underOrcaPriority(probeScheduler, run) + // Scoped external-manager surface: one captured desktop owner in, one host's + // managers out. The target and manager ID are derived inside the guard. ipcMain.handle( - 'automations:listExternalRuns', - (_event, input: ExternalAutomationRunsInput): Promise => { - return listExternalAutomationRuns(input) + 'automations:listExternalManagerForOwner', + ( + _event, + request: ScopedExternalManagerListRequest + ): Promise => + scopedExternal.listManager(requireCapturedOwner(request)) + ) + ipcMain.handle( + 'automations:listExternalRunsForOwner', + (_event, request: ScopedExternalManagerRunsRequest): Promise => + scopedExternal.listRuns(requireCapturedOwner(request)) + ) + ipcMain.handle( + 'automations:createExternalForOwner', + (_event, request: ScopedExternalManagerCreateRequest): Promise => + scopedExternal.create(requireCapturedOwner(request)) + ) + ipcMain.handle( + 'automations:updateExternalForOwner', + (_event, request: ScopedExternalManagerUpdateRequest): Promise => + scopedExternal.update(requireCapturedOwner(request)) + ) + ipcMain.handle( + 'automations:runExternalActionForOwner', + (_event, request: ScopedExternalManagerActionRequest): Promise => + scopedExternal.runAction(requireCapturedOwner(request)) + ) + // Why: probes for hosts the user is no longer viewing are cancelled, not left + // to finish. An empty list retains nothing, which is the correct fail-closed + // state for a renderer with no desktop scope selected. + ipcMain.handle( + 'automations:retainExternalScopes', + (_event, request?: { owners?: readonly AutomationOwnerRef[] } | null): void => { + probeScheduler.retainScopes((request?.owners ?? []).map(ownerKey)) } ) - ipcMain.handle('automations:createExternal', (_event, input: ExternalAutomationCreateInput) => { - return createExternalAutomation(input) - }) - ipcMain.handle('automations:updateExternal', (_event, input: ExternalAutomationUpdateInput) => { - return updateExternalAutomation(input) - }) - ipcMain.handle( - 'automations:runExternalAction', - (_event, input: ExternalAutomationActionInput) => { - return runExternalAutomationAction(input) - } - ) - ipcMain.handle( - 'automations:create', - (_event, input: AutomationCreateInput): Automation => store.createAutomation(input) - ) - ipcMain.handle( - 'automations:update', - (_event, args: { id: string; updates: AutomationUpdateInput }): Automation => - store.updateAutomation(args.id, args.updates) - ) - ipcMain.handle('automations:delete', (_event, args: { id: string }): void => { - store.deleteAutomation(args.id) - }) - ipcMain.handle( - 'automations:runNow', - (_event, args: { id: string }): Promise => service.runNow(args.id) - ) ipcMain.handle( 'automations:runPrecheck', ( diff --git a/src/main/ipc/ssh-target-crud-handlers.ts b/src/main/ipc/ssh-target-crud-handlers.ts index 03c4e457394..5d9a416039b 100644 --- a/src/main/ipc/ssh-target-crud-handlers.ts +++ b/src/main/ipc/ssh-target-crud-handlers.ts @@ -1,5 +1,10 @@ import { ipcMain } from 'electron' -import type { SshConfigHostListArgs, SshRepoReadoption, SshTarget } from '../../shared/ssh-types' +import type { + SshConfigHostListArgs, + SshRepoReadoption, + SshTargetCreateInput, + SshTargetUpdateInput +} from '../../shared/ssh-types' import { listUserSshConfigHostSummaries, resolveUserSshConfigHost @@ -29,6 +34,11 @@ function takeRepoReadoptions(): SshRepoReadoption[] { return repoReadoptions } +function omitRendererSshTargetGeneration(value: T): Omit { + const { generation: _generation, ...rest } = value as T & { generation?: unknown } + return rest +} + export function registerSshTargetCrudHandlers(): void { ipcMain.handle('ssh:listTargets', () => { return getSshTargetRegistryStore()!.listTargets() @@ -38,8 +48,10 @@ export function registerSshTargetCrudHandlers(): void { return getSshTargetRegistryStore()!.listRemovedTargetLabels() }) - ipcMain.handle('ssh:addTarget', (_event, args: { target: Omit }) => { - const target = getSshTargetRegistryStore()!.addTarget(args.target) + ipcMain.handle('ssh:addTarget', (_event, args: { target: SshTargetCreateInput }) => { + const target = getSshTargetRegistryStore()!.addTarget( + omitRendererSshTargetGeneration(args.target) + ) // Why: re-adding a removed host can re-adopt orphaned workspaces; refresh the renderer's repo list so they move back onto the live host. const repoReadoptions = takeRepoReadoptions() return { target, repoReadoptions } @@ -47,8 +59,11 @@ export function registerSshTargetCrudHandlers(): void { ipcMain.handle( 'ssh:updateTarget', - (_event, args: { id: string; updates: Partial> }) => { - return getSshTargetRegistryStore()!.updateTarget(args.id, args.updates) + (_event, args: { id: string; updates: SshTargetUpdateInput }) => { + return getSshTargetRegistryStore()!.updateTarget( + args.id, + omitRendererSshTargetGeneration(args.updates) + ) } ) diff --git a/src/main/ipc/ssh-target-registry.test.ts b/src/main/ipc/ssh-target-registry.test.ts index a808c29e6b7..17f82683832 100644 --- a/src/main/ipc/ssh-target-registry.test.ts +++ b/src/main/ipc/ssh-target-registry.test.ts @@ -82,6 +82,35 @@ describe('SSH IPC handlers', () => { expect(result).toEqual({ target: withId, repoReadoptions: [] }) }) + it('ssh:addTarget strips a renderer-supplied registration generation', async () => { + const target = { + label: 'New Server', + host: 'new.example.com', + port: 22, + username: 'deploy', + generation: 999 + } + mockSshStore.addTarget.mockReturnValue({ ...target, id: 'ssh-new', generation: 7 }) + + await handlers.get('ssh:addTarget')!(null, { target }) + + expect(mockSshStore.addTarget).toHaveBeenCalledWith({ + label: 'New Server', + host: 'new.example.com', + port: 22, + username: 'deploy' + }) + }) + + it('ssh:updateTarget strips a renderer-supplied registration generation', async () => { + await handlers.get('ssh:updateTarget')!(null, { + id: 'ssh-1', + updates: { label: 'Renamed', generation: 999 } + }) + + expect(mockSshStore.updateTarget).toHaveBeenCalledWith('ssh-1', { label: 'Renamed' }) + }) + it('ssh:addTarget returns exact re-adoption evidence and refreshes repos', async () => { const target = { id: 'ssh-new', diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 137031d270c..6afdcdb50e3 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -132,7 +132,9 @@ async function startOrcadRuntime( // Why a real Store: without one every persistence-backed RPC throws `runtime_unavailable` // and the read paths that use `this.store?.x ?? []` quietly answer "empty" instead — // a server that pairs and lists nothing looks healthy and is not. - const store = new Store({ dataFile: profile.dataFile }) + // Why: orcad IS the runtime authority — loading as 'desktop' would classify its + // own runtime-scheduled automations as ambiguous mirrors and orphan them. + const store = new Store({ dataFile: profile.dataFile, storageAuthority: 'runtime' }) // Why: every SSH connect consults this sidecar. Left unbound it reports nothing trusted, // which is safe but silently discards accept records on every launch. initSshHostKeyStoreFile(profile.dataFile) diff --git a/src/main/persistence-automations.test.ts b/src/main/persistence-automations.test.ts index 9ae65283cb3..605b7c35296 100644 --- a/src/main/persistence-automations.test.ts +++ b/src/main/persistence-automations.test.ts @@ -253,6 +253,72 @@ describe('Store', () => { }) }) + it('stores its own contexts over a client-perspective create runContext', async () => { + const store = await createStore() + store.addRepo(makeRepo({ upstream: { owner: 'stablyai', repo: 'orca' } })) + + const automation = store.createAutomation({ + name: 'Nightly', + prompt: 'Run checks', + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'new_per_run', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-13T00:00:00Z').getTime(), + // A paired client names this host 'runtime:' — an id the client assigned, + // which this store cannot interpret and must not persist. + runContext: { + kind: 'workspace-run', + projectId: 'github:stablyai/orca', + hostId: toRuntimeExecutionHostId('client-env'), + projectHostSetupId: 'client-setup', + repoId: 'client-repo', + path: '/client/checkout' + } + }) + + expect(automation.runContext).toMatchObject({ hostId: 'local', repoId: 'r1' }) + expect(store.listAutomationsForScope().items[0]?.selector).toEqual({ kind: 'self' }) + }) + + it('re-derives a stored client-perspective context on an explicit move, not on a toggle', async () => { + const store = await createStore() + store.addRepo(makeRepo({ upstream: { owner: 'stablyai', repo: 'orca' } })) + const automation = store.createAutomation({ + name: 'Nightly', + prompt: 'Run checks', + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'new_per_run', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-13T00:00:00Z').getTime() + }) + const persisted = readDataFile() as { automations: Record[] } + // A record a pre-fix host stored from a paired client's create input. + persisted.automations[0].runContext = { + kind: 'workspace-run', + projectId: 'github:stablyai/orca', + hostId: toRuntimeExecutionHostId('client-env'), + projectHostSetupId: 'client-setup', + repoId: 'client-repo', + path: '/client/checkout' + } + writeDataFile(persisted) + + const reloaded = await createStore() + expect(reloaded.listAutomationsForScope().items[0]?.selector.kind).toBe('orphan') + + // A toggle is not a move: the record must not silently re-adopt. + reloaded.updateAutomation(automation.id, { enabled: false }) + expect(reloaded.listAutomationsForScope().items[0]?.selector.kind).toBe('orphan') + + const healed = reloaded.updateAutomation(automation.id, { projectId: 'r1' }) + expect(healed.runContext).toMatchObject({ hostId: 'local', repoId: 'r1' }) + expect(reloaded.listAutomationsForScope().items[0]?.selector).toEqual({ kind: 'self' }) + }) + it('marks runtime-owned automations as remote-host scheduled', async () => { const store = await createStore() store.addRepo( diff --git a/src/main/persistence-ssh-readoption-automation-migration.test.ts b/src/main/persistence-ssh-readoption-automation-migration.test.ts new file mode 100644 index 00000000000..172c854cfd0 --- /dev/null +++ b/src/main/persistence-ssh-readoption-automation-migration.test.ts @@ -0,0 +1,373 @@ +/** + * SSH re-adoption has to repair the automation with the workspace. + * + * `reassignSshTargetId` used to re-point only repos, worktrees, sessions and + * setups, so a re-added host left every stored automation orphaned on the dead + * target id and the persisted Automations host filter naming a host that no + * longer existed. These tests drive the real Store through remove/re-add. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { Automation, AutomationRun } from '../shared/automations-types' +import type { FolderWorkspace } from '../shared/folder-workspace-types' +import type { ProjectGroup } from '../shared/project-group-types' +import type { Repo } from '../shared/repo-types' +import type { RemovedSshTargetTombstone } from '../shared/ssh-types' +import type { SshConnectionStore } from './ssh/ssh-connection-store' +import { getDefaultPersistedState } from '../shared/constants' +import { toSshExecutionHostId } from '../shared/execution-host' +import { hostStableKey } from '../shared/automation-owner-key' +import { folderWorkspaceKey } from '../shared/workspace-scope' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' + +const testState = { dir: '' } + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) + +const NOW = 1_700_000_000_000 +const OLD_ID = 'ssh-1738000000000-a9f3x' +const IDENTITY = { host: 'dev.example.com', port: 22, username: 'tim' } + +function desktopSshKey(targetId: string): string { + return hostStableKey({ authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId } }) +} + +function makeAutomation(overrides: Partial = {}): Automation { + return { + id: 'auto-1', + name: 'Nightly', + prompt: 'go', + precheck: null, + agentId: 'codex', + projectId: 'repo-1', + executionTargetType: 'ssh', + executionTargetId: OLD_ID, + schedulerOwner: 'local_host_service', + workspaceMode: 'new_per_run', + workspaceId: null, + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: NOW, + enabled: true, + nextRunAt: NOW, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: NOW, + updatedAt: NOW, + ...overrides + } +} + +function makeRun(): AutomationRun { + return { + id: 'run-1', + automationId: 'auto-1', + runContext: { + kind: 'workspace-run', + projectId: 'project-1', + hostId: toSshExecutionHostId(OLD_ID), + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + path: '/srv/repo' + }, + title: 'Nightly #1', + scheduledFor: NOW, + status: 'completed', + trigger: 'scheduled', + workspaceId: null, + sessionKind: 'terminal', + chatSessionId: null, + terminalSessionId: null, + terminalPaneKey: null, + terminalPtyId: null, + outputSnapshot: null, + precheckResult: null, + usage: null, + error: null, + startedAt: NOW, + dispatchedAt: NOW, + createdAt: NOW + } +} + +function sshRepo(): Repo { + return { + id: 'repo-1', + path: '/srv/repo', + displayName: 'Repo', + badgeColor: '#000', + addedAt: 1, + connectionId: OLD_ID, + executionHostId: `ssh:${OLD_ID}` + } as Repo +} + +function tombstone(overrides: Partial = {}): RemovedSshTargetTombstone { + return { + oldTargetId: OLD_ID, + ...IDENTITY, + configHost: 'dev.example.com', + label: 'Dev box', + removedAt: NOW, + ...overrides + } +} + +/** State as it looks right after the user removed the host: tombstone present, target gone. */ +function removedHostState(overrides: Record = {}) { + return { + repos: [sshRepo()], + sshTargets: [], + automations: [makeAutomation()], + removedSshTargetTombstones: [tombstone()], + ui: { ...getDefaultPersistedState(testState.dir).ui, automationHostFilter: undefined }, + ...overrides + } +} + +async function createStoreFromState(state: Record) { + mkdirSync(testState.dir, { recursive: true }) + writeFileSync( + join(testState.dir, 'orca-data.json'), + JSON.stringify({ ...getDefaultPersistedState(testState.dir), ...state }), + 'utf-8' + ) + vi.resetModules() + installFakeAppEnvironment({ getPath: () => testState.dir }) + const { Store, initDataPath } = await import('./persistence') + initDataPath() + return new Store() +} + +/** Re-read whatever is on disk now — no fixture rewrite. */ +async function reloadStore() { + vi.resetModules() + installFakeAppEnvironment({ getPath: () => testState.dir }) + const { Store, initDataPath } = await import('./persistence') + initDataPath() + return new Store() +} + +async function createSshStore(state: Record) { + const store = await createStoreFromState(state) + const { SshConnectionStore } = await import('./ssh/ssh-connection-store') + return { store, ssh: new SshConnectionStore(store) } +} + +/** Re-add the same host the tombstone remembers. */ +function readdDevBox(ssh: SshConnectionStore) { + return ssh.addTarget({ label: 'Dev box', configHost: 'dev.example.com', ...IDENTITY }) +} + +beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-readopt-')) +}) + +afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + vi.resetModules() +}) + +describe('SSH re-adoption migrates automations', () => { + it('leaves the automation orphaned while the host is gone', async () => { + const { store } = await createSshStore(removedHostState()) + const [item] = store.listAutomationsForScope().items + expect(item.selector).toEqual({ + kind: 'orphan', + issue: 'Its SSH host is no longer registered.' + }) + }) + + it('re-points the owner onto the re-added registration so it resolves again', async () => { + const { store, ssh } = await createSshStore(removedHostState()) + + const added = readdDevBox(ssh) + + const [item] = store.listAutomationsForScope().items + expect(item.selector).toEqual({ + kind: 'ssh', + targetId: added.id, + targetGeneration: added.generation + }) + expect(store.getRepo('repo-1')?.connectionId).toBe(added.id) + // Enablement is the user's intent and is never rewritten by migration or re-adoption. + expect(store.listAutomations()[0].enabled).toBe(true) + }) + + it('persists the automation, repo and filter migrations together', async () => { + const { store, ssh } = await createSshStore( + removedHostState({ + automationRuns: [makeRun()], + ui: { + ...getDefaultPersistedState(testState.dir).ui, + automationHostFilter: { kind: 'host', hostKey: desktopSshKey(OLD_ID) } + } + }) + ) + + const added = readdDevBox(ssh) + store.flush() + + const reloaded = await reloadStore() + const automation = reloaded.listAutomations()[0] + expect(automation.executionTargetId).toBe(added.id) + expect(automation.executionTargetGeneration).toBe(added.generation) + expect(reloaded.getUI().automationHostFilter).toEqual({ + kind: 'host', + hostKey: desktopSshKey(added.id) + }) + expect(reloaded.listAutomationRuns('auto-1')[0].runContext?.hostId).toBe(`ssh:${added.id}`) + expect(reloaded.getRepo('repo-1')?.connectionId).toBe(added.id) + }) + + it('repairs a folder workspace pinned to the removed host', async () => { + const folderWorkspace: FolderWorkspace = { + id: 'fw-1', + projectGroupId: 'group-1', + name: 'Remote folder', + folderPath: '/srv/folder', + connectionId: OLD_ID, + executionHostId: toSshExecutionHostId(OLD_ID), + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: NOW, + createdAt: NOW, + updatedAt: NOW + } + const group: ProjectGroup = { + id: 'group-1', + name: 'Remote', + parentPath: '/srv/folder', + connectionId: OLD_ID, + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: NOW, + updatedAt: NOW + } + // A folder workspace on SSH whose repo row is local: only the workspace pin names the host. + const { store, ssh } = await createSshStore( + removedHostState({ + repos: [{ ...sshRepo(), connectionId: undefined, executionHostId: 'local' } as Repo], + folderWorkspaces: [folderWorkspace], + projectGroups: [group], + automations: [ + makeAutomation({ + executionTargetType: 'local', + executionTargetId: 'local', + workspaceMode: 'existing', + workspaceId: folderWorkspaceKey('fw-1') + }) + ] + }) + ) + expect(store.listAutomationsForScope().items[0].selector).toEqual({ + kind: 'orphan', + issue: 'Its SSH host is no longer registered.' + }) + + const added = readdDevBox(ssh) + + expect(store.listAutomationsForScope().items[0].selector).toEqual({ + kind: 'ssh', + targetId: added.id, + targetGeneration: added.generation + }) + }) + + it('keeps allocating generations above every migrated capture', async () => { + const { store, ssh } = await createSshStore( + removedHostState({ + automations: [makeAutomation({ executionTargetGeneration: 40 })], + sshTargetGenerationCounter: 40 + }) + ) + + const added = readdDevBox(ssh) + + expect(added.generation).toBeGreaterThan(40) + expect(store.listAutomations()[0].executionTargetGeneration).toBe(added.generation) + // The migrated capture must never be able to depress or collide with a later allocation. + expect(store.allocateSshTargetGeneration()).toBeGreaterThan(added.generation!) + }) + + it('loads a pre-generation store and migrates it without dropping automations', async () => { + // No generations, no persisted filter, no counter: state written by an older build. + const { store, ssh } = await createSshStore({ + repos: [sshRepo()], + sshTargets: [], + automations: [makeAutomation()], + removedSshTargetTombstones: [tombstone()], + ui: {} + }) + + const added = readdDevBox(ssh) + + expect(store.listAutomations()).toHaveLength(1) + expect(store.listAutomationsForScope().items[0].selector).toEqual({ + kind: 'ssh', + targetId: added.id, + targetGeneration: added.generation + }) + }) +}) + +describe('SSH re-adoption tombstone retention', () => { + it('consumes the tombstone once nothing depends on its removal evidence', async () => { + const { store, ssh } = await createSshStore( + removedHostState({ + ui: { + ...getDefaultPersistedState(testState.dir).ui, + automationHostFilter: { kind: 'host', hostKey: desktopSshKey(OLD_ID) } + } + }) + ) + + readdDevBox(ssh) + + expect(store.getRemovedSshTargetTombstones()).toEqual([]) + }) + + it('retains the tombstone when a same-id re-registration leaves the automation on the old incarnation', async () => { + const { store } = await createSshStore(removedHostState()) + const { readoptOrphanedWorkspacesForTarget } = await import('./ssh/ssh-target-readoption') + // Same id back with a new registration: nothing is re-pointed, so the automation + // still depends on the tombstone as the only evidence its host was replaced. + const target = { id: OLD_ID, label: 'Dev box', ...IDENTITY, generation: 9 } + store.addSshTarget(target) + + expect(readoptOrphanedWorkspacesForTarget(store, target)).toEqual([]) + expect(store.getRemovedSshTargetTombstones()).toHaveLength(1) + }) + + it('retains the tombstone while only the persisted filter still names the removed host', async () => { + const { store } = await createSshStore( + removedHostState({ + automations: [], + ui: { + ...getDefaultPersistedState(testState.dir).ui, + automationHostFilter: { kind: 'host', hostKey: desktopSshKey(OLD_ID) } + } + }) + ) + + store.releaseRemovedSshTargetTombstone(OLD_ID) + + expect(store.getRemovedSshTargetTombstones()).toHaveLength(1) + }) +}) diff --git a/src/main/persistence-workspace-pinned-automation-fence.test.ts b/src/main/persistence-workspace-pinned-automation-fence.test.ts new file mode 100644 index 00000000000..977aed0692d --- /dev/null +++ b/src/main/persistence-workspace-pinned-automation-fence.test.ts @@ -0,0 +1,316 @@ +/** + * A `local`-typed automation whose folder workspace pins it to an SSH host is + * projected as SSH-owned, so it must be fenceable like any other SSH-owned row. + * + * It used to capture no registration generation at all, so `captured ?? current` + * always read the *current* one and `targetReplaced` could never fire: deleting + * a host and re-adding one under the same id silently moved the schedule onto a + * different machine. These tests drive the real Store through create and reload. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { Automation, AutomationCreateInput } from '../shared/automations-types' +import type { FolderWorkspace } from '../shared/folder-workspace-types' +import type { ProjectGroup } from '../shared/project-group-types' +import type { Repo } from '../shared/repo-types' +import type { SshTarget } from '../shared/ssh-types' +import { AUTOMATION_ORPHAN_ISSUES } from '../shared/automation-list-scope' +import { AUTOMATION_OWNER_CONFLICT_CODES } from '../shared/automation-owner-conflict' +import { getDefaultPersistedState } from '../shared/constants' +import { folderWorkspaceKey } from '../shared/workspace-scope' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' + +const testState = { dir: '' } + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) + +const NOW = 1_700_000_000_000 +const TARGET_ID = 'prod' +const FIRST_GENERATION = 4 +const REPLACEMENT_GENERATION = 5 + +function prodTarget(generation: number): SshTarget { + return { + id: TARGET_ID, + label: 'Prod box', + host: 'prod.example.com', + port: 22, + username: 'tim', + generation + } as SshTarget +} + +/** Local project outside the pinned folder scope: only the pin makes the record SSH-owned. */ +function localRepo(): Repo { + return { + id: 'repo-1', + path: '/other/repo', + displayName: 'Repo', + badgeColor: '#000', + addedAt: 1 + } as Repo +} + +function projectGroup(): ProjectGroup { + return { + id: 'group-1', + name: 'Group', + // A folder-backed group: folder workspaces only survive normalization under one. + parentPath: '/srv', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: NOW, + updatedAt: NOW + } as ProjectGroup +} + +/** A remote-rooted folder workspace: its scope connection is the pin. */ +function pinnedFolderWorkspace(): FolderWorkspace { + return { + id: 'fw-1', + projectGroupId: 'group-1', + name: 'Pinned workspace', + folderPath: '/srv/remote', + connectionId: TARGET_ID, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + createdAt: NOW, + updatedAt: NOW + } as FolderWorkspace +} + +function pinnedState(generation: number, overrides: Record = {}) { + return { + repos: [localRepo()], + projectGroups: [projectGroup()], + folderWorkspaces: [pinnedFolderWorkspace()], + sshTargets: [prodTarget(generation)], + sshTargetGenerationCounter: generation, + automations: [], + ...overrides + } +} + +async function createStoreFromState(state: Record) { + mkdirSync(testState.dir, { recursive: true }) + writeFileSync( + join(testState.dir, 'orca-data.json'), + JSON.stringify({ ...getDefaultPersistedState(testState.dir), ...state }), + 'utf-8' + ) + vi.resetModules() + installFakeAppEnvironment({ getPath: () => testState.dir }) + const { Store, initDataPath } = await import('./persistence') + initDataPath() + return new Store() +} + +async function reloadStore() { + vi.resetModules() + installFakeAppEnvironment({ getPath: () => testState.dir }) + const { Store, initDataPath } = await import('./persistence') + initDataPath() + return new Store() +} + +/** The same target id now carries a different registration incarnation. */ +function replaceStoredTargetGeneration(generation: number): void { + const file = join(testState.dir, 'orca-data.json') + const state = JSON.parse(readFileSync(file, 'utf-8')) + state.sshTargets = [prodTarget(generation)] + state.sshTargetGenerationCounter = generation + writeFileSync(file, JSON.stringify(state), 'utf-8') +} + +const PINNED_CREATE_INPUT: AutomationCreateInput = { + name: 'Nightly', + prompt: 'go', + agentId: 'codex', + projectId: 'repo-1', + workspaceMode: 'existing', + workspaceId: folderWorkspaceKey('fw-1'), + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: NOW +} + +function createPinnedAutomation(store: { + createAutomation: (input: AutomationCreateInput) => Automation +}): Automation { + return store.createAutomation(PINNED_CREATE_INPUT) +} + +beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-pinned-fence-')) +}) + +afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + vi.resetModules() +}) + +describe('workspace-pinned automations capture a registration generation', () => { + it('stamps the pinned SSH target generation when the record is created', async () => { + const store = await createStoreFromState(pinnedState(FIRST_GENERATION)) + + const automation = createPinnedAutomation(store) + + expect(automation.executionTargetType).toBe('local') + expect(automation.executionTargetGeneration).toBe(FIRST_GENERATION) + }) + + it('projects the pinned record onto the SSH host while the registration is unchanged', async () => { + const store = await createStoreFromState(pinnedState(FIRST_GENERATION)) + createPinnedAutomation(store) + + expect(store.listAutomationsForScope().items[0].selector).toEqual({ + kind: 'ssh', + targetId: TARGET_ID, + targetGeneration: FIRST_GENERATION + }) + }) + + it('orphans the pinned record when the same target id is re-added as a new incarnation', async () => { + const store = await createStoreFromState(pinnedState(FIRST_GENERATION)) + createPinnedAutomation(store) + store.flush() + replaceStoredTargetGeneration(REPLACEMENT_GENERATION) + + const reloaded = await reloadStore() + + expect(reloaded.listAutomationsForScope().items[0].selector).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.targetReplaced + }) + }) + + it('refuses to dispatch the pinned record onto the replacement machine', async () => { + const store = await createStoreFromState(pinnedState(FIRST_GENERATION)) + createPinnedAutomation(store) + store.flush() + replaceStoredTargetGeneration(REPLACEMENT_GENERATION) + + const reloaded = await reloadStore() + + const automation = reloaded.listAutomations()[0] + expect(reloaded.automationCapturedHostIssue(automation)).toBe( + AUTOMATION_ORPHAN_ISSUES.targetReplaced + ) + expect(() => + reloaded.assertAutomationOwnerFence({ id: automation.id, operation: 'execute' }) + ).toThrowError(expect.objectContaining({ code: AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved })) + }) + + it('backfills a legacy pinned record that was stored without a capture', async () => { + const legacy = { + id: 'auto-legacy', + name: 'Legacy', + prompt: 'go', + precheck: null, + agentId: 'codex', + projectId: 'repo-1', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'existing', + workspaceId: folderWorkspaceKey('fw-1'), + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: NOW, + enabled: true, + nextRunAt: NOW, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: NOW, + updatedAt: NOW + } + const store = await createStoreFromState( + pinnedState(FIRST_GENERATION, { automations: [legacy] }) + ) + + expect(store.listAutomations()[0].executionTargetGeneration).toBe(FIRST_GENERATION) + }) + + it('never re-stamps a differing capture, so a replacement cannot re-adopt the record', async () => { + const store = await createStoreFromState( + pinnedState(REPLACEMENT_GENERATION, { + automations: [ + { + id: 'auto-captured', + name: 'Captured', + prompt: 'go', + precheck: null, + agentId: 'codex', + projectId: 'repo-1', + executionTargetType: 'local', + executionTargetId: 'local', + executionTargetGeneration: FIRST_GENERATION, + schedulerOwner: 'local_host_service', + workspaceMode: 'existing', + workspaceId: folderWorkspaceKey('fw-1'), + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: NOW, + enabled: true, + nextRunAt: NOW, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: NOW, + updatedAt: NOW + } + ] + }) + ) + + expect(store.listAutomations()[0].executionTargetGeneration).toBe(FIRST_GENERATION) + expect(store.listAutomationsForScope().items[0].selector).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.targetReplaced + }) + }) +}) + +describe('the destination a pinned create lands on', () => { + // The derived selection reads `local`; the record still lands on the pinned SSH host, + // so accepting Self here scheduled a job on a machine the user did not choose. + it('refuses a Self destination for a workspace-pinned record', async () => { + const store = await createStoreFromState(pinnedState(FIRST_GENERATION)) + + expect(() => + store.createAutomation(PINNED_CREATE_INPUT, { destination: { selector: { kind: 'self' } } }) + ).toThrowError( + expect.objectContaining({ code: AUTOMATION_OWNER_CONFLICT_CODES.invalidDestination }) + ) + expect(store.listAutomations()).toHaveLength(0) + }) + + it('accepts the pinned host as the destination', async () => { + const store = await createStoreFromState(pinnedState(FIRST_GENERATION)) + + const created = store.createAutomation(PINNED_CREATE_INPUT, { + destination: { + selector: { kind: 'ssh', targetId: TARGET_ID, targetGeneration: FIRST_GENERATION } + } + }) + + expect(created.executionTargetGeneration).toBe(FIRST_GENERATION) + }) +}) diff --git a/src/main/persistence-workspace-repin-automation-owner.test.ts b/src/main/persistence-workspace-repin-automation-owner.test.ts new file mode 100644 index 00000000000..14f33c5db29 --- /dev/null +++ b/src/main/persistence-workspace-repin-automation-owner.test.ts @@ -0,0 +1,216 @@ +/** + * A folder workspace can be re-pointed at another SSH host outside the automation + * editor — the workspace's scope connection moves, and every record inside it + * moves with it. The capture on those records names the registration they were + * attached to, so it has to follow the pin or the record reads as a replaced + * orphan on a host that was never replaced. + * + * Following is gated on positive evidence: a live registration carrying the + * capture proves which pin it came from. Without that the capture stays put, so + * a host removed and re-added under the same id still cannot re-adopt the record. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { FolderWorkspace } from '../shared/folder-workspace-types' +import type { ProjectGroup } from '../shared/project-group-types' +import type { Repo } from '../shared/repo-types' +import type { SshTarget } from '../shared/ssh-types' +import { AUTOMATION_ORPHAN_ISSUES } from '../shared/automation-list-scope' +import { getDefaultPersistedState } from '../shared/constants' +import { folderWorkspaceKey } from '../shared/workspace-scope' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' + +const testState = { dir: '' } + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) + +const NOW = 1_700_000_000_000 +const PROD_GENERATION = 4 +const STAGING_GENERATION = 9 + +function target(id: string, generation: number): SshTarget { + return { + id, + label: id, + host: `${id}.example.com`, + port: 22, + username: 'tim', + generation + } as SshTarget +} + +/** Local project outside the pinned folder scope: only the pin makes the record SSH-owned. */ +function localRepo(): Repo { + return { + id: 'repo-1', + path: '/other/repo', + displayName: 'Repo', + badgeColor: '#000', + addedAt: 1 + } as Repo +} + +function projectGroup(): ProjectGroup { + return { + id: 'group-1', + name: 'Group', + parentPath: '/srv', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: NOW, + updatedAt: NOW + } as ProjectGroup +} + +function folderWorkspace(connectionId: string): FolderWorkspace { + return { + id: 'fw-1', + projectGroupId: 'group-1', + name: 'Pinned workspace', + folderPath: '/srv/remote', + connectionId, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + createdAt: NOW, + updatedAt: NOW + } as FolderWorkspace +} + +function pinnedAutomation() { + return { + id: 'auto-1', + name: 'Nightly', + prompt: 'go', + precheck: null, + agentId: 'codex', + projectId: 'repo-1', + executionTargetType: 'local', + executionTargetId: 'local', + executionTargetGeneration: PROD_GENERATION, + schedulerOwner: 'local_host_service', + workspaceMode: 'existing', + workspaceId: folderWorkspaceKey('fw-1'), + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: NOW, + enabled: true, + nextRunAt: NOW, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: NOW, + updatedAt: NOW + } +} + +function initialState(): Record { + return { + repos: [localRepo()], + projectGroups: [projectGroup()], + folderWorkspaces: [folderWorkspace('prod')], + sshTargets: [target('prod', PROD_GENERATION), target('staging', STAGING_GENERATION)], + sshTargetGenerationCounter: STAGING_GENERATION, + automations: [pinnedAutomation()] + } +} + +async function loadStore(state: Record) { + mkdirSync(testState.dir, { recursive: true }) + writeFileSync( + join(testState.dir, 'orca-data.json'), + JSON.stringify({ ...getDefaultPersistedState(testState.dir), ...state }), + 'utf-8' + ) + vi.resetModules() + installFakeAppEnvironment({ getPath: () => testState.dir }) + const { Store, initDataPath } = await import('./persistence') + initDataPath() + return new Store() +} + +/** The workspace's execution host changes without any automation being edited. */ +function repinWorkspace(connectionId: string, sshTargets?: SshTarget[]): void { + const file = join(testState.dir, 'orca-data.json') + const state = JSON.parse(readFileSync(file, 'utf-8')) + state.folderWorkspaces = [folderWorkspace(connectionId)] + if (sshTargets) { + state.sshTargets = sshTargets + } + writeFileSync(file, JSON.stringify(state), 'utf-8') +} + +beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-workspace-repin-')) +}) + +afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + vi.resetModules() +}) + +describe('a workspace re-pinned outside the automation editor', () => { + it('owns its records on the pin it started on', async () => { + const store = await loadStore(initialState()) + + expect(store.listAutomationsForScope().items[0].selector).toEqual({ + kind: 'ssh', + targetId: 'prod', + targetGeneration: PROD_GENERATION + }) + }) + + it('moves the captured owner onto the registration it now names', async () => { + const store = await loadStore(initialState()) + store.flush() + repinWorkspace('staging') + + vi.resetModules() + installFakeAppEnvironment({ getPath: () => testState.dir }) + const { Store, initDataPath } = await import('./persistence') + initDataPath() + const reloaded = new Store() + + expect(reloaded.listAutomations()[0].executionTargetGeneration).toBe(STAGING_GENERATION) + expect(reloaded.listAutomationsForScope().items[0].selector).toEqual({ + kind: 'ssh', + targetId: 'staging', + targetGeneration: STAGING_GENERATION + }) + expect(reloaded.automationCapturedHostIssue(reloaded.listAutomations()[0])).toBeNull() + }) + + // No live registration carries the capture, so nothing proves it came from the old pin + // rather than from this one before it was replaced. The record stays fenced. + it('keeps the record fenced when the pin it left is gone too', async () => { + const store = await loadStore(initialState()) + store.flush() + repinWorkspace('staging', [target('staging', STAGING_GENERATION)]) + + vi.resetModules() + installFakeAppEnvironment({ getPath: () => testState.dir }) + const { Store, initDataPath } = await import('./persistence') + initDataPath() + const reloaded = new Store() + + expect(reloaded.listAutomations()[0].executionTargetGeneration).toBe(PROD_GENERATION) + expect(reloaded.listAutomationsForScope().items[0].selector).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.targetReplaced + }) + }) +}) diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts b/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts index 8dd5425b833..919e4996486 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts @@ -2,6 +2,7 @@ import type { ProjectHostSetup } from '../../../shared/project-types' import { toSshExecutionHostId } from '../../../shared/execution-host' import type { PersistedState } from '../../../shared/persisted-state-types' import { + migrateFolderWorkspaceHostSshTargetId, migrateUiHostScopeSshTargetId, migrateWorkspaceSessionSshTargetId } from '../../ssh/ssh-target-id-migration' @@ -11,6 +12,11 @@ import { migrateRetirementNamespaceHostIdentity, sshHostIdentity } from '../../worktree-retirement-namespace' +import { + migrateAutomationHostFilterSshTargetId, + migrateAutomationsForSshReadoption +} from '../../automations/automation-ssh-readoption-migration' +import { automationIdsPinnedToSshTarget } from '../scheduling-automations/automation-owner-projection' export type SshTargetReassignmentOperations = { state: PersistedState @@ -107,6 +113,27 @@ export function reassignSshTargetId( if (migrateRetirementNamespaces(operations.state, oldTargetId, newTargetId)) { carrierChanged = true } + const workspacePinnedAutomationIds = automationIdsPinnedToSshTarget(operations.state, oldTargetId) + if (migrateFolderWorkspaceHostSshTargetId(operations.state, oldTargetId, newTargetId)) { + carrierChanged = true + } + if ( + migrateAutomationsForSshReadoption({ + automations: operations.state.automations ?? [], + automationRuns: operations.state.automationRuns ?? [], + oldTargetId, + newTargetId, + workspacePinnedAutomationIds, + newTargetGeneration: (operations.state.sshTargets ?? []).find( + (target) => target.id === newTargetId + )?.generation + }) + ) { + carrierChanged = true + } + if (migrateAutomationHostFilterSshTargetId(operations.state.ui, oldTargetId, newTargetId)) { + carrierChanged = true + } for (const lease of operations.state.sshRemotePtyLeases ?? []) { if (lease.targetId === oldTargetId) { lease.targetId = newTargetId @@ -149,6 +176,11 @@ export function reassignSshTargetId( operations.syncProjectHostSetupCompatibilityState() } if (repoIds.size > 0 || metaChanged || carrierChanged || setupsChanged) { + // The rewrites above patch rows in place; the list projection caches on array + // identity, so a same-identity array would keep serving pre-readoption owners. + operations.state.repos = [...operations.state.repos] + operations.state.automations = [...(operations.state.automations ?? [])] + operations.state.automationRuns = [...(operations.state.automationRuns ?? [])] operations.scheduleSave() } return [...repoIds] diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts b/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts index dbdb03b89a1..2f0ccdc8165 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts @@ -2,10 +2,12 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { RemovedSshTargetTombstone, SshTarget } from '../../../shared/ssh-types' import type { ProtectedSecretPersistence } from '../../protected-secret-persistence' import { sshPtyOwnerLeaseSecretSlot } from '../../protected-secret-persistence' +import { MAX_CLAUDE_LIVE_PTY_SESSION_IDS } from '../restoring-sessions/pane-alias-normalization' import { - MAX_CLAUDE_LIVE_PTY_SESSION_IDS, - MAX_REMOVED_SSH_TARGET_TOMBSTONES -} from '../restoring-sessions/pane-alias-normalization' + MAX_REMOVED_SSH_TARGET_TOMBSTONES, + capRemovedSshTargetTombstones, + collectSshTargetRemovalEvidenceDependencies +} from '../../ssh/removed-ssh-target-tombstone-retention' import { normalizeSshTarget } from './ssh-normalization' import { isRuntimeOwnedSshTargetId } from '../../../shared/execution-host' import { @@ -30,8 +32,8 @@ export function getSshTarget(state: PersistedState, id: string): SshTarget | und } export function addSshTarget(operations: SshTargetStateOperations, target: SshTarget): void { - operations.state.sshTargets ??= [] - operations.state.sshTargets.push(normalizeSshTarget(target)) + // Replaced, not pushed in place: the automation list projection caches on array identity. + operations.state.sshTargets = [...(operations.state.sshTargets ?? []), normalizeSshTarget(target)] operations.scheduleSave() } @@ -69,6 +71,9 @@ export function updateSshTarget( to: sshHostIdentity(target) }) } + // The Object.assign above patches the row in place (other callers hold the + // reference); the automation list projection caches on array identity. + operations.state.sshTargets = [...(operations.state.sshTargets ?? [])] operations.scheduleSave() return { ...target } } @@ -170,13 +175,35 @@ export function addRemovedSshTargetTombstone( const existing = operations.state.removedSshTargetTombstones ?? [] // Why: dedupe by oldTargetId so re-removing the same id can't stack duplicate tombstones; newest wins. const filtered = existing.filter((entry) => entry.oldTargetId !== tombstone.oldTargetId) - // Cap the history so pathological churn can't grow the state file unbounded. - operations.state.removedSshTargetTombstones = [...filtered, tombstone].slice( - -MAX_REMOVED_SSH_TARGET_TOMBSTONES + operations.state.removedSshTargetTombstones = capRemovedSshTargetTombstones( + [...filtered, tombstone], + sshTargetRemovalEvidenceDependencies(operations.state), + MAX_REMOVED_SSH_TARGET_TOMBSTONES ) operations.scheduleSave() } +function sshTargetRemovalEvidenceDependencies(state: PersistedState): Set { + return collectSshTargetRemovalEvidenceDependencies({ + automations: state.automations ?? [], + automationHostFilter: state.ui?.automationHostFilter, + workspaceState: { + folderWorkspaces: state.folderWorkspaces ?? [], + projectGroups: state.projectGroups ?? [], + repos: state.repos ?? [] + } + }) +} + +export function releaseRemovedSshTargetTombstone( + operations: SshTargetStateOperations, + oldTargetId: string +): void { + if (!sshTargetRemovalEvidenceDependencies(operations.state).has(oldTargetId)) { + removeRemovedSshTargetTombstone(operations, oldTargetId) + } +} + export function removeRemovedSshTargetTombstone( operations: SshTargetStateOperations, oldTargetId: string diff --git a/src/main/persistence/loading-store/automation-persistence.ts b/src/main/persistence/loading-store/automation-persistence.ts index fc62a4baf1e..3e56f2aaf8f 100644 --- a/src/main/persistence/loading-store/automation-persistence.ts +++ b/src/main/persistence/loading-store/automation-persistence.ts @@ -6,6 +6,17 @@ import type { AutomationRunTrigger, AutomationUpdateInput } from '../../../shared/automations-types' +import type { + AutomationCapturedHostIssue, + AutomationChangeSelector, + AutomationListParams, + AutomationListResult +} from '../../../shared/automation-list-scope' +import type { + AutomationDestination, + AutomationOwnerFenceOperation, + AutomationOwnerPrecondition +} from '../../../shared/automation-owner-precondition' import { getWorktreePathBasenameFromId } from '../../../shared/worktree/id' import { normalizeAutomationRunWorkspaceDisplayName } from '../scheduling-automations/automation-context-migration' import { @@ -18,6 +29,7 @@ import { import { createAutomationRun as createAutomationRunOperation, listAutomationRuns as listAutomationRunsOperation, + recordRepeatedAutomationSkip as recordRepeatedAutomationSkipOperation, snapshotAutomationRunWorkspaceDisplayName as snapshotAutomationRunWorkspaceDisplayNameOperation, updateAutomationRun as updateAutomationRunOperation, type AutomationRunOperations @@ -26,12 +38,22 @@ import { advanceAutomationNextRun as advanceAutomationNextRunOperation, getLatestAutomationOccurrence as getLatestAutomationOccurrenceOperation } from '../scheduling-automations/automation-schedule-operations' +import { + assertAutomationOwnerFence as assertAutomationOwnerFenceOperation, + automationCapturedHostIssue as automationCapturedHostIssueOperation, + automationChangeSelector as automationChangeSelectorOperation, + automationOwnerPrecondition as automationOwnerPreconditionOperation, + listAutomationsForScope as listAutomationsForScopeOperation +} from '../scheduling-automations/automation-owner-projection' import type { StoreRuntimeState } from './store-runtime-state' import type { WriteFlushBarrierOperations } from './write-flush-barriers' import type { ProfilePreferences } from './profile-preferences' -type AutomationPersistenceRuntime = Pick +type AutomationPersistenceRuntime = Pick< + StoreRuntimeState, + 'automationListProjectionCache' | 'state' | 'storageAuthority' +> const automationPersistenceContext = Symbol('AutomationPersistence') type AutomationPersistenceContext = { @@ -55,6 +77,47 @@ export class AutomationPersistence { return listAutomationsOperation(this[automationPersistenceContext].runtime.state) } + listAutomationsForScope(params?: AutomationListParams | null): AutomationListResult { + const runtime = this[automationPersistenceContext].runtime + const projection = listAutomationsForScopeOperation({ + state: runtime.state, + storageAuthority: runtime.storageAuthority, + automations: this.listAutomations(), + params, + cache: runtime.automationListProjectionCache + }) + runtime.automationListProjectionCache = projection.cache + return projection.result + } + + automationOwnerPrecondition(id: string): AutomationOwnerPrecondition | null { + const runtime = this[automationPersistenceContext].runtime + return automationOwnerPreconditionOperation(runtime.state, runtime.storageAuthority, id) + } + + automationChangeSelector(id: string): AutomationChangeSelector | null { + const runtime = this[automationPersistenceContext].runtime + return automationChangeSelectorOperation(runtime.state, runtime.storageAuthority, id) + } + + automationCapturedHostIssue(automation: Automation): AutomationCapturedHostIssue | null { + const runtime = this[automationPersistenceContext].runtime + return automationCapturedHostIssueOperation(runtime.state, runtime.storageAuthority, automation) + } + + assertAutomationOwnerFence(input: { + id: string + expectedOwner?: AutomationOwnerPrecondition + operation: AutomationOwnerFenceOperation + }): Automation { + const runtime = this[automationPersistenceContext].runtime + return assertAutomationOwnerFenceOperation({ + state: runtime.state, + storageAuthority: runtime.storageAuthority, + ...input + }) + } + listAutomationRuns(automationId?: string): AutomationRun[] { return listAutomationRunsOperation( this[automationPersistenceContext].runtime.state, @@ -62,16 +125,23 @@ export class AutomationPersistence { ) } - createAutomation(input: AutomationCreateInput): Automation { - return createAutomationOperation(getAutomationDefinitionOperations(this), input) + createAutomation( + input: AutomationCreateInput, + options?: { destination?: AutomationDestination } + ): Automation { + return createAutomationOperation(getAutomationDefinitionOperations(this), input, options) } - updateAutomation(id: string, updates: AutomationUpdateInput): Automation { - return updateAutomationOperation(getAutomationDefinitionOperations(this), id, updates) + updateAutomation( + id: string, + updates: AutomationUpdateInput, + options?: { expectedOwner?: AutomationOwnerPrecondition; destination?: AutomationDestination } + ): Automation { + return updateAutomationOperation(getAutomationDefinitionOperations(this), id, updates, options) } - deleteAutomation(id: string): void { - deleteAutomationOperation(getAutomationDefinitionOperations(this), id) + deleteAutomation(id: string, options?: { expectedOwner?: AutomationOwnerPrecondition }): void { + deleteAutomationOperation(getAutomationDefinitionOperations(this), id, options) } createAutomationRun( @@ -87,6 +157,19 @@ export class AutomationPersistence { ) } + recordRepeatedAutomationSkip( + automationId: string, + error: string, + scheduledFor: number + ): AutomationRun | null { + return recordRepeatedAutomationSkipOperation( + getAutomationRunOperations(this), + automationId, + error, + scheduledFor + ) + } + updateAutomationRun(result: AutomationDispatchResult): AutomationRun { return updateAutomationRunOperation(getAutomationRunOperations(this), result) } @@ -118,6 +201,7 @@ export function getAutomationDefinitionOperations( ): AutomationDefinitionOperations { return { state: owner[automationPersistenceContext].runtime.state, + storageAuthority: owner[automationPersistenceContext].runtime.storageAuthority, flush: () => owner[automationPersistenceContext].flushBarriers.flush(), recordCreated: () => owner[automationPersistenceContext].preferences.recordFeatureInteraction('automation-created') diff --git a/src/main/persistence/loading-store/loaded-state-parsing.ts b/src/main/persistence/loading-store/loaded-state-parsing.ts index 2d29844ffb0..2f28c771508 100644 --- a/src/main/persistence/loading-store/loaded-state-parsing.ts +++ b/src/main/persistence/loading-store/loaded-state-parsing.ts @@ -27,6 +27,7 @@ import { normalizeWorktreeLinkedItemMetadata } from '../tracking-repos/worktree-metadata-normalization' import { backfillLegacyAutomationContexts } from '../scheduling-automations/automation-context-migration' +import { migrateAutomationOwners } from '../../automations/automation-owner-migration' import { ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH, normalizeSshPtyConsumerRecovery @@ -60,6 +61,7 @@ type LoadedStateParsingOperationsRuntime = Pick< | 'githubCacheDirty' | 'loadNeedsSave' | 'protectedSecrets' + | 'storageAuthority' | 'terminalScrollbackSnapshotStorage' > @@ -221,6 +223,25 @@ export class LoadedStateParsingOperations { automationRuns: automationContextMigration.state.automationRuns } + const automationOwnerMigration = migrateAutomationOwners({ + automations: result.automations, + sshTargets: result.sshTargets, + repos, + folderWorkspaces: result.folderWorkspaces, + projectGroups: result.projectGroups, + sshTargetGenerationCounter: result.sshTargetGenerationCounter, + storageAuthority: this.runtime.storageAuthority + }) + if (automationOwnerMigration.changed) { + this.runtime.loadNeedsSave = true + } + result = { + ...result, + automations: automationOwnerMigration.automations, + sshTargets: automationOwnerMigration.sshTargets, + sshTargetGenerationCounter: automationOwnerMigration.sshTargetGenerationCounter + } + const folderScopeConnectionMigration = backfillFolderScopeConnectionIds({ ...result, repos, diff --git a/src/main/persistence/loading-store/ssh-profile-operations.ts b/src/main/persistence/loading-store/ssh-profile-operations.ts index 5ab7c635f27..dce7a92890f 100644 --- a/src/main/persistence/loading-store/ssh-profile-operations.ts +++ b/src/main/persistence/loading-store/ssh-profile-operations.ts @@ -14,6 +14,7 @@ import { removeClaudeLivePtySessionId as removeClaudeLivePtySessionIdOperation, removeDeletedSshConfigAlias as removeDeletedSshConfigAliasOperation, removeRemovedSshTargetTombstone as removeRemovedSshTargetTombstoneOperation, + releaseRemovedSshTargetTombstone as releaseRemovedSshTargetTombstoneOperation, removeSshTarget as removeSshTargetOperation, updateSshTarget as updateSshTargetOperation } from '../leasing-ssh-ptys/ssh-target-state' @@ -21,6 +22,7 @@ import { reassignSshTargetId as reassignSshTargetIdOperation, type SshTargetReassignmentOperations } from '../leasing-ssh-ptys/ssh-target-reassignment' +import { allocateSshTargetGeneration as allocateSshTargetGenerationOperation } from '../scheduling-automations/automation-owner-projection' import type { StoreRuntimeState } from './store-runtime-state' import type { WriteSchedulingOperations } from './write-scheduling' @@ -71,6 +73,13 @@ export class SshProfileOperations { removeSshTargetOperation(getSshTargetStateOperations(this), id) } + allocateSshTargetGeneration(): number { + const context = this[sshProfileOperationsContext] + return allocateSshTargetGenerationOperation(context.runtime.state, () => + scheduleSave(context.scheduling) + ) + } + getClaudeLivePtySessionIds(): string[] { return getClaudeLivePtySessionIdsOperation(this[sshProfileOperationsContext].runtime.state) } @@ -107,6 +116,10 @@ export class SshProfileOperations { addRemovedSshTargetTombstoneOperation(getSshTargetStateOperations(this), tombstone) } + releaseRemovedSshTargetTombstone(oldTargetId: string): void { + releaseRemovedSshTargetTombstoneOperation(getSshTargetStateOperations(this), oldTargetId) + } + removeRemovedSshTargetTombstone(oldTargetId: string): void { removeRemovedSshTargetTombstoneOperation(getSshTargetStateOperations(this), oldTargetId) } diff --git a/src/main/persistence/loading-store/store-runtime-state.ts b/src/main/persistence/loading-store/store-runtime-state.ts index 284f556ae41..b14ea8ce3a4 100644 --- a/src/main/persistence/loading-store/store-runtime-state.ts +++ b/src/main/persistence/loading-store/store-runtime-state.ts @@ -15,13 +15,22 @@ import type { RepoOrderPersistenceOperations } from '../tracking-repos/repo-orde import type { ProjectHostPersistenceOperations } from '../tracking-repos/project-host-operations' import type { RepoUpdatePersistenceOperations } from '../tracking-repos/repo-update-operations' import type { ProjectHostSetupPersistenceOperations } from '../tracking-repos/project-host-setup-update' +import type { + AutomationListProjectionCache, + AutomationStorageAuthority +} from '../scheduling-automations/automation-owner-projection' -export type StoreRuntimeOptions = { dataFile?: string } +export type StoreRuntimeOptions = { + dataFile?: string + storageAuthority?: AutomationStorageAuthority +} /** Mutable coordination state shared only with this Store's private collaborators. */ export class StoreRuntimeState { state!: PersistedState readonly dataFile: string + readonly storageAuthority: AutomationStorageAuthority + automationListProjectionCache: AutomationListProjectionCache | null = null activeViewPreference!: ActiveViewPreference readonly terminalScrollbackSnapshotStorage: TerminalScrollbackSnapshotStorage writeTimer: ReturnType | null = null @@ -62,6 +71,7 @@ export class StoreRuntimeState { constructor(options: StoreRuntimeOptions = {}) { this.dataFile = options.dataFile ?? getDataFile() + this.storageAuthority = options.storageAuthority ?? 'desktop' this.staleTempCleanup = removeStaleDurableWriteTempFiles(this.dataFile, { minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS }) diff --git a/src/main/persistence/loading-store/store.ts b/src/main/persistence/loading-store/store.ts index c66615a03df..888c0092ed2 100644 --- a/src/main/persistence/loading-store/store.ts +++ b/src/main/persistence/loading-store/store.ts @@ -7,7 +7,7 @@ import { agentHookServer } from '../../agent-hooks/server' import { ActiveViewPreference } from '../../active-view-preference' import { registerPersistedPaneKeyAlias } from '../restoring-sessions/pane-alias-normalization' import { normalizePersistedPaneIdentityState } from '../restoring-sessions/workspace-pane-normalization' -import { StoreRuntimeState } from './store-runtime-state' +import { StoreRuntimeState, type StoreRuntimeOptions } from './store-runtime-state' import { createStoreDomains, installStoreDomainContexts, @@ -33,7 +33,7 @@ import type { RetiredWorktreeNamePersistence } from './retired-worktree-name-per import type { SshLeaseRecoveryOperations } from './ssh-lease-recovery-operations' import type { WriteFlushBarrierOperations } from './write-flush-barriers' -export type StoreOptions = { dataFile?: string } +export type StoreOptions = StoreRuntimeOptions export type PtyBindingSourceExpectation = { worktreeId?: string tabId: string diff --git a/src/main/persistence/loading-store/write-flush-barriers.ts b/src/main/persistence/loading-store/write-flush-barriers.ts index 81424dc931b..1a80c77976f 100644 --- a/src/main/persistence/loading-store/write-flush-barriers.ts +++ b/src/main/persistence/loading-store/write-flush-barriers.ts @@ -10,6 +10,7 @@ import { enqueueWrite } from './primary-state-writes' type WriteFlushBarrierOperationsRuntime = Pick< StoreRuntimeState, | 'activeViewPreference' + | 'automationListProjectionCache' | 'dataFile' | 'firstPendingSaveAt' | 'githubCacheDirty' @@ -39,6 +40,7 @@ export class WriteFlushBarrierOperations { } flush(): void { + this[writeFlushBarrierOperationsContext].runtime.automationListProjectionCache = null if (this[writeFlushBarrierOperationsContext].runtime.quitFlushStarted) { return } diff --git a/src/main/persistence/loading-store/write-scheduling.ts b/src/main/persistence/loading-store/write-scheduling.ts index bf9fad874c4..c78a5d0dfd4 100644 --- a/src/main/persistence/loading-store/write-scheduling.ts +++ b/src/main/persistence/loading-store/write-scheduling.ts @@ -8,6 +8,7 @@ const SAVE_MAX_WAIT_MS = 5_000 type WriteSchedulingOperationsRuntime = Pick< StoreRuntimeState, | 'activeViewPreference' + | 'automationListProjectionCache' | 'firstPendingSaveAt' | 'pendingWrite' | 'quitFlushStarted' @@ -37,6 +38,7 @@ export class WriteSchedulingOperations { } export function scheduleSave(owner: WriteSchedulingOperations): void { + owner[writeSchedulingOperationsContext].runtime.automationListProjectionCache = null // Why: once the quit flush has snapshotted, a newly debounced write would fire during // teardown with nothing awaiting it, and the process can exit mid-rename. The quit // flush is the last write by construction. diff --git a/src/main/persistence/scheduling-automations/automation-context-migration.ts b/src/main/persistence/scheduling-automations/automation-context-migration.ts index b5c2fd6845d..0584651c407 100644 --- a/src/main/persistence/scheduling-automations/automation-context-migration.ts +++ b/src/main/persistence/scheduling-automations/automation-context-migration.ts @@ -126,8 +126,15 @@ export function getAutomationContextsForRepo( const projection = projectHostSetupProjectionFromRepos([repo]) const projectedProject = projection.projects[0] const projectedSetup = projection.setups[0] + // Why the host filter first: a repo id can be shared across hosts, and the + // contexts must describe the copy this record resolved to, not a sibling's. + const repoHostId = getRepoExecutionHostId(repo) const setup = - projectHostSetups.find((candidate) => candidate.repoId === repo.id) ?? projectedSetup + projectHostSetups.find( + (candidate) => candidate.repoId === repo.id && candidate.hostId === repoHostId + ) ?? + projectHostSetups.find((candidate) => candidate.repoId === repo.id) ?? + projectedSetup const runContext = setup ? buildWorkspaceRunContext({ projectId: setup.projectId, diff --git a/src/main/persistence/scheduling-automations/automation-definition-operations.ts b/src/main/persistence/scheduling-automations/automation-definition-operations.ts index 801746f91ff..3b06ad7dce9 100644 --- a/src/main/persistence/scheduling-automations/automation-definition-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-definition-operations.ts @@ -7,15 +7,34 @@ import type { import type { PersistedState } from '../../../shared/persisted-state-types' import { normalizeAutomationPrecheck } from '../../../shared/automation-precheck' import { nextAutomationOccurrenceAfter } from '../../../shared/automation-schedules' +import { + applyAutomationExecutionTarget, + deriveAutomationExecutionTargetForCreate, + deriveAutomationExecutionTargetForUpdate +} from '../../../shared/automation-execution-target' +import { + assertAutomationDestination, + assertAutomationOwnerFence, + assertExecutionTargetMatchesDestination, + type AutomationDestination, + type AutomationOwnerPrecondition +} from '../../../shared/automation-owner-precondition' import { getAutomationContextsForRepo, getAutomationSchedulerOwner, normalizeAutomationSessionReuse, normalizeAutomationSetupDecisionForWorkspaceMode } from './automation-context-migration' +import { + automationProjectionContext, + automationWorkspaceSshPin, + sshTargetGenerationForConnection, + type AutomationStorageAuthority +} from './automation-owner-projection' export type AutomationDefinitionOperations = { state: PersistedState + storageAuthority: AutomationStorageAuthority flush: () => void recordCreated: () => void } @@ -28,11 +47,27 @@ export function listAutomations(state: PersistedState): Automation[] { export function createAutomation( operations: AutomationDefinitionOperations, - input: AutomationCreateInput + input: AutomationCreateInput, + options?: { destination?: AutomationDestination } ): Automation { const repo = operations.state.repos.find((entry) => entry.id === input.projectId) const now = Date.now() - const executionTargetType = repo?.connectionId ? 'ssh' : 'local' + const workspaceId = input.workspaceMode === 'existing' ? (input.workspaceId ?? null) : null + const workspaceSshPin = automationWorkspaceSshPin(operations.state, workspaceId) + const executionTarget = deriveAutomationExecutionTargetForCreate({ + repo, + sshTargetGeneration: sshTargetGenerationForConnection(operations.state, repo?.connectionId), + workspaceSshPin + }) + if (options?.destination) { + const context = automationProjectionContext( + operations.state, + operations.storageAuthority, + false + ) + assertAutomationDestination(options.destination, context) + assertExecutionTargetMatchesDestination(executionTarget, options.destination, workspaceSshPin) + } const schedulerOwner = getAutomationSchedulerOwner(repo) const contexts = getAutomationContextsForRepo(repo, operations.state.projectHostSetups ?? []) const automation: Automation = { @@ -41,14 +76,16 @@ export function createAutomation( prompt: input.prompt, precheck: normalizeAutomationPrecheck(input.precheck), agentId: input.agentId, - runContext: input.runContext ?? contexts.runContext, - sourceContext: input.sourceContext ?? contexts.sourceContext, + // Why own contexts win: a wire context speaks the client's perspective — + // 'runtime:' is a client-assigned name this store cannot interpret, and + // persisting it makes the projection orphan a record this authority owns. + runContext: contexts.runContext ?? input.runContext ?? null, + sourceContext: contexts.sourceContext ?? input.sourceContext ?? null, projectId: input.projectId, - executionTargetType, - executionTargetId: executionTargetType === 'ssh' ? (repo?.connectionId ?? '') : 'local', + ...executionTarget, schedulerOwner, workspaceMode: input.workspaceMode, - workspaceId: input.workspaceMode === 'existing' ? (input.workspaceId ?? null) : null, + workspaceId, baseBranch: input.workspaceMode === 'new_per_run' ? (input.baseBranch ?? null) : null, setupDecision: normalizeAutomationSetupDecisionForWorkspaceMode( input.workspaceMode, @@ -74,13 +111,28 @@ export function createAutomation( export function updateAutomation( operations: AutomationDefinitionOperations, id: string, - updates: AutomationUpdateInput + updates: AutomationUpdateInput, + options?: { expectedOwner?: AutomationOwnerPrecondition; destination?: AutomationDestination } ): Automation { const index = (operations.state.automations ?? []).findIndex((entry) => entry.id === id) if (index === -1) { throw new Error('Automation not found.') } const current = operations.state.automations[index] + const projectionContext = automationProjectionContext( + operations.state, + operations.storageAuthority, + false + ) + assertAutomationOwnerFence({ + automation: current, + expectedOwner: options?.expectedOwner, + operation: 'mutate', + context: projectionContext + }) + if (options?.destination) { + assertAutomationDestination(options.destination, projectionContext) + } // Why: the renderer forwards a Partial verbatim, so `{ enabled: undefined }` survives structuredClone // and would blank the stored value in the spread below. Explicit clears go through the `null` branches. const definedUpdates = Object.fromEntries( @@ -88,14 +140,16 @@ export function updateAutomation( ) as AutomationUpdateInput const repoId = updates.projectId ?? current.projectId const repo = operations.state.repos.find((entry) => entry.id === repoId) - const executionTargetType = repo?.connectionId ? 'ssh' : 'local' - const schedulerOwner = getAutomationSchedulerOwner(repo) + const selectorMoveRequested = + updates.projectId !== undefined || options?.destination !== undefined + const schedulerOwner = + selectorMoveRequested && repo ? getAutomationSchedulerOwner(repo) : current.schedulerOwner const contexts = getAutomationContextsForRepo(repo, operations.state.projectHostSetups ?? []) const rrule = updates.rrule ?? current.rrule const dtstart = updates.dtstart ?? current.dtstart const scheduleChanged = updates.rrule !== undefined || updates.dtstart !== undefined const workspaceMode = updates.workspaceMode ?? current.workspaceMode - const updated: Automation = { + const merged: Automation = { ...current, ...definedUpdates, name: updates.name !== undefined ? updates.name.trim() || 'Untitled automation' : current.name, @@ -103,18 +157,21 @@ export function updateAutomation( ? normalizeAutomationPrecheck(definedUpdates.precheck) : normalizeAutomationPrecheck(current.precheck), projectId: repoId, - runContext: Object.hasOwn(definedUpdates, 'runContext') - ? (definedUpdates.runContext ?? null) - : updates.projectId !== undefined - ? contexts.runContext - : (current.runContext ?? contexts.runContext), - sourceContext: Object.hasOwn(definedUpdates, 'sourceContext') - ? (definedUpdates.sourceContext ?? null) - : updates.projectId !== undefined - ? contexts.sourceContext - : (current.sourceContext ?? contexts.sourceContext), - executionTargetType, - executionTargetId: executionTargetType === 'ssh' ? (repo?.connectionId ?? '') : 'local', + // Why the wire object is ignored: contexts are the storing authority's own + // registry speaking. A move restates them from that registry, anything else + // keeps the stored value; an explicit null still clears. + runContext: + definedUpdates.runContext === null + ? null + : selectorMoveRequested + ? (contexts.runContext ?? definedUpdates.runContext ?? null) + : (current.runContext ?? contexts.runContext), + sourceContext: + definedUpdates.sourceContext === null + ? null + : selectorMoveRequested + ? (contexts.sourceContext ?? definedUpdates.sourceContext ?? null) + : (current.sourceContext ?? contexts.sourceContext), schedulerOwner, workspaceMode, workspaceId: @@ -149,12 +206,47 @@ export function updateAutomation( : current.nextRunAt, updatedAt: Date.now() } - operations.state.automations[index] = updated + const previousPin = automationWorkspaceSshPin(operations.state, current.workspaceId) + const workspaceSshPin = automationWorkspaceSshPin(operations.state, merged.workspaceId) + const workspaceSshPinMoved = previousPin?.targetId !== workspaceSshPin?.targetId + const executionTarget = deriveAutomationExecutionTargetForUpdate({ + current, + repo, + selectorMoveRequested, + sshTargetGeneration: sshTargetGenerationForConnection(operations.state, repo?.connectionId), + workspaceSshPin, + workspaceSshPinMoved + }) + if (options?.destination) { + assertExecutionTargetMatchesDestination(executionTarget, options.destination, workspaceSshPin) + } + const updated = applyAutomationExecutionTarget( + merged, + executionTarget, + workspaceSshPinMoved ? undefined : workspaceSshPin + ) + // Replaced, not patched in place: the list projection caches on array identity. + operations.state.automations = operations.state.automations.map((entry) => + entry.id === id ? updated : entry + ) operations.flush() return updated } -export function deleteAutomation(operations: AutomationDefinitionOperations, id: string): void { +export function deleteAutomation( + operations: AutomationDefinitionOperations, + id: string, + options?: { expectedOwner?: AutomationOwnerPrecondition } +): void { + const automation = (operations.state.automations ?? []).find((entry) => entry.id === id) + if (automation) { + assertAutomationOwnerFence({ + automation, + expectedOwner: options?.expectedOwner, + operation: 'mutate', + context: automationProjectionContext(operations.state, operations.storageAuthority, false) + }) + } operations.state.automations = (operations.state.automations ?? []).filter( (entry) => entry.id !== id ) diff --git a/src/main/persistence/scheduling-automations/automation-owner-projection.ts b/src/main/persistence/scheduling-automations/automation-owner-projection.ts new file mode 100644 index 00000000000..c271626d0c0 --- /dev/null +++ b/src/main/persistence/scheduling-automations/automation-owner-projection.ts @@ -0,0 +1,264 @@ +import type { Automation, AutomationRun } from '../../../shared/automations-types' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { + automationCapturedHostIssue as resolveAutomationCapturedHostIssue, + automationSelectorMatchesScope, + projectAutomationList, + projectAutomationSelector, + toAutomationChangeSelector, + type AutomationCapturedHostIssue, + type AutomationChangeSelector, + type AutomationListParams, + type AutomationListResult, + type AutomationProjectionContext +} from '../../../shared/automation-list-scope' +import { + assertAutomationOwnerFence as enforceAutomationOwnerFence, + toAutomationOwnerPrecondition, + type AutomationOwnerFenceOperation, + type AutomationOwnerPrecondition +} from '../../../shared/automation-owner-precondition' +import { + AUTOMATION_OWNER_CONFLICT_CODES, + AutomationOwnerConflictError +} from '../../../shared/automation-owner-conflict' +import { + nextSshTargetGeneration, + resolveSshTargetGenerationHighWaterMark, + sanitizeSshTargetGeneration +} from '../../../shared/ssh-target-generation' +import { + resolveAutomationWorkspaceHost, + resolveAutomationWorkspaceSshTargetId, + type AutomationWorkspaceSshPin +} from '../../../shared/automation-workspace-pin' +import { summarizeAutomationRunUsage } from '../../../shared/automation-usage-summary' +import type { FolderWorkspaceHostState } from '../../../shared/folder-workspace-execution-host' + +export type AutomationStorageAuthority = 'desktop' | 'runtime' + +export type AutomationListProjectionCache = { + inputs: readonly unknown[] + result: AutomationListResult +} + +export function sshTargetGenerationForConnection( + state: PersistedState, + connectionId: string | null | undefined +): number | undefined { + const targetId = connectionId?.trim() + const target = targetId ? state.sshTargets?.find((entry) => entry.id === targetId) : undefined + return sanitizeSshTargetGeneration(target?.generation) +} + +function automationUsageSummaries(state: PersistedState) { + const runsByAutomation = new Map() + for (const run of state.automationRuns ?? []) { + const runs = runsByAutomation.get(run.automationId) + if (runs) { + runs.push(run) + } else { + runsByAutomation.set(run.automationId, [run]) + } + } + return new Map([...runsByAutomation].map(([id, runs]) => [id, summarizeAutomationRunUsage(runs)])) +} + +function folderWorkspaceHostState(state: PersistedState): FolderWorkspaceHostState { + return { + folderWorkspaces: state.folderWorkspaces ?? [], + projectGroups: state.projectGroups ?? [], + repos: state.repos ?? [] + } +} + +export function automationIdsPinnedToSshTarget( + state: PersistedState, + targetId: string +): Set { + const hostState = folderWorkspaceHostState(state) + return new Set( + (state.automations ?? []) + .filter( + (automation) => + resolveAutomationWorkspaceSshTargetId(hostState, automation.workspaceId) === targetId + ) + .map((automation) => automation.id) + ) +} + +export function automationWorkspaceSshPin( + state: PersistedState, + workspaceId: Automation['workspaceId'] +): AutomationWorkspaceSshPin | undefined { + const targetId = resolveAutomationWorkspaceSshTargetId( + folderWorkspaceHostState(state), + workspaceId + ) + return targetId === undefined + ? undefined + : { targetId, generation: sshTargetGenerationForConnection(state, targetId) } +} + +export function automationProjectionContext( + state: PersistedState, + storageAuthority: AutomationStorageAuthority, + withUsage: boolean +): AutomationProjectionContext { + const usage = withUsage ? automationUsageSummaries(state) : null + const reposById = new Map(state.repos.map((repo) => [repo.id, repo])) + const targetIdByGeneration = new Map( + (state.sshTargets ?? []).flatMap((target) => + target.generation === undefined ? [] : [[target.generation, target.id] as const] + ) + ) + return { + storageAuthority, + sshTargetGeneration: (targetId) => sshTargetGenerationForConnection(state, targetId), + sshTargetIdForGeneration: (generation) => targetIdByGeneration.get(generation), + repoConnectionId: (repoId) => { + const repo = reposById.get(repoId) + return repo ? repo.connectionId?.trim() || null : undefined + }, + workspaceHost: (automation) => + resolveAutomationWorkspaceHost(folderWorkspaceHostState(state), automation.workspaceId), + ...(usage ? { usageSummary: (id: string) => usage.get(id) ?? null } : {}) + } +} + +function projectionInputs(state: PersistedState): readonly unknown[] { + return [ + state.automations, + state.automationRuns, + state.repos, + state.sshTargets, + state.folderWorkspaces, + state.projectGroups + ] +} + +export function listAutomationsForScope(input: { + state: PersistedState + storageAuthority: AutomationStorageAuthority + automations: readonly Automation[] + params?: AutomationListParams | null + cache: AutomationListProjectionCache | null +}): { result: AutomationListResult; cache: AutomationListProjectionCache } { + const scope = input.params?.selector + if (scope?.kind === 'ssh') { + const current = sshTargetGenerationForConnection(input.state, scope.targetId) + if (current === undefined) { + throw new AutomationOwnerConflictError(AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved) + } + if (current !== scope.expectedTargetGeneration) { + throw new AutomationOwnerConflictError(AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged) + } + } + const inputs = projectionInputs(input.state) + const complete = + input.cache && inputs.every((entry, index) => entry === input.cache?.inputs[index]) + ? input.cache + : { + inputs, + result: projectAutomationList( + input.automations, + automationProjectionContext(input.state, input.storageAuthority, true) + ) + } + if (!scope) { + return { result: complete.result, cache: complete } + } + const items = complete.result.items.filter((item) => + automationSelectorMatchesScope(item.selector, scope) + ) + const ids = new Set(items.map((item) => item.automationId)) + return { + result: { + automations: complete.result.automations.filter((automation) => ids.has(automation.id)), + items, + orphanCount: complete.result.orphanCount + }, + cache: complete + } +} + +export function automationOwnerPrecondition( + state: PersistedState, + storageAuthority: AutomationStorageAuthority, + id: string +): AutomationOwnerPrecondition | null { + const automation = (state.automations ?? []).find((entry) => entry.id === id) + return automation + ? toAutomationOwnerPrecondition( + projectAutomationSelector( + automation, + automationProjectionContext(state, storageAuthority, false) + ) + ) + : null +} + +export function automationChangeSelector( + state: PersistedState, + storageAuthority: AutomationStorageAuthority, + id: string +): AutomationChangeSelector | null { + const automation = (state.automations ?? []).find((entry) => entry.id === id) + return automation + ? toAutomationChangeSelector( + projectAutomationSelector( + automation, + automationProjectionContext(state, storageAuthority, false) + ) + ) + : null +} + +export function automationCapturedHostIssue( + state: PersistedState, + storageAuthority: AutomationStorageAuthority, + automation: Automation +): AutomationCapturedHostIssue | null { + return resolveAutomationCapturedHostIssue( + automation, + automationProjectionContext(state, storageAuthority, false) + ) +} + +export function assertAutomationOwnerFence(input: { + state: PersistedState + storageAuthority: AutomationStorageAuthority + id: string + expectedOwner?: AutomationOwnerPrecondition + operation: AutomationOwnerFenceOperation +}): Automation { + const automation = (input.state.automations ?? []).find((entry) => entry.id === input.id) + if (!automation) { + throw new Error('Automation not found.') + } + enforceAutomationOwnerFence({ + automation, + expectedOwner: input.expectedOwner, + operation: input.operation, + context: automationProjectionContext(input.state, input.storageAuthority, false) + }) + return automation +} + +export function allocateSshTargetGeneration( + state: PersistedState, + scheduleSave: () => void +): number { + const next = nextSshTargetGeneration( + resolveSshTargetGenerationHighWaterMark({ + persistedCounter: state.sshTargetGenerationCounter, + targetGenerations: (state.sshTargets ?? []).map((target) => target.generation), + capturedGenerations: (state.automations ?? []).map( + (automation) => automation.executionTargetGeneration + ) + }) + ) + state.sshTargetGenerationCounter = next + scheduleSave() + return next +} diff --git a/src/main/persistence/scheduling-automations/automation-run-operations.ts b/src/main/persistence/scheduling-automations/automation-run-operations.ts index d1ebfe31c66..639fda7b836 100644 --- a/src/main/persistence/scheduling-automations/automation-run-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-run-operations.ts @@ -25,6 +25,15 @@ export type AutomationRunOperations = { getWorkspaceDisplayName: (workspaceId: string | null | undefined) => string | null } +function touchAutomation(state: PersistedState, automationId: string, now: number): void { + if (!state.automations.some((entry) => entry.id === automationId)) { + return + } + state.automations = state.automations.map((entry) => + entry.id === automationId ? { ...entry, lastRunAt: now, updatedAt: now } : entry + ) +} + export function listAutomationRuns(state: PersistedState, automationId?: string): AutomationRun[] { const runs = state.automationRuns ?? [] return [...(automationId ? runs.filter((run) => run.automationId === automationId) : runs)] @@ -88,6 +97,43 @@ export function createAutomationRun( return run } +export function recordRepeatedAutomationSkip( + operations: AutomationRunOperations, + automationId: string, + error: string, + scheduledFor: number +): AutomationRun | null { + const runs = operations.state.automationRuns ?? [] + const latest = runs + .filter((run) => run.automationId === automationId) + .reduce( + (newest, run) => (!newest || run.createdAt > newest.createdAt ? run : newest), + null + ) + if ( + !latest || + latest.status !== 'skipped_unavailable' || + latest.trigger !== 'scheduled' || + latest.error !== error + ) { + return null + } + if ((latest.lastOccurrenceAt ?? latest.scheduledFor) === scheduledFor) { + return latest + } + const now = Date.now() + const updated: AutomationRun = { + ...latest, + occurrenceCount: (latest.occurrenceCount ?? 1) + 1, + lastOccurrenceAt: scheduledFor + } + // Replaced, not patched in place: the list projection caches on array identity. + operations.state.automationRuns = runs.map((run) => (run.id === latest.id ? updated : run)) + touchAutomation(operations.state, automationId, now) + operations.flush() + return updated +} + export function updateAutomationRun( operations: AutomationRunOperations, result: AutomationDispatchResult @@ -132,12 +178,11 @@ export function updateAutomationRun( startedAt: current.startedAt ?? now, dispatchedAt: result.status === 'dispatched' ? now : current.dispatchedAt } - operations.state.automationRuns[index] = updated - const automation = operations.state.automations.find((entry) => entry.id === updated.automationId) - if (automation) { - automation.lastRunAt = now - automation.updatedAt = now - } + // Replaced, not patched in place: the list projection caches on array identity. + operations.state.automationRuns = operations.state.automationRuns.map((run) => + run.id === result.runId ? updated : run + ) + touchAutomation(operations.state, updated.automationId, now) operations.flush() return updated } diff --git a/src/main/persistence/scheduling-automations/automation-schedule-operations.ts b/src/main/persistence/scheduling-automations/automation-schedule-operations.ts index 25788a158cb..d1142eb771e 100644 --- a/src/main/persistence/scheduling-automations/automation-schedule-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-schedule-operations.ts @@ -18,7 +18,8 @@ export function advanceAutomationNextRun( const current = state.automations[index] const nextRunAt = nextAutomationOccurrenceAfter(current.rrule, current.dtstart, now) const updated = { ...current, nextRunAt, updatedAt: now } - state.automations[index] = updated + // Replaced, not patched in place: the list projection caches on array identity. + state.automations = state.automations.map((entry) => (entry.id === id ? updated : entry)) flush() return updated } diff --git a/src/main/runtime/automation-change-publication.test.ts b/src/main/runtime/automation-change-publication.test.ts new file mode 100644 index 00000000000..7e6889c2d82 --- /dev/null +++ b/src/main/runtime/automation-change-publication.test.ts @@ -0,0 +1,180 @@ +/** + * A definition change must name the host it affected, and a change that moves a + * record between hosts must name both — a subscriber that never hears about the + * source keeps rendering a row that has left it. + * + * Runs against the real Store so the published selectors are the ones the + * persistence layer actually derives, not fixture stand-ins. The runtime methods + * are the single publication site for every transport (local IPC and remote RPC). + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' +import type { Automation } from '../../shared/automations-types' +import type { Repo } from '../../shared/repo-types' +import type { SshTarget } from '../../shared/ssh-types' +import { getDefaultPersistedState } from '../../shared/constants' +import type { AutomationsChangedPayload } from '../../shared/runtime-client-events' + +const testState = { dir: '' } + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) + +const REPOS = [ + { id: 'repo-local', path: '/local', displayName: 'Local', badgeColor: '#000', addedAt: 1 }, + { + id: 'repo-ssh', + path: '/remote', + displayName: 'Remote', + badgeColor: '#000', + addedAt: 2, + connectionId: 'ssh-1' + } +] as Repo[] + +const TARGETS = [ + { id: 'ssh-1', label: 'Box', host: 'box', port: 22, username: 'me', generation: 7 } +] as SshTarget[] + +function automation(overrides: Partial): Automation { + return { + id: 'local-1', + name: 'Nightly', + prompt: 'go', + precheck: null, + agentId: 'claude', + projectId: 'repo-local', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'new_per_run', + workspaceId: null, + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: 0, + enabled: true, + nextRunAt: 0, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 0, + updatedAt: 0, + ...overrides + } as Automation +} + +const AUTOMATIONS = [ + automation({}), + automation({ + id: 'ssh-1-a', + projectId: 'repo-ssh', + executionTargetType: 'ssh', + executionTargetId: 'ssh-1', + executionTargetGeneration: 7 + }), + automation({ + id: 'orphan-1', + projectId: 'repo-ssh', + executionTargetType: 'ssh', + executionTargetId: 'ssh-gone', + executionTargetGeneration: 3 + }) +] + +async function makeRuntime() { + mkdirSync(testState.dir, { recursive: true }) + writeFileSync( + join(testState.dir, 'orca-data.json'), + JSON.stringify({ + ...getDefaultPersistedState(testState.dir), + repos: REPOS, + sshTargets: TARGETS, + sshTargetGenerationCounter: 7, + automations: AUTOMATIONS + }), + 'utf-8' + ) + vi.resetModules() + const { Store, initDataPath } = await import('../persistence') + initDataPath() + const store = new Store() + const { OrcaRuntimeService } = await import('./orca-runtime') + const runtime = new OrcaRuntimeService(store as never) + const published: AutomationsChangedPayload[] = [] + vi.spyOn(runtime, 'notifyAutomationsChanged').mockImplementation( + (payload: AutomationsChangedPayload = {}) => { + published.push(payload) + } + ) + return { store, runtime, published } +} + +beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'automation-publish-')) + // Why: the store resolves orca-data.json through the app environment, so the + // suite's fixture directory must be what `userData` answers with. + installFakeAppEnvironment({ + getPath: (name) => (name === 'userData' ? testState.dir : tmpdir()) + }) +}) + +afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) +}) + +describe('scoped automationsChanged publication', () => { + it('names the host a delete removed a row from', async () => { + const { runtime, published } = await makeRuntime() + runtime.deleteAutomation('ssh-1-a', { + selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } + }) + expect(published).toEqual([ + { reason: 'definition', selector: { kind: 'ssh', targetId: 'ssh-1' } } + ]) + }) + + it('names the orphan bucket when an unowned row is deleted', async () => { + const { runtime, published } = await makeRuntime() + runtime.deleteAutomation('orphan-1', { selector: { kind: 'orphan' } }) + expect(published).toEqual([{ reason: 'definition', selector: { kind: 'orphan' } }]) + }) + + it('publishes source and destination when an update moves a record between hosts', async () => { + const { runtime, published, store } = await makeRuntime() + await runtime.updateAutomation( + 'local-1', + { repo: 'repo-ssh' }, + { + expectedOwner: { selector: { kind: 'self' } }, + destination: { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } } + } + ) + expect(published).toEqual([ + { reason: 'definition', selector: { kind: 'self' } }, + { reason: 'definition', selector: { kind: 'ssh', targetId: 'ssh-1' } } + ]) + expect(store.automationChangeSelector('local-1')).toEqual({ + kind: 'ssh', + targetId: 'ssh-1' + }) + }) + + it('publishes one event when an update leaves the record on the same host', async () => { + const { runtime, published } = await makeRuntime() + await runtime.updateAutomation( + 'local-1', + { enabled: false }, + { expectedOwner: { selector: { kind: 'self' } } } + ) + expect(published).toEqual([{ reason: 'definition', selector: { kind: 'self' } }]) + }) +}) diff --git a/src/main/runtime/orca-runtime-automations.test.ts b/src/main/runtime/orca-runtime-automations.test.ts index 25741043206..9c2ea907c91 100644 --- a/src/main/runtime/orca-runtime-automations.test.ts +++ b/src/main/runtime/orca-runtime-automations.test.ts @@ -97,7 +97,9 @@ describe('OrcaRuntimeService automation methods', () => { workspaceMode: 'new_per_run', workspaceId: null, setupDecision: 'skip' - }) + }), + // Owner options are absent on an unfenced RPC create; the arg still rides. + undefined ) expect(automation.id).toBe('auto-1') }) @@ -187,14 +189,14 @@ describe('OrcaRuntimeService automation methods', () => { const updated = await runtime.updateAutomation('auto-1', { enabled: false }) const removed = runtime.deleteAutomation('auto-1') - expect(store.updateAutomation).toHaveBeenCalledWith('auto-1', { enabled: false }) + expect(store.updateAutomation).toHaveBeenCalledWith('auto-1', { enabled: false }, undefined) expect(updated).toMatchObject({ prompt: existingAutomation.prompt, baseBranch: existingAutomation.baseBranch, workspaceMode: existingAutomation.workspaceMode, enabled: false }) - expect(store.deleteAutomation).toHaveBeenCalledWith('auto-1') + expect(store.deleteAutomation).toHaveBeenCalledWith('auto-1', undefined) expect(removed).toEqual({ removed: true, id: 'auto-1' }) }) @@ -204,7 +206,7 @@ describe('OrcaRuntimeService automation methods', () => { await runtime.updateAutomation('auto-1', { baseBranch: null }) - expect(store.updateAutomation).toHaveBeenCalledWith('auto-1', { baseBranch: null }) + expect(store.updateAutomation).toHaveBeenCalledWith('auto-1', { baseBranch: null }, undefined) }) it('passes setup decision updates through the shared store', async () => { @@ -213,7 +215,11 @@ describe('OrcaRuntimeService automation methods', () => { await runtime.updateAutomation('auto-1', { setupDecision: 'run' }) - expect(store.updateAutomation).toHaveBeenCalledWith('auto-1', { setupDecision: 'run' }) + expect(store.updateAutomation).toHaveBeenCalledWith( + 'auto-1', + { setupDecision: 'run' }, + undefined + ) }) it('passes session reuse updates for existing-workspace automations', async () => { @@ -228,7 +234,7 @@ describe('OrcaRuntimeService automation methods', () => { await runtime.updateAutomation('auto-1', { reuseSession: true }) - expect(store.updateAutomation).toHaveBeenCalledWith('auto-1', { reuseSession: true }) + expect(store.updateAutomation).toHaveBeenCalledWith('auto-1', { reuseSession: true }, undefined) }) it('clears session reuse when retargeting to new-per-run', async () => { @@ -254,7 +260,8 @@ describe('OrcaRuntimeService automation methods', () => { workspaceMode: 'new_per_run', workspaceId: null, reuseSession: false - }) + }), + undefined ) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 046940420a1..b86135d76a3 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -264,6 +264,17 @@ import type { AutomationUpdateInput, AutomationWorkspaceMode } from '../../shared/automations-types' +import { + automationChangePublications, + type AutomationChangeSelector, + type AutomationListParams, + type AutomationListResult +} from '../../shared/automation-list-scope' +import type { + AutomationDestination, + AutomationOwnerFenceOperation, + AutomationOwnerPrecondition +} from '../../shared/automation-owner-precondition' import type { DirEntry, FilesystemPathFlavor } from '../../shared/filesystem-entry-types' import type { FolderWorkspace, WorkspaceKey } from '../../shared/folder-workspace-types' import type { @@ -385,7 +396,10 @@ import { screencastSubscriberDrivesAsMobile, type BrowserScreencastSubscriber } from './browser-screencast-driver-scope' -import type { RuntimeClientEvent } from '../../shared/runtime-client-events' +import type { + AutomationsChangedPayload, + RuntimeClientEvent +} from '../../shared/runtime-client-events' import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events' import { navigationTargetsClients, @@ -668,6 +682,7 @@ import { } from '../ports/workspace-port-ownership' import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' import type { AutomationService } from '../automations/service' +import { runAutomationNowFenced } from '../automations/refused-manual-run' import type { RuntimeBrowserCommands } from './orca-runtime-browser' import { createRuntimeBrowserCommands, @@ -1351,6 +1366,10 @@ type RuntimeStore = { updateUI?: Store['updateUI'] recordFeatureInteraction?: Store['recordFeatureInteraction'] listAutomations?: Store['listAutomations'] + listAutomationsForScope?: Store['listAutomationsForScope'] + assertAutomationOwnerFence?: Store['assertAutomationOwnerFence'] + automationOwnerPrecondition?: Store['automationOwnerPrecondition'] + automationChangeSelector?: Store['automationChangeSelector'] listAutomationRuns?: Store['listAutomationRuns'] createAutomation?: Store['createAutomation'] updateAutomation?: Store['updateAutomation'] @@ -1418,6 +1437,7 @@ export type RuntimeAutomationCreateInput = Omit< workspace?: string workspaceMode?: AutomationWorkspaceMode timezone?: string + destination?: AutomationDestination } export type RuntimeAutomationUpdateInput = Omit< @@ -2215,6 +2235,7 @@ type RuntimeNotifier = { worktreeBaseStatus?(event: WorktreeBaseStatusEvent): void worktreeRemoteBranchConflict?(event: WorktreeRemoteBranchConflictEvent): void reposChanged(): void + automationsChanged?(payload: AutomationsChangedPayload): void activateWorktree( repoId: string, worktreeId: string, @@ -4225,18 +4246,67 @@ export class OrcaRuntimeService { return this.store.listAutomations() } - listAutomationRuns(automationId?: string): AutomationRun[] { - if (!this.store?.listAutomationRuns) { - throw new Error('runtime_unavailable') - } - return this.store.listAutomationRuns(automationId) + // Why: Orca's own automation work holds the desktop probe scheduler's + // priority lease so queued external probes stay parked behind it; runtime + // servers install no lease and run directly. + private underExternalProbePriority(run: () => T): T { + const wrap = this.automationService?.externalProbePriority + return wrap ? wrap(run) : run() } - showAutomation(id: string): Automation { + listAutomationsForScope(params: AutomationListParams): AutomationListResult { + const store = this.store + if (!store?.listAutomationsForScope) { + throw new Error('runtime_unavailable') + } + return this.underExternalProbePriority(() => store.listAutomationsForScope!(params)) + } + + // Why: a supplied precondition must never degrade to unfenced work, so a store that cannot check it fails the call. + private fenceAutomationOwner( + id: string, + expectedOwner: AutomationOwnerPrecondition | undefined, + operation: AutomationOwnerFenceOperation + ): void { + if (!this.store?.assertAutomationOwnerFence) { + if (expectedOwner) { + throw new Error('runtime_unavailable') + } + return + } + this.store.assertAutomationOwnerFence({ id, expectedOwner, operation }) + } + + listAutomationRuns( + automationId?: string, + expectedOwner?: AutomationOwnerPrecondition + ): AutomationRun[] { + const store = this.store + if (!store?.listAutomationRuns) { + throw new Error('runtime_unavailable') + } + if (expectedOwner && !automationId) { + throw new Error('An expected owner requires an automation id.') + } + return this.underExternalProbePriority(() => { + if (automationId) { + this.fenceAutomationOwner(automationId, expectedOwner, 'read') + } + return store.listAutomationRuns!(automationId) + }) + } + + /** Null when the store predates the projection: the caller then sends no precondition. */ + automationOwnerPrecondition(id: string): AutomationOwnerPrecondition | null { + return this.store?.automationOwnerPrecondition?.(id) ?? null + } + + showAutomation(id: string, expectedOwner?: AutomationOwnerPrecondition): Automation { const automation = this.listAutomations().find((entry) => entry.id === id) if (!automation) { throw new Error('Automation not found.') } + this.fenceAutomationOwner(id, expectedOwner, 'read') return automation } @@ -4249,7 +4319,7 @@ export class OrcaRuntimeService { if (input.reuseSession && target.workspaceMode !== 'existing') { throw new Error('Session reuse requires an existing workspace target.') } - return this.store.createAutomation({ + const createInput: AutomationCreateInput = { name: input.name, prompt: input.prompt, precheck: input.precheck, @@ -4267,10 +4337,37 @@ export class OrcaRuntimeService { dtstart: input.dtstart, enabled: input.enabled, missedRunGraceMinutes: input.missedRunGraceMinutes + } + return this.underExternalProbePriority(() => { + const created = this.store!.createAutomation!( + createInput, + input.destination ? { destination: input.destination } : undefined + ) + const selector = this.automationChangeSelector(created.id) + this.publishAutomationDefinitionChange(selector, selector) + return created }) } - async updateAutomation(id: string, updates: RuntimeAutomationUpdateInput): Promise { + private automationChangeSelector(id: string): AutomationChangeSelector | null { + return this.store?.automationChangeSelector?.(id) ?? null + } + + /** A store that cannot name the affected host degrades to one unscoped authority event. */ + private publishAutomationDefinitionChange( + before: AutomationChangeSelector | null, + after: AutomationChangeSelector | null + ): void { + for (const selector of automationChangePublications(before, after)) { + this.notifyAutomationsChanged({ reason: 'definition', ...(selector ? { selector } : {}) }) + } + } + + async updateAutomation( + id: string, + updates: RuntimeAutomationUpdateInput, + options?: { expectedOwner?: AutomationOwnerPrecondition; destination?: AutomationDestination } + ): Promise { if (!this.store?.updateAutomation) { throw new Error('runtime_unavailable') } @@ -4341,23 +4438,49 @@ export class OrcaRuntimeService { if (!targetChanged && patch.reuseSession && current.workspaceMode !== 'existing') { throw new Error('Session reuse requires an existing workspace target.') } - return this.store.updateAutomation(id, patch) + return this.underExternalProbePriority(() => { + // Captured first: an update may move the record to another host. + const before = this.automationChangeSelector(id) + const updated = this.store!.updateAutomation!(id, patch, options) + this.publishAutomationDefinitionChange(before, this.automationChangeSelector(id)) + return updated + }) } - deleteAutomation(id: string): { removed: boolean; id: string } { + deleteAutomation( + id: string, + expectedOwner?: AutomationOwnerPrecondition + ): { removed: boolean; id: string } { if (!this.store?.deleteAutomation) { throw new Error('runtime_unavailable') } - this.showAutomation(id) - this.store.deleteAutomation(id) - return { removed: true, id } + return this.underExternalProbePriority(() => { + this.showAutomation(id) + const before = this.automationChangeSelector(id) + this.store!.deleteAutomation!(id, expectedOwner ? { expectedOwner } : undefined) + this.publishAutomationDefinitionChange(before, before) + return { removed: true, id } + }) } - async runAutomationNow(id: string): Promise { - if (!this.automationService) { + async runAutomationNow( + id: string, + expectedOwner?: AutomationOwnerPrecondition + ): Promise { + const service = this.automationService + if (!service) { throw new Error('runtime_unavailable') } - return await this.automationService.runNow(id) + // Why: an orphan or re-registered host must be refused before dispatch, not + // after a session starts. The lease spans the whole dispatch promise, so + // queued external probes stay parked until the run the user is waiting on settles. + return await this.underExternalProbePriority(() => + runAutomationNowFenced({ + fence: () => this.fenceAutomationOwner(id, expectedOwner, 'execute'), + service, + automationId: id + }) + ) } private async resolveAutomationTarget( @@ -6658,6 +6781,14 @@ export class OrcaRuntimeService { this.emitClientEvent({ type: 'reposChanged' }) } + // Why: automation writes land in the automation service and IPC handlers, so + // like SSH state they need a public entry point onto the client-event stream. + // Old clients drop the unknown event type; nothing is negotiated for it. + notifyAutomationsChanged(payload: AutomationsChangedPayload = {}): void { + this.notifier?.automationsChanged?.(payload) + this.emitClientEvent({ type: 'automationsChanged', ...payload }) + } + // Why: SSH state changes originate in main's ssh handlers, not in runtime // methods, so they need a public entry point onto the client-event stream. notifySshStateChanged(targetId: string, state: SshConnectionState): void { @@ -35061,7 +35192,9 @@ export class OrcaRuntimeService { return Date.now() - completedAtMs <= AGENT_STATUS_STALE_AFTER_MS ? dispatch : undefined } - private getTerminalHandleForPaneKey(paneKey: string): string | null { + // Why: public because automation completion watching runs in main but the + // pane→handle mapping is runtime-owned state. + getTerminalHandleForPaneKey(paneKey: string): string | null { const parsed = parsePaneKey(paneKey) const leaf = parsed ? this.leaves.get(this.getLeafKey(parsed.tabId, parsed.leafId)) : undefined if (leaf?.ptyId && leaf.connected) { diff --git a/src/main/runtime/rpc/errors.test.ts b/src/main/runtime/rpc/errors.test.ts index 190a5b88be5..de30a1f4547 100644 --- a/src/main/runtime/rpc/errors.test.ts +++ b/src/main/runtime/rpc/errors.test.ts @@ -5,6 +5,10 @@ import { ARTIFACT_SHARING_DISABLED_MESSAGE, ArtifactSharingDisabledError } from '../../../shared/artifact-sharing-gate' +import { + AUTOMATION_OWNER_CONFLICT_CODES, + AutomationOwnerConflictError +} from '../../../shared/automation-owner-conflict' class LineageError extends Error { code = 'LINEAGE_PARENT_NOT_FOUND' @@ -228,3 +232,21 @@ describe('artifact sharing denial', () => { }) }) }) + +// Why: the renderer decides between "reload the host", "re-adopt", and "stop offering +// the action" from this code; flattened to runtime_error it can only guess. +describe('automation owner conflicts', () => { + it.each(Object.values(AUTOMATION_OWNER_CONFLICT_CODES))( + 'passes %s through structured', + (code) => { + expect( + mapRuntimeError('req_1', { runtimeId: 'runtime-1' }, new AutomationOwnerConflictError(code)) + ).toMatchObject({ ok: false, error: { code } }) + } + ) + + it('still lets an old runtime be classified from the message tail', () => { + const error = new AutomationOwnerConflictError(AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged) + expect(error.message.endsWith(`: ${AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged}`)).toBe(true) + }) +}) diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index 0b083a64d61..f4b4768865b 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -21,6 +21,7 @@ import { classifySkillInstallFailureCode } from '../../../shared/skill-install-failure' import { GIT_DIFF_TOO_LARGE_CODE } from '../../../shared/git-diff-transport-budget' +import { AUTOMATION_OWNER_CONFLICT_CODES } from '../../../shared/automation-owner-conflict' export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess { return { @@ -125,7 +126,10 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ AGENT_SKILL_SELECTOR_NOT_FOUND_CODE, AGENT_SKILL_SHARING_BUSY_CODE, AGENT_SKILL_SHARING_UNSUPPORTED_ENVIRONMENT_CODE, - SKILL_INSTALL_RPC_ERROR_CODE + SKILL_INSTALL_RPC_ERROR_CODE, + // Why: an owner conflict is a distinct client decision (reload the host, re-adopt, + // stop offering the action) — flattened to runtime_error it can only be guessed at. + ...Object.values(AUTOMATION_OWNER_CONFLICT_CODES) ]) export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknown): RpcFailure { diff --git a/src/main/runtime/rpc/methods/agent-skill-sharing-capability-grant.test.ts b/src/main/runtime/rpc/methods/agent-skill-sharing-capability-grant.test.ts index 24da0c6b138..c5e089082bd 100644 --- a/src/main/runtime/rpc/methods/agent-skill-sharing-capability-grant.test.ts +++ b/src/main/runtime/rpc/methods/agent-skill-sharing-capability-grant.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' import { OrcaRuntimeService } from '../../orca-runtime' -import { SettingsUpdate } from './client-ui-schemas' +import { SettingsUpdate } from './client-settings-schemas' vi.mock('electron', () => ({ app: { getPath: () => '/orca-state', isPackaged: true } diff --git a/src/main/runtime/rpc/methods/artifact-sharing-capability-grant.test.ts b/src/main/runtime/rpc/methods/artifact-sharing-capability-grant.test.ts index 7c341efcfa8..1f7ad45a2fb 100644 --- a/src/main/runtime/rpc/methods/artifact-sharing-capability-grant.test.ts +++ b/src/main/runtime/rpc/methods/artifact-sharing-capability-grant.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' import { OrcaRuntimeService } from '../../orca-runtime' -import { SettingsUpdate } from './client-ui-schemas' +import { SettingsUpdate } from './client-settings-schemas' function runtimeWithSharing(artifactSharingEnabled: unknown): OrcaRuntimeService { return new OrcaRuntimeService({ diff --git a/src/main/runtime/rpc/methods/automation-schemas.ts b/src/main/runtime/rpc/methods/automation-schemas.ts new file mode 100644 index 00000000000..919a251d26d --- /dev/null +++ b/src/main/runtime/rpc/methods/automation-schemas.ts @@ -0,0 +1,190 @@ +// Why: the automation method table stays readable only if its field-level validation lives beside it rather than inside it. +import { z } from 'zod' +import { isValidAutomationSchedule } from '../../../../shared/automation-schedules' +import { + MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, + normalizeAutomationPrecheckTimeoutSeconds +} from '../../../../shared/automation-precheck' +import { normalizeExecutionHostId } from '../../../../shared/execution-host' +import type { TaskProviderIdentity as SharedTaskProviderIdentity } from '../../../../shared/task-source-context' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { + OptionalBoolean, + OptionalPlainString, + OptionalPositiveInt, + OptionalString, + requiredNumber, + requiredString +} from '../schemas' + +const TuiAgent = requiredString('Missing provider').refine(isTuiAgent, { + message: 'Unknown provider' +}) + +const AutomationWorkspaceMode = z.enum(['existing', 'new_per_run']).optional() +const SetupDecision = z.enum(['inherit', 'run', 'skip']).optional() +const ExecutionHostId = requiredString('Missing host id').transform((value, ctx) => { + const hostId = normalizeExecutionHostId(value) + if (!hostId) { + ctx.addIssue({ code: 'custom', message: 'Invalid host id' }) + return z.NEVER + } + return hostId +}) + +const AutomationSchedule = requiredString('Missing trigger').refine(isValidAutomationSchedule, { + message: 'Invalid automation trigger' +}) + +const AutomationPrecheck = z + .object({ + command: requiredString('Missing precheck command'), + timeoutSeconds: OptionalPositiveInt.transform((value) => + normalizeAutomationPrecheckTimeoutSeconds(value) + ).refine((value) => value <= MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, { + message: 'Precheck timeout is too large' + }) + }) + .nullable() + .optional() + +const OptionalNullablePlainString = z + .unknown() + .transform((value) => (value === null || typeof value === 'string' ? value : undefined)) + .pipe(z.union([z.string(), z.null(), z.undefined()])) + .optional() + +const TaskProviderIdentity = z + .custom( + (value) => + value !== null && + typeof value === 'object' && + 'provider' in value && + ['github', 'gitlab', 'linear', 'jira'].includes(String(value.provider)) + ) + .optional() + .nullable() + +const TaskSourceContext = z + .object({ + kind: z.literal('task-source'), + provider: z.enum(['github', 'gitlab', 'linear', 'jira']), + projectId: requiredString('Missing source project id'), + hostId: ExecutionHostId, + projectHostSetupId: OptionalNullablePlainString, + repoId: OptionalNullablePlainString, + providerIdentity: TaskProviderIdentity, + accountLabel: OptionalNullablePlainString + }) + .optional() + .nullable() + +const WorkspaceRunContext = z + .object({ + kind: z.literal('workspace-run'), + projectId: requiredString('Missing run project id'), + hostId: ExecutionHostId, + projectHostSetupId: requiredString('Missing project host setup id'), + repoId: requiredString('Missing repo id'), + path: requiredString('Missing run path') + }) + .optional() + .nullable() + +const SshTargetGeneration = requiredNumber('Missing SSH target generation').refine( + (value) => Number.isSafeInteger(value) && value >= 1, + { message: 'Invalid SSH target generation' } +) + +const OwnedSshSelector = z.object({ + kind: z.literal('ssh'), + targetId: requiredString('Missing SSH target id'), + targetGeneration: SshTargetGeneration +}) + +/** Orphan is accepted here, unlike a destination: a record with no executable host is still deletable. */ +const OwnerPreconditionSelector = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('self') }), + OwnedSshSelector, + z.object({ kind: z.literal('orphan') }) +]) + +const DestinationSelector = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('self') }), + OwnedSshSelector +]) + +export const ExpectedOwner = z.object({ selector: OwnerPreconditionSelector }).optional() +export const Destination = z.object({ selector: DestinationSelector }).optional() + +const ListScopeSelector = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('self') }), + z.object({ + kind: z.literal('ssh'), + targetId: requiredString('Missing SSH target id'), + expectedTargetGeneration: SshTargetGeneration + }), + z.object({ kind: z.literal('orphan') }) +]) + +/** An omitted selector is the legacy request; old clients keep the authority's complete list. */ +export const AutomationList = z.object({ selector: ListScopeSelector.optional() }) + +export const AutomationId = z.object({ + id: requiredString('Missing automation id'), + expectedOwner: ExpectedOwner +}) + +export const AutomationRuns = z.object({ + automationId: OptionalString, + expectedOwner: ExpectedOwner +}) + +export const AutomationCreate = z.object({ + name: requiredString('Missing automation name'), + prompt: requiredString('Missing automation prompt'), + precheck: AutomationPrecheck, + agentId: TuiAgent, + runContext: WorkspaceRunContext, + sourceContext: TaskSourceContext, + repo: OptionalString, + workspace: OptionalString, + workspaceMode: AutomationWorkspaceMode, + baseBranch: OptionalPlainString, + setupDecision: SetupDecision, + reuseSession: OptionalBoolean, + timezone: OptionalString, + rrule: AutomationSchedule, + dtstart: requiredNumber('Missing trigger start time'), + enabled: OptionalBoolean, + missedRunGraceMinutes: OptionalPositiveInt, + destination: Destination +}) + +const AutomationUpdateFields = z.object({ + name: OptionalString, + prompt: OptionalString, + precheck: AutomationPrecheck, + agentId: TuiAgent.optional(), + runContext: WorkspaceRunContext, + sourceContext: TaskSourceContext, + repo: OptionalString, + workspace: OptionalString, + workspaceMode: AutomationWorkspaceMode, + // Why: update patches distinguish omitted from null so callers can clear a saved base branch. + baseBranch: OptionalNullablePlainString, + setupDecision: SetupDecision, + reuseSession: OptionalBoolean, + timezone: OptionalString, + rrule: AutomationSchedule.optional(), + dtstart: requiredNumber('Missing trigger start time').optional(), + enabled: OptionalBoolean, + missedRunGraceMinutes: OptionalPositiveInt +}) + +export const AutomationUpdate = z.object({ + id: requiredString('Missing automation id'), + updates: AutomationUpdateFields, + expectedOwner: ExpectedOwner, + destination: Destination +}) diff --git a/src/main/runtime/rpc/methods/automation-scoped-list-methods.test.ts b/src/main/runtime/rpc/methods/automation-scoped-list-methods.test.ts new file mode 100644 index 00000000000..f1d2081dc6b --- /dev/null +++ b/src/main/runtime/rpc/methods/automation-scoped-list-methods.test.ts @@ -0,0 +1,234 @@ +/** + * The runtime end of the list contract: an old client that sends no params must + * keep receiving the authority's complete list through the legacy field while + * current callers also receive owner metadata. + */ +import { describe, expect, it, vi } from 'vitest' +import type { RpcContext, RpcRequest } from '../core' +import { RpcDispatcher } from '../dispatcher' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { AUTOMATION_METHODS } from './automations' +import { AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' + +function method(name: string) { + const found = AUTOMATION_METHODS.find((entry) => entry.name === name) + if (!found?.params) { + throw new Error(`missing method ${name}`) + } + return found +} + +function runtimeStub() { + return { + listAutomations: vi.fn(() => [{ id: 'a1' }, { id: 'a2' }]), + listAutomationsForScope: vi.fn((params?: { selector?: { kind: string } }) => + params?.selector + ? { + automations: [{ id: 'a1' }], + items: [{ automationId: 'a1', selector: { kind: 'self' } }], + orphanCount: 2 + } + : { + automations: [{ id: 'a1' }, { id: 'a2' }], + items: [ + { automationId: 'a1', selector: { kind: 'self' } }, + { automationId: 'a2', selector: { kind: 'orphan', issue: 'missing' } } + ], + orphanCount: 1 + } + ), + listAutomationRuns: vi.fn(() => []), + showAutomation: vi.fn(() => ({ id: 'a1' })), + automationOwnerPrecondition: vi.fn(() => SSH_OWNER), + updateAutomation: vi.fn(async () => ({ id: 'a1' })), + deleteAutomation: vi.fn(() => ({ removed: true, id: 'a1' })), + runAutomationNow: vi.fn(async () => ({ id: 'run-1' })) + } +} + +async function invoke( + name: string, + params: unknown, + runtime: ReturnType, + context: Pick = {} +) { + const target = method(name) + // Why: mirrors the dispatcher, which turns omitted params into an empty object before parsing. + const parsed = target.params?.safeParse(params ?? {}) + if (!parsed?.success) { + throw parsed?.error + } + return await target.handler(parsed.data, { + runtime: runtime as unknown as OrcaRuntimeService, + ...context + } as RpcContext) +} + +const SSH_OWNER = { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } } + +describe('automation.list', () => { + it('answers a parameterless request with owner metadata and the legacy field', async () => { + const runtime = runtimeStub() + expect(await invoke('automation.list', undefined, runtime)).toMatchObject({ + automations: [{ id: 'a1' }, { id: 'a2' }], + items: [{ automationId: 'a1' }, { automationId: 'a2' }] + }) + expect(runtime.listAutomationsForScope).toHaveBeenCalledWith({}) + }) + + it('treats an empty object the same as no params', async () => { + const runtime = runtimeStub() + expect(await invoke('automation.list', {}, runtime)).toMatchObject({ + automations: [{ id: 'a1' }, { id: 'a2' }], + items: [{ automationId: 'a1' }, { automationId: 'a2' }] + }) + }) + + it('answers a scoped request with items and the orphan count', async () => { + const runtime = runtimeStub() + const result = await invoke('automation.list', { selector: { kind: 'self' } }, runtime) + expect(runtime.listAutomationsForScope).toHaveBeenCalledWith({ selector: { kind: 'self' } }) + expect(result).toMatchObject({ orphanCount: 2, items: [{ automationId: 'a1' }] }) + }) + + it('requires an SSH scope to name the generation it expects', async () => { + const runtime = runtimeStub() + await expect( + invoke('automation.list', { selector: { kind: 'ssh', targetId: 'ssh-1' } }, runtime) + ).rejects.toBeTruthy() + }) +}) + +/** + * The other two cases go through `invoke`, which normalizes params itself — so + * literal `null` has to be proved through the real dispatcher instead. An old + * client that serializes an absent params field as `null` is the case the + * legacy fallback rests on, and only the dispatcher decides what it becomes. + */ +describe('automation.list from a client that sends literal null params', () => { + it('answers with the complete authority list, not an invalid-argument error', async () => { + const runtime = runtimeStub() + const dispatcher = new RpcDispatcher({ + runtime: { ...runtime, getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService, + methods: AUTOMATION_METHODS + }) + const request: RpcRequest = { + id: 'req-1', + authToken: 'tok', + method: 'automation.list', + params: null + } + + const response = await dispatcher.dispatch(request) + + expect(response).toMatchObject({ result: { automations: [{ id: 'a1' }, { id: 'a2' }] } }) + expect(runtime.listAutomationsForScope).toHaveBeenCalledWith({}) + }) +}) + +describe('owner preconditions', () => { + it('forwards the expected owner on show, runs, delete, and runNow', async () => { + const runtime = runtimeStub() + await invoke('automation.show', { id: 'a1', expectedOwner: SSH_OWNER }, runtime) + await invoke('automation.runs', { automationId: 'a1', expectedOwner: SSH_OWNER }, runtime) + await invoke('automation.delete', { id: 'a1', expectedOwner: SSH_OWNER }, runtime) + await invoke('automation.runNow', { id: 'a1', expectedOwner: SSH_OWNER }, runtime) + expect(runtime.showAutomation).toHaveBeenCalledWith('a1', SSH_OWNER) + expect(runtime.listAutomationRuns).toHaveBeenCalledWith('a1', SSH_OWNER) + expect(runtime.deleteAutomation).toHaveBeenCalledWith('a1', SSH_OWNER) + expect(runtime.runAutomationNow).toHaveBeenCalledWith('a1', SSH_OWNER) + }) + + // Why: a client with no SSH target registry of its own — the CLI — can only satisfy + // the fence by echoing back an owner the authority projected for it. + it('returns the projected owner beside the automation on show', async () => { + const runtime = runtimeStub() + expect(await invoke('automation.show', { id: 'a1' }, runtime)).toEqual({ + automation: { id: 'a1' }, + owner: SSH_OWNER + }) + }) + + it('omits the owner rather than inventing one when the store cannot project it', async () => { + const runtime = runtimeStub() + runtime.automationOwnerPrecondition.mockReturnValue(null as never) + expect(await invoke('automation.show', { id: 'a1' }, runtime)).toEqual({ + automation: { id: 'a1' } + }) + }) + + it('forwards both sides of a selector-moving update', async () => { + const runtime = runtimeStub() + await invoke( + 'automation.update', + { + id: 'a1', + updates: { enabled: false }, + expectedOwner: SSH_OWNER, + destination: { selector: { kind: 'self' } } + }, + runtime + ) + expect(runtime.updateAutomation).toHaveBeenCalledWith( + 'a1', + expect.objectContaining({ enabled: false }), + { expectedOwner: SSH_OWNER, destination: { selector: { kind: 'self' } } } + ) + }) + + it('accepts an orphan precondition but never an orphan destination', async () => { + const runtime = runtimeStub() + await invoke( + 'automation.delete', + { id: 'a1', expectedOwner: { selector: { kind: 'orphan' } } }, + runtime + ) + expect(runtime.deleteAutomation).toHaveBeenCalledWith('a1', { + selector: { kind: 'orphan' } + }) + await expect( + invoke( + 'automation.update', + { id: 'a1', updates: {}, destination: { selector: { kind: 'orphan' } } }, + runtime + ) + ).rejects.toBeTruthy() + }) + + it('rejects an SSH precondition with no registration generation', async () => { + const runtime = runtimeStub() + await expect( + invoke( + 'automation.delete', + { id: 'a1', expectedOwner: { selector: { kind: 'ssh', targetId: 'ssh-1' } } }, + runtime + ) + ).rejects.toBeTruthy() + }) + + it('leaves the parameterless old-client shapes valid', async () => { + const runtime = runtimeStub() + await invoke('automation.update', { id: 'a1', updates: { enabled: false } }, runtime, { + clientCapabilities: [] + }) + await invoke('automation.delete', { id: 'a1' }, runtime, { clientCapabilities: [] }) + await invoke('automation.runNow', { id: 'a1' }, runtime, { clientCapabilities: [] }) + await invoke('automation.runs', {}, runtime) + expect(runtime.updateAutomation).toHaveBeenCalledWith( + 'a1', + expect.objectContaining({ enabled: false }), + { expectedOwner: SSH_OWNER, destination: undefined } + ) + expect(runtime.deleteAutomation).toHaveBeenCalledWith('a1', SSH_OWNER) + expect(runtime.runAutomationNow).toHaveBeenCalledWith('a1', SSH_OWNER) + expect(runtime.listAutomationRuns).toHaveBeenCalledWith(undefined, undefined) + }) + + it('keeps missing owner metadata fenced for a current remote client', async () => { + const runtime = runtimeStub() + await invoke('automation.delete', { id: 'a1' }, runtime, { + clientCapabilities: [AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY] + }) + expect(runtime.deleteAutomation).toHaveBeenCalledWith('a1', undefined) + }) +}) diff --git a/src/main/runtime/rpc/methods/automations.test.ts b/src/main/runtime/rpc/methods/automations.test.ts index c5a8cf37440..d972bf6a553 100644 --- a/src/main/runtime/rpc/methods/automations.test.ts +++ b/src/main/runtime/rpc/methods/automations.test.ts @@ -13,6 +13,10 @@ describe('automation RPC methods', () => { const runtime = { getRuntimeId: () => 'test-runtime', listAutomations: vi.fn().mockReturnValue([{ id: 'auto-1', name: 'Daily review' }]), + listAutomationsForScope: vi.fn().mockReturnValue({ + automations: [{ id: 'auto-1', name: 'Daily review' }], + items: [{ automationId: 'auto-1', selector: { kind: 'self' } }] + }), showAutomation: vi.fn().mockReturnValue({ id: 'auto-1', name: 'Daily review' }), createAutomation: vi.fn().mockResolvedValue({ id: 'auto-2', name: 'New review' }), updateAutomation: vi.fn().mockResolvedValue({ id: 'auto-1', name: 'Paused' }), @@ -70,8 +74,9 @@ describe('automation RPC methods', () => { await dispatcher.dispatch(makeRequest('automation.runNow', { id: 'auto-1' })) await dispatcher.dispatch(makeRequest('automation.runs', { automationId: 'auto-1' })) - expect(runtime.listAutomations).toHaveBeenCalled() - expect(runtime.showAutomation).toHaveBeenCalledWith('auto-1') + expect(runtime.listAutomationsForScope).toHaveBeenCalledWith({}) + // A legacy client sends no precondition, so each call forwards an absent expected owner. + expect(runtime.showAutomation).toHaveBeenCalledWith('auto-1', undefined) expect(runtime.createAutomation).toHaveBeenCalledWith( expect.objectContaining({ name: 'New review', @@ -92,11 +97,12 @@ describe('automation RPC methods', () => { setupDecision: 'run', reuseSession: false, rrule: '0 9 * * 1-5' - }) + }), + { expectedOwner: undefined, destination: undefined } ) - expect(runtime.deleteAutomation).toHaveBeenCalledWith('auto-1') - expect(runtime.runAutomationNow).toHaveBeenCalledWith('auto-1') - expect(runtime.listAutomationRuns).toHaveBeenCalledWith('auto-1') + expect(runtime.deleteAutomation).toHaveBeenCalledWith('auto-1', undefined) + expect(runtime.runAutomationNow).toHaveBeenCalledWith('auto-1', undefined) + expect(runtime.listAutomationRuns).toHaveBeenCalledWith('auto-1', undefined) }) it('rejects unknown providers and invalid schedules', async () => { @@ -147,6 +153,10 @@ describe('automation RPC methods', () => { }) ) - expect(runtime.updateAutomation).toHaveBeenCalledWith('auto-1', { baseBranch: null }) + expect(runtime.updateAutomation).toHaveBeenCalledWith( + 'auto-1', + { baseBranch: null }, + { expectedOwner: undefined, destination: undefined } + ) }) }) diff --git a/src/main/runtime/rpc/methods/automations.ts b/src/main/runtime/rpc/methods/automations.ts index d4e74b719fe..3645df3c149 100644 --- a/src/main/runtime/rpc/methods/automations.ts +++ b/src/main/runtime/rpc/methods/automations.ts @@ -1,160 +1,48 @@ -import { z } from 'zod' -import { isValidAutomationSchedule } from '../../../../shared/automation-schedules' +import { AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { AutomationOwnerPrecondition } from '../../../../shared/automation-owner-precondition' +import { defineMethod, type RpcContext, type RpcMethod } from '../core' import { - MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, - normalizeAutomationPrecheckTimeoutSeconds -} from '../../../../shared/automation-precheck' -import { normalizeExecutionHostId } from '../../../../shared/execution-host' -import type { TaskProviderIdentity as SharedTaskProviderIdentity } from '../../../../shared/task-source-context' -import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { defineMethod, type RpcMethod } from '../core' -import { - OptionalBoolean, - OptionalPlainString, - OptionalPositiveInt, - OptionalString, - requiredNumber, - requiredString -} from '../schemas' + AutomationCreate, + AutomationId, + AutomationList, + AutomationRuns, + AutomationUpdate +} from './automation-schemas' -const TuiAgent = requiredString('Missing provider').refine(isTuiAgent, { - message: 'Unknown provider' -}) - -const AutomationWorkspaceMode = z.enum(['existing', 'new_per_run']).optional() -const SetupDecision = z.enum(['inherit', 'run', 'skip']).optional() -const ExecutionHostId = requiredString('Missing host id').transform((value, ctx) => { - const hostId = normalizeExecutionHostId(value) - if (!hostId) { - ctx.addIssue({ code: 'custom', message: 'Invalid host id' }) - return z.NEVER +function mutationOwner( + id: string, + expectedOwner: AutomationOwnerPrecondition | undefined, + context: RpcContext +): AutomationOwnerPrecondition | undefined { + if ( + expectedOwner || + context.clientCapabilities === undefined || + context.clientCapabilities.includes(AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY) + ) { + return expectedOwner } - return hostId -}) - -const AutomationSchedule = requiredString('Missing trigger').refine(isValidAutomationSchedule, { - message: 'Invalid automation trigger' -}) - -const AutomationPrecheck = z - .object({ - command: requiredString('Missing precheck command'), - timeoutSeconds: OptionalPositiveInt.transform((value) => - normalizeAutomationPrecheckTimeoutSeconds(value) - ).refine((value) => value <= MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, { - message: 'Precheck timeout is too large' - }) - }) - .nullable() - .optional() - -const OptionalNullablePlainString = z - .unknown() - .transform((value) => (value === null || typeof value === 'string' ? value : undefined)) - .pipe(z.union([z.string(), z.null(), z.undefined()])) - .optional() - -const TaskProviderIdentity = z - .custom( - (value) => - value !== null && - typeof value === 'object' && - 'provider' in value && - ['github', 'gitlab', 'linear', 'jira'].includes(String(value.provider)) - ) - .optional() - .nullable() - -const TaskSourceContext = z - .object({ - kind: z.literal('task-source'), - provider: z.enum(['github', 'gitlab', 'linear', 'jira']), - projectId: requiredString('Missing source project id'), - hostId: ExecutionHostId, - projectHostSetupId: OptionalNullablePlainString, - repoId: OptionalNullablePlainString, - providerIdentity: TaskProviderIdentity, - accountLabel: OptionalNullablePlainString - }) - .optional() - .nullable() - -const WorkspaceRunContext = z - .object({ - kind: z.literal('workspace-run'), - projectId: requiredString('Missing run project id'), - hostId: ExecutionHostId, - projectHostSetupId: requiredString('Missing project host setup id'), - repoId: requiredString('Missing repo id'), - path: requiredString('Missing run path') - }) - .optional() - .nullable() - -const AutomationId = z.object({ - id: requiredString('Missing automation id') -}) - -const AutomationRuns = z.object({ - automationId: OptionalString -}) - -const AutomationCreate = z.object({ - name: requiredString('Missing automation name'), - prompt: requiredString('Missing automation prompt'), - precheck: AutomationPrecheck, - agentId: TuiAgent, - runContext: WorkspaceRunContext, - sourceContext: TaskSourceContext, - repo: OptionalString, - workspace: OptionalString, - workspaceMode: AutomationWorkspaceMode, - baseBranch: OptionalPlainString, - setupDecision: SetupDecision, - reuseSession: OptionalBoolean, - timezone: OptionalString, - rrule: AutomationSchedule, - dtstart: requiredNumber('Missing trigger start time'), - enabled: OptionalBoolean, - missedRunGraceMinutes: OptionalPositiveInt -}) - -const AutomationUpdateFields = z.object({ - name: OptionalString, - prompt: OptionalString, - precheck: AutomationPrecheck, - agentId: TuiAgent.optional(), - runContext: WorkspaceRunContext, - sourceContext: TaskSourceContext, - repo: OptionalString, - workspace: OptionalString, - workspaceMode: AutomationWorkspaceMode, - // Why: update patches distinguish omitted from null so callers can clear a saved base branch. - baseBranch: OptionalNullablePlainString, - setupDecision: SetupDecision, - reuseSession: OptionalBoolean, - timezone: OptionalString, - rrule: AutomationSchedule.optional(), - dtstart: requiredNumber('Missing trigger start time').optional(), - enabled: OptionalBoolean, - missedRunGraceMinutes: OptionalPositiveInt -}) - -const AutomationUpdate = z.object({ - id: requiredString('Missing automation id'), - updates: AutomationUpdateFields -}) + // Legacy clients cannot echo owner metadata, so snapshot it at the RPC boundary. + return context.runtime.automationOwnerPrecondition(id) ?? undefined +} export const AUTOMATION_METHODS: RpcMethod[] = [ defineMethod({ name: 'automation.list', - params: null, - handler: (_params, { runtime }) => ({ automations: runtime.listAutomations() }) + params: AutomationList, + // The projection retains `automations`, so old clients ignore the added owner metadata. + handler: (params, { runtime }) => runtime.listAutomationsForScope(params) }), defineMethod({ name: 'automation.show', params: AutomationId, - handler: (params, { runtime }) => ({ automation: runtime.showAutomation(params.id) }) + // Why: the owner rides along so a client that cannot project one itself — the + // CLI — can echo it back on the mutation that follows. Optional: an older + // host omits it, and an older client ignores it. + handler: (params, { runtime }) => { + const automation = runtime.showAutomation(params.id, params.expectedOwner) + const owner = runtime.automationOwnerPrecondition(params.id) + return owner ? { automation, owner } : { automation } + } }), defineMethod({ name: 'automation.create', @@ -166,25 +54,37 @@ export const AUTOMATION_METHODS: RpcMethod[] = [ defineMethod({ name: 'automation.update', params: AutomationUpdate, - handler: async (params, { runtime }) => ({ - automation: await runtime.updateAutomation(params.id, params.updates) + handler: async (params, context) => ({ + automation: await context.runtime.updateAutomation(params.id, params.updates, { + expectedOwner: mutationOwner(params.id, params.expectedOwner, context), + destination: params.destination + }) }) }), defineMethod({ name: 'automation.delete', params: AutomationId, - handler: (params, { runtime }) => runtime.deleteAutomation(params.id) + handler: (params, context) => + context.runtime.deleteAutomation( + params.id, + mutationOwner(params.id, params.expectedOwner, context) + ) }), defineMethod({ name: 'automation.runNow', params: AutomationId, - handler: async (params, { runtime }) => ({ run: await runtime.runAutomationNow(params.id) }) + handler: async (params, context) => ({ + run: await context.runtime.runAutomationNow( + params.id, + mutationOwner(params.id, params.expectedOwner, context) + ) + }) }), defineMethod({ name: 'automation.runs', params: AutomationRuns, handler: (params, { runtime }) => ({ - runs: runtime.listAutomationRuns(params.automationId) + runs: runtime.listAutomationRuns(params.automationId, params.expectedOwner) }) }) ] diff --git a/src/main/runtime/rpc/methods/client-settings-schemas.ts b/src/main/runtime/rpc/methods/client-settings-schemas.ts new file mode 100644 index 00000000000..7244879c350 --- /dev/null +++ b/src/main/runtime/rpc/methods/client-settings-schemas.ts @@ -0,0 +1,82 @@ +import { z } from 'zod' +import { normalizePRBotAuthorOverrides } from '../../../../shared/pr-bot-author-overrides' +import { isTaskProvider } from '../../../../shared/task-providers' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { + normalizeTuiAgentArgsRecord, + normalizeTuiAgentEnvRecord +} from '../../../../shared/tui-agent-launch-defaults' +import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection' +import { WorktreeVisibilityDefaultsUpdate } from './worktree-visibility-defaults-schema' +import type { TaskProvider } from '../../../../shared/task-providers' + +const TaskProviderParam = z.custom(isTaskProvider, { + message: 'Unknown task provider' +}) + +export const PRBotAuthorOverrideUpdate = z + .object({ author: z.string(), isBot: z.boolean() }) + .strict() + +const GitHubProjectRef = z + .object({ + owner: z.string(), + ownerType: z.enum(['organization', 'user']), + number: z.number().int(), + host: z.string().optional() + }) + .strict() +const GitHubProjectSettings = z + .object({ + pinned: z.array(GitHubProjectRef), + recent: z.array( + GitHubProjectRef.extend({ + lastOpenedAt: z.string() + }).strict() + ), + lastViewByProject: z.record(z.string(), z.object({ viewId: z.string() }).strict()), + activeProject: GitHubProjectRef.nullable() + }) + .strict() + +export const SettingsUpdate = z + .object({ + worktreeVisibilityDefaults: WorktreeVisibilityDefaultsUpdate.optional(), + defaultTuiAgent: z + .unknown() + .transform((value) => + value === null || value === 'blank' || isTuiAgent(value) ? value : undefined + ) + .optional(), + disabledTuiAgents: z + .unknown() + .transform((value) => normalizeDisabledTuiAgents(value)) + .optional(), + agentDefaultArgs: z + .unknown() + .transform((value) => normalizeTuiAgentArgsRecord(value)) + .optional(), + agentDefaultEnv: z + .unknown() + .transform((value) => normalizeTuiAgentEnvRecord(value)) + .optional(), + defaultTaskSource: TaskProviderParam.optional(), + visibleTaskProviders: z.array(TaskProviderParam).optional(), + defaultTaskViewPreset: z + .enum(['issues', 'my-issues', 'prs', 'my-prs', 'review', 'all']) + .optional(), + experimentalNewWorktreeCardStyle: z.boolean().optional(), + agentStatusHooksEnabled: z.boolean().optional(), + defaultRepoSelection: z.array(z.string()).nullable().optional(), + defaultLinearTeamSelection: z.array(z.string()).nullable().optional(), + compactWorktreeCards: z.boolean().optional(), + minimaxGroupId: z.string().optional(), + minimaxUsageModels: z.string().optional(), + githubProjects: GitHubProjectSettings.optional(), + prBotAuthorOverrides: z + .unknown() + .transform((value) => normalizePRBotAuthorOverrides(value)) + .optional() + }) + .strict() + .default({}) diff --git a/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts b/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts index d2bcec6a7f3..02c93c14f6b 100644 --- a/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts +++ b/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts @@ -39,6 +39,7 @@ describe('client UI RPC pairing-local field seams', () => { // Driven off the census so a field added to PAIRING_LOCAL_UI_FIELDS without wiring a seam // fails here rather than shipping. Sample values are what a paired web client actually sends. const pairingLocalSamples: Record<(typeof PAIRING_LOCAL_UI_FIELDS)[number], unknown> = { + automationHostFilter: { kind: 'host', hostKey: 'authority:desktop|selector:self' }, hideWorkspacesFromOtherDevices: true, manualRepoOrder: [ { hostId: 'runtime:web-11111111-2222-3333-4444-555555555555', repoId: 'repo-a' } diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index a7940828d88..79b923a55db 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -4,32 +4,19 @@ import { type FeatureInteractionId } from '../../../../shared/feature-interactions' import { isFeatureTipId } from '../../../../shared/feature-tips' -import { - normalizeTuiAgentArgsRecord, - normalizeTuiAgentEnvRecord -} from '../../../../shared/tui-agent-launch-defaults' -import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { isTaskProvider } from '../../../../shared/task-providers' import { isReleaseChannel, type ReleaseChannel } from '../../../../shared/release-channel' -import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection' -import { normalizePRBotAuthorOverrides } from '../../../../shared/pr-bot-author-overrides' import { normalizeWorktreeCardProperties, WORKTREE_CARD_PROPERTIES } from '../../../../shared/worktree/card-properties' import { isPluginPanelTabKey } from '../../../../shared/plugins/plugin-manifest' -import type { TaskProvider } from '../../../../shared/task-providers' import { ClientUiWorkspaceFilterFields } from './client-ui-workspace-filter-fields' import { TaskResumeState } from './task-resume-state-schema' import { WorkspaceCleanup } from './workspace-cleanup-ui-schema' import { omitUndefinedValues, tolerateUnknownValues } from './ui-update-value-tolerance' -import { WorktreeVisibilityDefaultsUpdate } from './worktree-visibility-defaults-schema' const NullableString = z.string().nullable() const StringArray = z.array(z.string()) -const TaskProviderParam = z.custom(isTaskProvider, { - message: 'Unknown task provider' -}) const FeatureTipIds = z.array(z.custom(isFeatureTipId, { message: 'Unknown feature tip id' })) const UnknownRecord = z.record(z.string(), z.unknown()) const UnknownRecordArray = z.array(UnknownRecord) @@ -101,72 +88,6 @@ const FeatureInteractions = z export const FeatureInteractionIdParam = z.custom(isFeatureInteractionId, { message: 'Unknown feature interaction id' }) -export const PRBotAuthorOverrideUpdate = z - .object({ author: z.string(), isBot: z.boolean() }) - .strict() -const GitHubProjectRef = z - .object({ - owner: z.string(), - ownerType: z.enum(['organization', 'user']), - number: z.number().int(), - host: z.string().optional() - }) - .strict() -const GitHubProjectSettings = z - .object({ - pinned: z.array(GitHubProjectRef), - recent: z.array( - GitHubProjectRef.extend({ - lastOpenedAt: z.string() - }).strict() - ), - lastViewByProject: z.record(z.string(), z.object({ viewId: z.string() }).strict()), - activeProject: GitHubProjectRef.nullable() - }) - .strict() - -export const SettingsUpdate = z - .object({ - worktreeVisibilityDefaults: WorktreeVisibilityDefaultsUpdate.optional(), - defaultTuiAgent: z - .unknown() - .transform((value) => - value === null || value === 'blank' || isTuiAgent(value) ? value : undefined - ) - .optional(), - disabledTuiAgents: z - .unknown() - .transform((value) => normalizeDisabledTuiAgents(value)) - .optional(), - agentDefaultArgs: z - .unknown() - .transform((value) => normalizeTuiAgentArgsRecord(value)) - .optional(), - agentDefaultEnv: z - .unknown() - .transform((value) => normalizeTuiAgentEnvRecord(value)) - .optional(), - defaultTaskSource: TaskProviderParam.optional(), - visibleTaskProviders: z.array(TaskProviderParam).optional(), - defaultTaskViewPreset: z - .enum(['issues', 'my-issues', 'prs', 'my-prs', 'review', 'all']) - .optional(), - experimentalNewWorktreeCardStyle: z.boolean().optional(), - agentStatusHooksEnabled: z.boolean().optional(), - defaultRepoSelection: z.array(z.string()).nullable().optional(), - defaultLinearTeamSelection: z.array(z.string()).nullable().optional(), - compactWorktreeCards: z.boolean().optional(), - minimaxGroupId: z.string().optional(), - minimaxUsageModels: z.string().optional(), - githubProjects: GitHubProjectSettings.optional(), - prBotAuthorOverrides: z - .unknown() - .transform((value) => normalizePRBotAuthorOverrides(value)) - .optional() - }) - .strict() - .default({}) - const TopLevelViewSchema = z.enum([ 'terminal', 'settings', @@ -202,6 +123,12 @@ const UiUpdateFields = z workspaceHostScope: z.string().optional(), visibleWorkspaceHostIds: z.array(z.string()).nullable().optional(), workspaceHostOrder: z.array(z.string()).optional(), + automationHostFilter: z + .union([ + z.object({ kind: z.literal('all') }).strict(), + z.object({ kind: z.literal('host'), hostKey: z.string().min(1) }).strict() + ]) + .optional(), manualRepoOrder: z .array(z.object({ hostId: z.string(), repoId: z.string() }).strict()) .optional(), diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index c62328ebc64..4161064be01 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -1,12 +1,8 @@ import { omitPairingLocalUiFields } from '../../../../shared/pairing-local-ui-fields' import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types' import { defineMethod, type RpcMethod } from '../core' -import { - FeatureInteractionIdParam, - PRBotAuthorOverrideUpdate, - SettingsUpdate, - UiUpdate -} from './client-ui-schemas' +import { PRBotAuthorOverrideUpdate, SettingsUpdate } from './client-settings-schemas' +import { FeatureInteractionIdParam, UiUpdate } from './client-ui-schemas' // Type-only side effect: keeps the schema/PersistedUIState parity assertions in // the typecheck graph so drift fails the build instead of a paired client. diff --git a/src/main/runtime/rpc/methods/ssh.ts b/src/main/runtime/rpc/methods/ssh.ts index 41691dc6ae9..2e0a4e4f4ab 100644 --- a/src/main/runtime/rpc/methods/ssh.ts +++ b/src/main/runtime/rpc/methods/ssh.ts @@ -7,13 +7,19 @@ import { } from '../../../ssh/ssh-target-registry' import { defineMethod, type RpcMethod } from '../core' import { getPublicSshError, getPublicSshState } from '../../public-ssh-state' +import type { SshTargetSummary } from '../../../../shared/ssh-types' const SshTarget = z.object({ targetId: z.string().min(1) }) -function listRegisteredSshTargetSummaries(): { id: string; label: string }[] { - return listRegisteredSshTargets().map(({ id, label }) => ({ id, label })) +// Why: `generation` stays optional on the wire — an old server simply omits it and its rows key on target id alone. +function listRegisteredSshTargetSummaries(): SshTargetSummary[] { + return listRegisteredSshTargets().map(({ id, label, generation }) => ({ + id, + label, + ...(generation === undefined ? {} : { generation }) + })) } export const SSH_METHODS: RpcMethod[] = [ diff --git a/src/main/ssh/removed-ssh-target-tombstone-retention.test.ts b/src/main/ssh/removed-ssh-target-tombstone-retention.test.ts new file mode 100644 index 00000000000..73976525c36 --- /dev/null +++ b/src/main/ssh/removed-ssh-target-tombstone-retention.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { + capRemovedSshTargetTombstones, + collectAutomationReferencedSshTargetIds, + collectSshTargetRemovalEvidenceDependencies +} from './removed-ssh-target-tombstone-retention' +import type { RemovedSshTargetTombstone } from '../../shared/ssh-types' +import type { FolderWorkspaceHostState } from '../../shared/folder-workspace-execution-host' + +function tombstones(count: number): RemovedSshTargetTombstone[] { + return Array.from({ length: count }, (_, index) => ({ + oldTargetId: `ssh-${index}`, + host: 'h', + port: 22, + username: 'u', + label: `Host ${index}`, + removedAt: index + })) +} + +describe('capRemovedSshTargetTombstones', () => { + it('drops the oldest unreferenced records first', () => { + const capped = capRemovedSshTargetTombstones(tombstones(5), new Set(), 3) + expect(capped.map((entry) => entry.oldTargetId)).toEqual(['ssh-2', 'ssh-3', 'ssh-4']) + }) + + it('retains referenced removal evidence past the cap', () => { + const capped = capRemovedSshTargetTombstones(tombstones(5), new Set(['ssh-0']), 3) + expect(capped.map((entry) => entry.oldTargetId)).toEqual(['ssh-0', 'ssh-3', 'ssh-4']) + }) + + it('keeps everything when every record is still referenced', () => { + const all = tombstones(5) + const referenced = new Set(all.map((entry) => entry.oldTargetId)) + expect(capRemovedSshTargetTombstones(all, referenced, 2)).toHaveLength(5) + }) +}) + +describe('collectAutomationReferencedSshTargetIds', () => { + it('collects only non-empty SSH selectors', () => { + expect( + collectAutomationReferencedSshTargetIds([ + { executionTargetType: 'ssh', executionTargetId: 'ssh-1' }, + { executionTargetType: 'ssh', executionTargetId: '' }, + { executionTargetType: 'local', executionTargetId: 'local' } + ]) + ).toEqual(new Set(['ssh-1'])) + }) +}) + +describe('folder-workspace removal evidence', () => { + it('retains a tombstone referenced by an automation workspace pin past the cap', () => { + const all = tombstones(51) + const workspaceState = { + folderWorkspaces: [ + { + id: 'folder-1', + projectGroupId: 'group-1', + folderPath: '/workspace', + executionHostId: 'ssh:ssh-0' + } + ], + projectGroups: [], + repos: [] + } as unknown as FolderWorkspaceHostState + const referenced = collectSshTargetRemovalEvidenceDependencies({ + automations: [ + { executionTargetType: 'local', executionTargetId: 'local', workspaceId: 'folder:folder-1' } + ], + workspaceState + }) + + const capped = capRemovedSshTargetTombstones(all, referenced) + + expect(capped).toHaveLength(50) + expect(capped.some((entry) => entry.oldTargetId === 'ssh-0')).toBe(true) + expect(capped.some((entry) => entry.oldTargetId === 'ssh-1')).toBe(false) + }) +}) diff --git a/src/main/ssh/removed-ssh-target-tombstone-retention.ts b/src/main/ssh/removed-ssh-target-tombstone-retention.ts new file mode 100644 index 00000000000..b3238c97f11 --- /dev/null +++ b/src/main/ssh/removed-ssh-target-tombstone-retention.ts @@ -0,0 +1,94 @@ +import type { RemovedSshTargetTombstone } from '../../shared/ssh-types' +import type { PersistedAutomationHostFilter } from '../../shared/automation-host-filter' +import { parseHostStableKey } from '../../shared/automation-owner-key' +import { resolveAutomationWorkspaceSshTargetId } from '../../shared/automation-workspace-pin' +import type { FolderWorkspaceHostState } from '../../shared/folder-workspace-execution-host' + +// Why: bound removed-SSH-target history so remove/re-add churn can't grow the file unbounded. +export const MAX_REMOVED_SSH_TARGET_TOMBSTONES = 50 + +/** + * Apply the tombstone cap while retaining positive removal evidence something + * still depends on. A tombstone referenced by a stored automation is the only + * proof that its ghost host ever existed, so evicting it would silently turn an + * orphaned automation into an unexplained record. Referenced tombstones are kept + * past the cap; unreferenced ones are dropped oldest-first. + */ +export function capRemovedSshTargetTombstones( + tombstones: readonly RemovedSshTargetTombstone[], + referencedTargetIds: ReadonlySet, + cap = MAX_REMOVED_SSH_TARGET_TOMBSTONES +): RemovedSshTargetTombstone[] { + if (tombstones.length <= cap) { + return [...tombstones] + } + const unreferencedToDrop = new Set() + let dropCount = tombstones.length - cap + for (let index = 0; index < tombstones.length && dropCount > 0; index += 1) { + if (!referencedTargetIds.has(tombstones[index].oldTargetId)) { + unreferencedToDrop.add(index) + dropCount -= 1 + } + } + return tombstones.filter((_, index) => !unreferencedToDrop.has(index)) +} + +/** SSH target ids stored automations still point at — the tombstones that must survive the cap. */ +export function collectAutomationReferencedSshTargetIds( + automations: readonly { executionTargetType: string; executionTargetId: string }[] +): Set { + const ids = new Set() + for (const automation of automations) { + if (automation.executionTargetType === 'ssh' && automation.executionTargetId) { + ids.add(automation.executionTargetId) + } + } + return ids +} + +/** Desktop SSH target id the persisted Automations host filter still names, if any. */ +export function persistedAutomationHostFilterSshTargetId( + filter: PersistedAutomationHostFilter | undefined +): string | null { + if (filter?.kind !== 'host') { + return null + } + const host = parseHostStableKey(filter.hostKey) + return host?.authority.kind === 'desktop' && host.selector.kind === 'ssh' + ? host.selector.targetId + : null +} + +export type SshTargetRemovalEvidenceDependents = { + automations: readonly { + executionTargetType: string + executionTargetId: string + workspaceId?: string | null + }[] + automationHostFilter?: PersistedAutomationHostFilter + /** Folder-workspace pins are indirect automation host references. */ + workspaceState?: FolderWorkspaceHostState +} + +/** Everything that still depends on removal evidence: a tombstone for one of these is never discarded. */ +export function collectSshTargetRemovalEvidenceDependencies( + input: SshTargetRemovalEvidenceDependents +): Set { + const ids = collectAutomationReferencedSshTargetIds(input.automations) + if (input.workspaceState) { + for (const automation of input.automations) { + const targetId = resolveAutomationWorkspaceSshTargetId( + input.workspaceState, + automation.workspaceId ?? null + ) + if (targetId) { + ids.add(targetId) + } + } + } + const filterTargetId = persistedAutomationHostFilterSshTargetId(input.automationHostFilter) + if (filterTargetId) { + ids.add(filterTargetId) + } + return ids +} diff --git a/src/main/ssh/ssh-connection-store.test.ts b/src/main/ssh/ssh-connection-store.test.ts index 9acba4d18eb..b086a9c5e31 100644 --- a/src/main/ssh/ssh-connection-store.test.ts +++ b/src/main/ssh/ssh-connection-store.test.ts @@ -17,8 +17,19 @@ function createMockStore() { let deletedAliases: string[] = [] const removedTombstones: RemovedSshTargetTombstone[] = [] const reassignments: { oldTargetId: string; newTargetId: string }[] = [] + let generationCounter = 0 + + const dropTombstone = (oldTargetId: string) => { + const kept = removedTombstones.filter((t) => t.oldTargetId !== oldTargetId) + removedTombstones.length = 0 + removedTombstones.push(...kept) + } return { + allocateSshTargetGeneration: vi.fn(() => { + generationCounter += 1 + return generationCounter + }), getSshTargets: vi.fn(() => [...targets]), getSshTarget: vi.fn((id: string) => targets.find((t) => t.id === id)), addSshTarget: vi.fn((target: SshTarget) => targets.push(target)), @@ -56,11 +67,9 @@ function createMockStore() { removedTombstones.length = 0 removedTombstones.push(...filtered, tombstone) }), - removeRemovedSshTargetTombstone: vi.fn((oldTargetId: string) => { - const kept = removedTombstones.filter((t) => t.oldTargetId !== oldTargetId) - removedTombstones.length = 0 - removedTombstones.push(...kept) - }), + removeRemovedSshTargetTombstone: vi.fn(dropTombstone), + // Nothing in this mock stores automations, so releasing always drops. + releaseRemovedSshTargetTombstone: vi.fn(dropTombstone), reassignSshTargetId: vi.fn((oldTargetId: string, newTargetId: string) => { reassignments.push({ oldTargetId, newTargetId }) // Pretend one repo referenced the old id. diff --git a/src/main/ssh/ssh-connection-store.ts b/src/main/ssh/ssh-connection-store.ts index 97fc2e3b32e..08f78c77dd7 100644 --- a/src/main/ssh/ssh-connection-store.ts +++ b/src/main/ssh/ssh-connection-store.ts @@ -41,7 +41,9 @@ export class SshConnectionStore { // Why: default to 'manual' so user-created targets are never overwritten // by a later ~/.ssh/config import (only 'ssh-config' targets are synced). source: target.source ?? 'manual', - id: `ssh-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + id: `ssh-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + // Why: a fresh registration; automations fenced on an earlier one must not adopt it. + generation: this.store.allocateSshTargetGeneration() } // Why: re-adding a host the user previously deleted is an explicit intent to // keep it — lift any tombstone so config sync stops suppressing this alias. @@ -69,6 +71,8 @@ export class SshConnectionStore { configHost: target.configHost ?? target.host, owner: { type: 'on-demand-runtime', runtimeId }, source: 'manual', + // Why: an upsert onto a live registration is not a re-registration — only a fresh id allocates. + generation: existing?.generation ?? this.store.allocateSshTargetGeneration(), ...(existing?.lastRequiredPassphrase !== undefined ? { lastRequiredPassphrase: existing.lastRequiredPassphrase } : {}) @@ -216,7 +220,11 @@ export class SshConnectionStore { changed.push(updated) } } else { - const inserted: SshTarget = { ...candidate, source: 'ssh-config' } + const inserted: SshTarget = { + ...candidate, + source: 'ssh-config', + generation: this.store.allocateSshTargetGeneration() + } this.store.addSshTarget(inserted) // Why: a freshly-inserted config host may be one the user removed and is // now re-importing — re-adopt its orphaned workspaces. Updated-in-place diff --git a/src/main/ssh/ssh-target-id-migration.ts b/src/main/ssh/ssh-target-id-migration.ts index 18d3f075bf0..403688d2b31 100644 --- a/src/main/ssh/ssh-target-id-migration.ts +++ b/src/main/ssh/ssh-target-id-migration.ts @@ -1,4 +1,6 @@ +import type { FolderWorkspace } from '../../shared/folder-workspace-types' import type { PersistedUIState } from '../../shared/persisted-ui-state-types' +import type { ProjectGroup } from '../../shared/project-group-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { parseAppSshPtyId, toAppSshPtyId } from '../../shared/ssh-pty-id' import { toSshExecutionHostId } from '../../shared/execution-host' @@ -89,6 +91,34 @@ export function migrateWorkspaceSessionSshTargetId( return changed } +/** + * Re-point folder-workspace scopes pinned to the old SSH target. + * + * A folder workspace carries its own connection/host stamp, and its group can + * carry the scope connection — neither is a repo row, so the repo sweep misses + * both and the workspace (plus any automation pinned to it) stays on the dead id. + */ +export function migrateFolderWorkspaceHostSshTargetId( + scope: { folderWorkspaces?: FolderWorkspace[]; projectGroups?: ProjectGroup[] }, + oldTargetId: string, + newTargetId: string +): boolean { + const oldHostId = toSshExecutionHostId(oldTargetId) + const newHostId = toSshExecutionHostId(newTargetId) + let changed = false + for (const entry of [...(scope.folderWorkspaces ?? []), ...(scope.projectGroups ?? [])]) { + if (entry.connectionId === oldTargetId) { + entry.connectionId = newTargetId + changed = true + } + if (entry.executionHostId === oldHostId) { + entry.executionHostId = newHostId + changed = true + } + } + return changed +} + /** Re-point the sidebar host-scope arrays pinned to the old SSH host id. */ export function migrateUiHostScopeSshTargetId( ui: PersistedUIState, diff --git a/src/main/ssh/ssh-target-readoption.test.ts b/src/main/ssh/ssh-target-readoption.test.ts index 2b066a0bdc4..c8d8cd24a18 100644 --- a/src/main/ssh/ssh-target-readoption.test.ts +++ b/src/main/ssh/ssh-target-readoption.test.ts @@ -23,7 +23,7 @@ function makeFakeStore(tombstones: RemovedSshTargetTombstone[]) { let current = [...tombstones] const store = { getRemovedSshTargetTombstones: () => [...current], - removeRemovedSshTargetTombstone: (oldTargetId: string) => { + releaseRemovedSshTargetTombstone: (oldTargetId: string) => { current = current.filter((t) => t.oldTargetId !== oldTargetId) }, reassignSshTargetId: (oldTargetId: string, newTargetId: string) => { diff --git a/src/main/ssh/ssh-target-readoption.ts b/src/main/ssh/ssh-target-readoption.ts index 70d66078785..fd80d91a4ae 100644 --- a/src/main/ssh/ssh-target-readoption.ts +++ b/src/main/ssh/ssh-target-readoption.ts @@ -14,7 +14,8 @@ import { meaningfulSshAlias, sshEndpointKey, type SshIdentityFields } from './ss * minted and nothing links the old workspaces to it. We bridge that gap using * removal-time tombstones ({ oldTargetId, configHost, host, username, port }): * on add/import we match the new target's identity to a tombstone and re-point - * every repo/worktree from the old id to the new one. + * every repo/worktree from the old id to the new one, together with the stored + * automations and persisted host filter pinned to it. * * Matching is intentionally strict — configHost (alias) first, then the * host+username+port tuple — so we only auto-reattach on a confident identity @@ -61,7 +62,7 @@ export function readoptOrphanedWorkspacesForTarget( // Why: a re-added target can't share the id of one that still exists, but // guard anyway so we never re-point a live target onto itself. if (tombstone.oldTargetId === newTarget.id) { - store.removeRemovedSshTargetTombstone(tombstone.oldTargetId) + store.releaseRemovedSshTargetTombstone(tombstone.oldTargetId) continue } if (!tombstoneMatches(tombstone, newTarget)) { @@ -72,8 +73,9 @@ export function readoptOrphanedWorkspacesForTarget( readoptions.push({ oldTargetId: tombstone.oldTargetId, newTargetId: newTarget.id, repoIds }) } // Consume the tombstone whether or not it re-pointed anything: the host has - // returned, so the record has served its purpose. - store.removeRemovedSshTargetTombstone(tombstone.oldTargetId) + // returned, so the record has served its purpose — unless an automation or the + // persisted filter still depends on that removal evidence, which retains it. + store.releaseRemovedSshTargetTombstone(tombstone.oldTargetId) } return readoptions } diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 2615a5f9246..ce62a6d3da4 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -344,6 +344,7 @@ function registerRuntimeWindowLifecycle( worktreeBaseStatus: (event) => send('worktree:baseStatus', event), worktreeRemoteBranchConflict: (event) => send('worktree:remoteBranchConflict', event), reposChanged: () => send('repos:changed'), + automationsChanged: (payload) => send('automations:changed', payload), activateWorktree: ( repoId, worktreeId, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index d81d8c59f93..08511397a5c 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -153,6 +153,7 @@ export type PreloadApi = { export type { ClaudeUsageApi, CodexUsageApi, OpenCodeUsageApi } from './api/agent-usage-api' export type { AiVaultApi } from './api/ai-vault-api' +export type { AutomationsApi, ExternalAutomationManagerResult } from './api/automation-api' export type { AppApi } from './api/app-api' export type { BrowserApi, DetectedBrowserInfo, DetectedBrowserProfileInfo } from './api/browser-api' export type { EmulatorApi } from './api/emulator-api' diff --git a/src/preload/api/automation-api.ts b/src/preload/api/automation-api.ts index 90f13e4ce1e..794f8067221 100644 --- a/src/preload/api/automation-api.ts +++ b/src/preload/api/automation-api.ts @@ -1,31 +1,44 @@ import type { - Automation, - AutomationCreateInput, AutomationDispatchRequest, AutomationDispatchResult, - ExternalAutomationCreateInput, - ExternalAutomationActionInput, - ExternalAutomationManager, - ExternalAutomationRunsInput, - ExternalAutomationRunsPage, - ExternalAutomationUpdateInput, - AutomationRun, AutomationPrecheckResult, - AutomationUpdateInput + AutomationRun, + ExternalAutomationManager, + ExternalAutomationRunsPage } from '../../shared/automations-types' +import type { AutomationOwnerRef } from '../../shared/automation-owner-ref' +import type { + ScopedExternalManagerActionRequest, + ScopedExternalManagerCreateRequest, + ScopedExternalManagerListRequest, + ScopedExternalManagerRunsRequest, + ScopedExternalManagerUpdateRequest +} from '../../shared/external-automation-scope' +import type { AutomationsChangedPayload } from '../../shared/runtime-client-events' +export type ExternalAutomationManagerResult = { + /** Null once a probe succeeded and found no manager configured for this scope. */ + manager: ExternalAutomationManager | null + error: string | null + updatedAt: number +} + +/** + * Orca automation CRUD rides the local runtime RPC surface (`runtime:call`), + * so this IPC api carries only what stays desktop-native: the external-manager + * scope (probes run on this computer) and the dispatch-loop plumbing. + */ export type AutomationsApi = { - list: () => Promise - listRuns: (args?: { automationId?: string }) => Promise - listExternalManagers: () => Promise - listExternalRuns: (input: ExternalAutomationRunsInput) => Promise - createExternal: (input: ExternalAutomationCreateInput) => Promise - updateExternal: (input: ExternalAutomationUpdateInput) => Promise - runExternalAction: (input: ExternalAutomationActionInput) => Promise - create: (input: AutomationCreateInput) => Promise - update: (args: { id: string; updates: AutomationUpdateInput }) => Promise - delete: (args: { id: string }) => Promise - runNow: (args: { id: string }) => Promise + listExternalManagerForOwner: ( + request: ScopedExternalManagerListRequest + ) => Promise + listExternalRunsForOwner: ( + request: ScopedExternalManagerRunsRequest + ) => Promise + createExternalForOwner: (request: ScopedExternalManagerCreateRequest) => Promise + updateExternalForOwner: (request: ScopedExternalManagerUpdateRequest) => Promise + runExternalActionForOwner: (request: ScopedExternalManagerActionRequest) => Promise + retainExternalScopes: (request: { owners: readonly AutomationOwnerRef[] }) => Promise runPrecheck: (args: { automationId: string runId: string @@ -34,4 +47,5 @@ export type AutomationsApi = { snapshotWorkspaceName: (args: { workspaceId: string; displayName: string }) => Promise rendererReady: () => Promise onDispatchRequested: (callback: (request: AutomationDispatchRequest) => void) => () => void + onChanged: (callback: (payload: AutomationsChangedPayload) => void) => () => void } diff --git a/src/preload/api/ssh-api.ts b/src/preload/api/ssh-api.ts index 6740819fa19..2cdc9776714 100644 --- a/src/preload/api/ssh-api.ts +++ b/src/preload/api/ssh-api.ts @@ -7,7 +7,9 @@ import type { SshConfigImportResult, SshConnectionState, SshTarget, - SshTargetAddResult + SshTargetAddResult, + SshTargetCreateInput, + SshTargetUpdateInput } from '../../shared/ssh-types' import type { FilesystemPathFlavor } from '../../shared/filesystem-entry-types' @@ -15,11 +17,8 @@ export type SshApi = { listTargets: () => Promise // Removed-target id → last known label, for a friendly host name on workspaces still pinned to a removed target. listRemovedTargetLabels: () => Promise> - addTarget: (args: { target: Omit }) => Promise - updateTarget: (args: { - id: string - updates: Partial> - }) => Promise + addTarget: (args: { target: SshTargetCreateInput }) => Promise + updateTarget: (args: { id: string; updates: SshTargetUpdateInput }) => Promise removeTarget: (args: { id: string }) => Promise importConfig: (args?: { reAdopt?: boolean }) => Promise listConfigHosts: (args?: SshConfigHostListArgs) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 980e99efcbf..9efc4716019 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -45,7 +45,9 @@ import type { SshConfigHostResolution, SshConfigImportResult, SshTargetAddResult, + SshTargetCreateInput, SshTarget, + SshTargetUpdateInput, PortForwardEntry, EnrichedDetectedPort } from '../shared/ssh-types' @@ -263,6 +265,7 @@ import type { PluginHostInstallSource, PluginHostListEntry, PluginHostLogLine, + ExternalAutomationManagerResult, PreloadApi } from './api-types' import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' @@ -277,20 +280,21 @@ import { createUsageProviderApi } from './usage-provider-api' import type { AppStarSource } from '../shared/gh-star-source' import type { ExecutionHostId } from '../shared/execution-host' import type { - Automation, - AutomationCreateInput, AutomationDispatchRequest, AutomationDispatchResult, - ExternalAutomationCreateInput, - ExternalAutomationActionInput, - ExternalAutomationManager, - ExternalAutomationRunsInput, ExternalAutomationRunsPage, - ExternalAutomationUpdateInput, AutomationRun, - AutomationPrecheckResult, - AutomationUpdateInput + AutomationPrecheckResult } from '../shared/automations-types' +import type { AutomationOwnerRef } from '../shared/automation-owner-ref' +import type { + ScopedExternalManagerActionRequest, + ScopedExternalManagerCreateRequest, + ScopedExternalManagerListRequest, + ScopedExternalManagerRunsRequest, + ScopedExternalManagerUpdateRequest +} from '../shared/external-automation-scope' +import type { AutomationsChangedPayload } from '../shared/runtime-client-events' import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings' import type { AiVaultDeleteSessionArgs, @@ -4763,13 +4767,11 @@ const api = { listRemovedTargetLabels: (): Promise> => ipcRenderer.invoke('ssh:listRemovedTargetLabels'), - addTarget: (args: { target: Omit }): Promise => + addTarget: (args: { target: SshTargetCreateInput }): Promise => ipcRenderer.invoke('ssh:addTarget', args), - updateTarget: (args: { - id: string - updates: Partial> - }): Promise => ipcRenderer.invoke('ssh:updateTarget', args), + updateTarget: (args: { id: string; updates: SshTargetUpdateInput }): Promise => + ipcRenderer.invoke('ssh:updateTarget', args), removeTarget: (args: { id: string }): Promise => ipcRenderer.invoke('ssh:removeTarget', args), @@ -4921,27 +4923,25 @@ const api = { ipcRenderer.invoke('ssh:submitCredential', args) }, + // Orca automation CRUD rides the local runtime RPC surface (`runtime:call`), + // so only external-manager and dispatch-loop plumbing stays on IPC. automations: { - list: (): Promise => ipcRenderer.invoke('automations:list'), - listRuns: (args?: { automationId?: string }): Promise => - ipcRenderer.invoke('automations:listRuns', args), - listExternalManagers: (): Promise => - ipcRenderer.invoke('automations:listExternalManagers'), - listExternalRuns: (input: ExternalAutomationRunsInput): Promise => - ipcRenderer.invoke('automations:listExternalRuns', input), - createExternal: (input: ExternalAutomationCreateInput): Promise => - ipcRenderer.invoke('automations:createExternal', input), - updateExternal: (input: ExternalAutomationUpdateInput): Promise => - ipcRenderer.invoke('automations:updateExternal', input), - runExternalAction: (input: ExternalAutomationActionInput): Promise => - ipcRenderer.invoke('automations:runExternalAction', input), - create: (input: AutomationCreateInput): Promise => - ipcRenderer.invoke('automations:create', input), - update: (args: { id: string; updates: AutomationUpdateInput }): Promise => - ipcRenderer.invoke('automations:update', args), - delete: (args: { id: string }): Promise => ipcRenderer.invoke('automations:delete', args), - runNow: (args: { id: string }): Promise => - ipcRenderer.invoke('automations:runNow', args), + listExternalManagerForOwner: ( + request: ScopedExternalManagerListRequest + ): Promise => + ipcRenderer.invoke('automations:listExternalManagerForOwner', request), + listExternalRunsForOwner: ( + request: ScopedExternalManagerRunsRequest + ): Promise => + ipcRenderer.invoke('automations:listExternalRunsForOwner', request), + createExternalForOwner: (request: ScopedExternalManagerCreateRequest): Promise => + ipcRenderer.invoke('automations:createExternalForOwner', request), + updateExternalForOwner: (request: ScopedExternalManagerUpdateRequest): Promise => + ipcRenderer.invoke('automations:updateExternalForOwner', request), + runExternalActionForOwner: (request: ScopedExternalManagerActionRequest): Promise => + ipcRenderer.invoke('automations:runExternalActionForOwner', request), + retainExternalScopes: (request: { owners: readonly AutomationOwnerRef[] }): Promise => + ipcRenderer.invoke('automations:retainExternalScopes', request), runPrecheck: (args: { automationId: string runId: string @@ -4957,6 +4957,12 @@ const api = { callback(request) ipcRenderer.on('automations:dispatchRequested', listener) return () => ipcRenderer.removeListener('automations:dispatchRequested', listener) + }, + onChanged: (callback: (payload: AutomationsChangedPayload) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: AutomationsChangedPayload) => + callback(payload) + ipcRenderer.on('automations:changed', listener) + return () => ipcRenderer.removeListener('automations:changed', listener) } }, diff --git a/src/renderer/src/components/automations/AutomationCreateDestinationField.test.tsx b/src/renderer/src/components/automations/AutomationCreateDestinationField.test.tsx new file mode 100644 index 00000000000..1df0a7519bf --- /dev/null +++ b/src/renderer/src/components/automations/AutomationCreateDestinationField.test.tsx @@ -0,0 +1,86 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { AutomationCreateDestinationField } from './AutomationCreateDestinationField' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref' +import type { AutomationCreateDestinationControl } from './use-automation-create-destination' +import type { Repo } from '../../../../shared/repo-types' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +const OWNER: AutomationOwnerRef = { + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId: 't1', targetGeneration: 1 } +} + +const ENTRY: AutomationHostCatalogEntry = { + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 't1' } }, + owner: OWNER, + stableKey: 'host:desktop:ssh:t1', + label: 'openclaw', + authorityLabel: 'Local Mac', + kind: 'ssh', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'connected', + querySupport: 'scoped' +} + +function control(projects: Repo[]): AutomationCreateDestinationControl { + return { + entries: [ENTRY], + resolution: { + status: 'ready', + authority: OWNER.authority, + destination: { selector: OWNER.selector }, + entry: ENTRY + }, + onSelect: () => undefined, + projects + } +} + +function render(projects: Repo[]): void { + act(() => { + root.render( + + + + ) + }) +} + +function emptyNote(): HTMLElement | null { + return container.querySelector('[data-testid="automation-create-no-projects"]') +} + +describe('AutomationCreateDestinationField', () => { + it('names the host that has no projects rather than leaving Create dead', () => { + render([]) + + expect(emptyNote()?.textContent).toContain('openclaw') + }) + + it('states the storing authority once the host has a project to offer', () => { + render([{ id: 'repo-1' } as Repo]) + + expect(emptyNote()).toBeNull() + expect(container.textContent).toContain('Local Mac') + }) +}) diff --git a/src/renderer/src/components/automations/AutomationCreateDestinationField.tsx b/src/renderer/src/components/automations/AutomationCreateDestinationField.tsx new file mode 100644 index 00000000000..8ba017b0ff7 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationCreateDestinationField.tsx @@ -0,0 +1,96 @@ +import React from 'react' +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { translate } from '@/i18n/i18n' +import { AutomationHostLabel, AutomationHostStatusBadges } from './AutomationHostBadges' +import { Field } from './automation-page-parts' +import { automationCreateHostEligible } from './automation-create-destination' +import { groupAutomationHostEntriesByAuthority } from './automation-host-picker-groups' +import type { AutomationCreateDestinationControl } from './use-automation-create-destination' + +/** + * Where the new automation will be stored, named on the form before submit. + * + * This is the storage authority, not the workspace's execution host — the two + * routinely differ, and only this one decides which machine keeps and schedules + * the record. A concrete host filter only preselects: the list filter narrows a + * view, so it must not decide where a new record is allowed to live. + */ + +export function AutomationCreateDestinationField({ + control, + labelClassName +}: { + control: AutomationCreateDestinationControl + labelClassName?: string +}): React.JSX.Element { + const selected = control.resolution.status === 'ready' ? control.resolution.entry : null + // Offered and resolved by the same predicate, so the list never contains a + // host that selecting would refuse (e.g. a view-only entry). + const groups = groupAutomationHostEntriesByAuthority( + control.entries.filter(automationCreateHostEligible) + ) + const label = translate('auto.components.automations.createDestination.label', 'Create on') + + return ( + + + {selected && control.projects.length === 0 ? ( + // Otherwise Create is disabled for an empty project list with nothing said. +

+ {translate( + 'auto.components.automations.createDestination.noProjects', + 'No projects are set up on {host}. Add one there, or choose another host.' + ).replace('{host}', selected.label)} +

+ ) : ( +

+ {selected + ? translate( + 'auto.components.automations.createDestination.storedOn', + 'Stored and scheduled by {authority}.' + ).replace('{authority}', selected.authorityLabel) + : translate( + 'auto.components.automations.createDestination.unselected', + 'Choose the host this automation will be created on.' + )} +

+ )} +
+ ) +} diff --git a/src/renderer/src/components/automations/AutomationDetail.test.tsx b/src/renderer/src/components/automations/AutomationDetail.test.tsx new file mode 100644 index 00000000000..9b469c1f3e1 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationDetail.test.tsx @@ -0,0 +1,76 @@ +// @vitest-environment happy-dom + +/** + * A record Orca switched off used to be indistinguishable from one the user + * switched off — same "Paused", no reason, no next step. These pin the one new + * state to the migration's own stamp, and pin the other two to reading exactly + * as they did before it existed. + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { Automation } from '../../../../shared/automations-types' +import { AutomationDetail } from './AutomationDetail' +import { makeAutomation } from './automations-page-fixtures' + +const roots: Root[] = [] + +async function render(overrides: Partial): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render( + + + + ) + }) + return container +} + +function notice(container: HTMLDivElement): HTMLElement | null { + return container.querySelector('[data-testid="automation-enablement-notice"]') +} + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true +}) + +afterEach(async () => { + await act(async () => { + roots.splice(0).forEach((root) => root.unmount()) + }) + document.body.innerHTML = '' +}) + +describe('AutomationDetail enablement', () => { + it('shows a paused record as a plain pause', async () => { + const container = await render({ enabled: false }) + + expect(container.textContent).toContain('Paused') + expect(notice(container)).toBeNull() + }) + + it('shows a running automation as enabled', async () => { + const container = await render({ enabled: true }) + + expect(container.textContent).toContain('Enabled') + expect(notice(container)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/automations/AutomationDetail.tsx b/src/renderer/src/components/automations/AutomationDetail.tsx index c1ed6165295..a15c56e5abf 100644 --- a/src/renderer/src/components/automations/AutomationDetail.tsx +++ b/src/renderer/src/components/automations/AutomationDetail.tsx @@ -144,7 +144,7 @@ export function AutomationDetail({ {automation.enabled ? translate('auto.components.automations.AutomationDetail.eaa02014f8', 'Enabled') - : translate('auto.components.automations.AutomationDetail.b09b2384fd', 'Paused')} + : translate('auto.components.automations.enablement.paused', 'Paused')}

diff --git a/src/renderer/src/components/automations/AutomationEditorDialog.tsx b/src/renderer/src/components/automations/AutomationEditorDialog.tsx index bda6cbd46ce..9fa13b7c210 100644 --- a/src/renderer/src/components/automations/AutomationEditorDialog.tsx +++ b/src/renderer/src/components/automations/AutomationEditorDialog.tsx @@ -14,6 +14,10 @@ import type { TuiAgent } from '../../../../shared/tui-agent' import type { SetupDecision } from '../../../../shared/worktree/create-types' import type { Worktree } from '../../../../shared/worktree/types' import { closeUnfocusedMonacoFindOrPreventDialogDismiss } from '@/components/editor/monaco-find-widget' +import { AutomationOwnerConflictNotice } from './AutomationOwnerConflictNotice' +import type { AutomationActionNotice } from './automation-row-action-dispatch' +import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors' +import type { AutomationCreateDestinationControl } from './use-automation-create-destination' import { AutomationEditorDialogFooter } from './AutomationEditorDialogFooter' import { AutomationEditorDialogHeader } from './AutomationEditorDialogHeader' import { getAutomationPromptEditorRoot } from './AutomationEditorPromptEditor' @@ -67,6 +71,12 @@ type AutomationEditorDialogProps = { worktrees: Worktree[] settings: GlobalSettings | null draft: AutomationDraft + /** Present only while creating an Orca automation; editing keeps the record's captured owner. */ + createDestination?: AutomationCreateDestinationControl + /** Why a save was refused. Belongs here rather than on the page: this dialog covers it. */ + notice?: AutomationActionNotice | null + onNoticeRecover?: (action: AutomationHostRecoveryAction) => void + onNoticeDismiss?: () => void onProjectChange: (projectId: string) => void getRepoHostLabel?: (repo: Repo) => string | null | undefined allowAddProject?: boolean @@ -93,6 +103,10 @@ export function AutomationEditorDialog({ worktrees, settings, draft, + createDestination, + notice, + onNoticeRecover, + onNoticeDismiss, onProjectChange, getRepoHostLabel, allowAddProject, @@ -172,6 +186,8 @@ export function AutomationEditorDialog({ onDismiss={() => onOpenChange(false)} /> + + + {createDestination && isCreateMode && !isHermesTarget ? ( +

+ +
+ ) : null} { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +function entry(overrides: Partial = {}): AutomationHostCatalogEntry { + return { + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 't1' } }, + owner: null, + stableKey: 'host:desktop:ssh:t1', + label: 'web-01', + authorityLabel: 'This computer', + kind: 'ssh', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'connected', + querySupport: 'scoped', + ...overrides + } +} + +function render(node: React.ReactNode): void { + act(() => { + root.render({node}) + }) +} + +function statusIds(axis?: string): string[] { + const selector = axis ? `[data-status-axis="${axis}"]` : '[data-status-id]' + return [...container.querySelectorAll(selector)].map( + (node) => node.getAttribute('data-status-id') ?? '' + ) +} + +describe('AutomationHostStatusBadges', () => { + it('renders nothing when both axes rest healthy and the contract is scoped', () => { + render() + expect(statusIds()).toEqual([]) + }) + + it('renders the two health axes as separate badges', () => { + render( + + ) + expect(statusIds('authority')).toEqual(['authority-stale-error']) + expect(statusIds('execution')).toEqual(['execution-disconnected']) + }) + + it('shows an execution failure while the authority is fresh', () => { + render() + expect(statusIds('authority')).toEqual([]) + expect(statusIds('execution')).toEqual(['execution-disconnected']) + }) + + it('shows an authority failure while the target is connected', () => { + render() + expect(statusIds('authority')).toEqual(['authority-unavailable']) + expect(statusIds('execution')).toEqual([]) + }) + + it('distinguishes an incompatible authority from a legacy-unscoped contract', () => { + render() + const incompatible = statusIds() + render() + const legacy = statusIds() + + expect(incompatible).toContain('authority-incompatible') + expect(legacy).toContain('query-legacy-unscoped') + expect(incompatible).not.toEqual(legacy) + }) + + it('separates the incompatible query contract from the incompatible authority', () => { + render() + expect(statusIds('query')).toEqual(['query-incompatible']) + expect(statusIds('authority')).toEqual([]) + }) + + it('names the removed host, not the server, for a ghost entry', () => { + render( + + ) + expect(statusIds('query')).toEqual(['query-target-removed']) + expect( + container.querySelector('[data-status-id="query-target-removed"]')?.getAttribute('aria-label') + ).toBe( + 'View only. This host was removed, so its automations can no longer be edited or run from here.' + ) + }) + + it('separates an unverified target from a server that lacks host scoping', () => { + render( + + ) + expect(statusIds('query')).toEqual(['query-target-unverified']) + expect( + container + .querySelector('[data-status-id="query-target-unverified"]') + ?.getAttribute('aria-label') + ).toBe( + 'View only. This host has not been verified since its connection dropped, so its automations cannot be edited or run from here.' + ) + }) + + it('names both the label and the explanation in the accessible name', () => { + render() + const badge = container.querySelector('[data-status-id="execution-disconnected"]') + expect(badge?.getAttribute('aria-label')).toBe( + 'Not connected. Automations on this host cannot run until the connection is restored.' + ) + }) + + it('renders resting states when asked to show healthy', () => { + render() + expect(statusIds()).toEqual(['authority-fresh', 'execution-connected']) + }) +}) + +describe('AutomationHostLabel', () => { + it('keeps the full name in the accessible name while the visible label truncates', () => { + render() + const labelled = container.querySelector('[data-host-stable-key]') + expect(labelled?.getAttribute('aria-label')).toBe('a-very-long-host-name-that-truncates') + expect(container.querySelector('.truncate')?.textContent).toBe( + 'a-very-long-host-name-that-truncates' + ) + }) + + it('qualifies the accessible name with the authority when asked', () => { + render() + expect(container.querySelector('[data-host-stable-key]')?.getAttribute('aria-label')).toBe( + 'This computer · web-01' + ) + }) +}) diff --git a/src/renderer/src/components/automations/AutomationHostBadges.tsx b/src/renderer/src/components/automations/AutomationHostBadges.tsx new file mode 100644 index 00000000000..7fe2aa608f4 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationHostBadges.tsx @@ -0,0 +1,126 @@ +import React from 'react' +import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' +import { Badge } from '@/components/ui/badge' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import { hostScopeDescriptor } from './automation-host-scope-descriptors' +import { + authorityHealthDescriptor, + executionHealthDescriptor, + type AutomationHostStatusDescriptor, + type AutomationHostStatusTone +} from './automation-host-status-descriptors' + +/** + * Host identity and status chips. The two health axes render as two chips and + * never merge: a host can be freshly loaded and disconnected at the same time. + */ + +type BadgeStyle = { variant: React.ComponentProps['variant']; className?: string } + +// `Badge` has no muted variant and this design does not add one — quiet reads as outline + muted text. +const TONE_STYLE: Record = { + neutral: { variant: 'outline' }, + quiet: { variant: 'outline', className: 'text-muted-foreground' }, + attention: { variant: 'secondary' }, + error: { variant: 'destructive' } +} + +type AutomationHostLabelProps = { + entry: AutomationHostCatalogEntry + /** Host accent color; falls back to a neutral token so no new color is invented. */ + color?: string | null + showAuthority?: boolean + className?: string +} + +export function AutomationHostLabel({ + entry, + color, + showAuthority = false, + className +}: AutomationHostLabelProps): React.JSX.Element { + const accessibleName = showAuthority ? `${entry.authorityLabel} · ${entry.label}` : entry.label + + return ( + + + {/* Truncation lives on the inner label, so the full name stays in the accessible name. */} + + + + + + {accessibleName} + + + ) +} + +type StatusBadgeProps = { + descriptor: AutomationHostStatusDescriptor + axis: 'authority' | 'execution' | 'query' +} + +function StatusBadge({ descriptor, axis }: StatusBadgeProps): React.JSX.Element { + const style = TONE_STYLE[descriptor.tone] + + return ( + + + + {descriptor.label} + + + + {descriptor.description} + + + ) +} + +type AutomationHostStatusBadgesProps = { + entry: AutomationHostCatalogEntry + /** Render the resting states too; off by default so healthy rows stay quiet. */ + showHealthy?: boolean + className?: string +} + +export function AutomationHostStatusBadges({ + entry, + showHealthy = false, + className +}: AutomationHostStatusBadgesProps): React.JSX.Element | null { + const authority = authorityHealthDescriptor(entry.authorityHealth) + const execution = executionHealthDescriptor(entry.executionHealth) + const query = hostScopeDescriptor(entry) + const showAuthority = showHealthy || !authority.isDefault + const showExecution = showHealthy || !execution.isDefault + + if (!showAuthority && !showExecution && !query) { + return null + } + + return ( + + {showAuthority ? : null} + {showExecution ? : null} + {query ? : null} + + ) +} diff --git a/src/renderer/src/components/automations/AutomationHostFilterNotice.test.tsx b/src/renderer/src/components/automations/AutomationHostFilterNotice.test.tsx new file mode 100644 index 00000000000..e4e6903c151 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationHostFilterNotice.test.tsx @@ -0,0 +1,205 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { + AutomationHostFilterNotice, + AutomationHostLoadSummary, + automationHostLoadSummaryMessage +} from './AutomationHostFilterNotice' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import type { + AutomationHostFilterResolution, + AutomationHostFilterStatus +} from './automation-host-filter-resolution' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +function entry(overrides: Partial = {}): AutomationHostCatalogEntry { + return { + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 't1' } }, + owner: null, + stableKey: 'host:desktop:ssh:t1', + label: 'web-01', + authorityLabel: 'This computer', + kind: 'ssh', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'connected', + querySupport: 'scoped', + ...overrides + } +} + +function resolution( + status: AutomationHostFilterStatus, + overrides: Partial = {} +): AutomationHostFilterResolution { + return { + effective: status === 'all' ? { kind: 'all' } : { kind: 'host', host: entry().stableRef }, + entry: status === 'all' ? null : entry(), + status, + announceFallback: false, + ...overrides + } +} + +function renderNotice( + value: AutomationHostFilterResolution, + onRecover?: (action: 'retry' | 'reconnect' | 'update-server') => void +): void { + act(() => { + root.render( + + + + ) + }) +} + +function liveRegionText(): string { + return [...container.querySelectorAll('[aria-live="polite"]')] + .map((node) => node.textContent ?? '') + .join(' ') + .trim() +} + +describe('AutomationHostFilterNotice', () => { + it('stays silent when the selection is healthy', () => { + renderNotice(resolution('all')) + expect(container.querySelector('[data-filter-status]')).toBeNull() + renderNotice(resolution('ready')) + expect(container.querySelector('[data-filter-status]')).toBeNull() + }) + + const NOTICED: AutomationHostFilterStatus[] = ['loading', 'unavailable', 'ghost', 'removed'] + it.each(NOTICED)('surfaces the %s status inline', (status) => { + renderNotice(resolution(status)) + const notice = container.querySelector('[data-filter-status]') + expect(notice?.getAttribute('data-filter-status')).toBe(status) + expect(notice?.textContent?.length).toBeGreaterThan(0) + }) + + it('reports a removed host as still listed rather than as data loss', () => { + renderNotice(resolution('ghost')) + expect(container.querySelector('[data-filter-status="ghost"]')?.textContent).toContain( + 'web-01 was removed. Automations still assigned to it remain listed.' + ) + }) + + it('announces the fallback to All hosts', () => { + renderNotice(resolution('all', { announceFallback: true })) + expect(liveRegionText()).toBe('The selected host is no longer available. Showing all hosts.') + }) + + it('says nothing when no fallback happened', () => { + renderNotice(resolution('all')) + expect(liveRegionText()).toBe('') + }) + + it('keeps the polite region mounted while there is nothing to announce', () => { + renderNotice(resolution('all')) + expect(container.querySelector('[role="status"][aria-live="polite"]')).not.toBeNull() + }) + + it('announces into the region that was already on the page', () => { + renderNotice(resolution('all')) + const before = container.querySelector('[role="status"][aria-live="polite"]') + + renderNotice(resolution('all', { announceFallback: true })) + + // A region inserted with its text already in it is not a change screen readers report. + expect(container.querySelector('[role="status"][aria-live="polite"]')).toBe(before) + }) + + it('offers the recovery action for the failure it actually has', () => { + const onRecover = vi.fn() + renderNotice( + resolution('unavailable', { entry: entry({ authorityHealth: 'unavailable' }) }), + onRecover + ) + const button = container.querySelector('button') + expect(button?.textContent).toBe('Reconnect') + + act(() => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(onRecover).toHaveBeenCalledWith('reconnect') + }) + + it('offers Update server for a legacy-unscoped contract', () => { + renderNotice( + resolution('ghost', { entry: entry({ querySupport: 'legacy-unscoped' }) }), + () => undefined + ) + expect(container.querySelector('button')?.textContent).toBe('Update server') + }) + + it('does not move focus when the notice appears', () => { + const input = document.createElement('input') + document.body.appendChild(input) + input.focus() + + renderNotice(resolution('unavailable')) + + expect(document.activeElement).toBe(input) + input.remove() + }) +}) + +describe('AutomationHostLoadSummary', () => { + function renderSummary(failed: number, total: number): void { + act(() => { + root.render() + }) + } + + it('words the partial failure exactly as specified', () => { + expect(automationHostLoadSummaryMessage(2, 5)).toBe('2 of 5 hosts could not be loaded') + }) + + it('says nothing when every host loaded', () => { + renderSummary(0, 5) + expect(liveRegionText()).toBe('') + }) + + it('announces politely rather than assertively', () => { + renderSummary(2, 5) + const region = container.querySelector('[aria-live]') + expect(region?.getAttribute('aria-live')).toBe('polite') + expect(region?.textContent).toBe('2 of 5 hosts could not be loaded') + }) + + it('does not re-announce when a retry leaves the failed count unchanged', () => { + renderSummary(2, 5) + const first = container.querySelector('[aria-live]')?.firstChild + renderSummary(2, 6) + expect(container.querySelector('[aria-live]')?.textContent).toBe( + '2 of 5 hosts could not be loaded' + ) + // The same text node survives, so assistive tech sees no change to announce. + expect(container.querySelector('[aria-live]')?.firstChild).toBe(first) + }) + + it('re-announces when the failed count changes', () => { + renderSummary(2, 5) + renderSummary(1, 5) + expect(container.querySelector('[aria-live]')?.textContent).toBe( + '1 of 5 hosts could not be loaded' + ) + }) +}) diff --git a/src/renderer/src/components/automations/AutomationHostFilterNotice.tsx b/src/renderer/src/components/automations/AutomationHostFilterNotice.tsx new file mode 100644 index 00000000000..de479f577c4 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationHostFilterNotice.tsx @@ -0,0 +1,159 @@ +import React, { useEffect, useRef, useState } from 'react' +import { Loader2, TriangleAlert } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { AutomationHostStatusBadges } from './AutomationHostBadges' +import type { AutomationHostFilterResolution } from './automation-host-filter-resolution' +import { + automationHostRecoveryActions, + recoveryActionLabel, + type AutomationHostRecoveryAction +} from './automation-host-status-descriptors' + +/** + * Inline status for the current host selection, plus the two polite + * announcements. Status appears inline rather than as a toast because the user + * has to read and act on it, and it must never steal focus. + */ + +export function automationHostLoadSummaryMessage( + failedHostCount: number, + totalHostCount: number +): string | null { + if (failedHostCount <= 0) { + return null + } + return translate( + 'auto.components.automations.hostSummary.partialFailure', + '{{failed}} of {{total}} hosts could not be loaded', + { failed: failedHostCount, total: totalHostCount } + ) +} + +type AutomationHostLoadSummaryProps = { + failedHostCount: number + totalHostCount: number + className?: string +} + +/** Polite and deduplicated: re-announces only when the failed count changes, not per retry. */ +export function AutomationHostLoadSummary({ + failedHostCount, + totalHostCount, + className +}: AutomationHostLoadSummaryProps): React.JSX.Element { + const [announced, setAnnounced] = useState('') + // Why a ref: a host joining or leaving must not re-announce an unchanged set of failures. + const totalRef = useRef(totalHostCount) + totalRef.current = totalHostCount + + useEffect(() => { + setAnnounced(automationHostLoadSummaryMessage(failedHostCount, totalRef.current) ?? '') + }, [failedHostCount]) + + return ( +
+ {announced} +
+ ) +} + +type AutomationHostFilterNoticeProps = { + resolution: AutomationHostFilterResolution + onRecover?: (action: AutomationHostRecoveryAction) => void + className?: string +} + +function noticeCopy(resolution: AutomationHostFilterResolution): string | null { + const label = + resolution.entry?.label ?? + translate('auto.components.automations.emptyState.unknownHost', 'this host') + switch (resolution.status) { + case 'loading': + return translate('auto.components.automations.hostNotice.loading', 'Loading host…') + case 'unavailable': + return translate( + 'auto.components.automations.hostNotice.unavailable', + '{{hostLabel}} could not be reached. Showing the automations last loaded from it.', + { hostLabel: label } + ) + case 'ghost': + return translate( + 'auto.components.automations.hostNotice.ghost', + '{{hostLabel}} was removed. Automations still assigned to it remain listed.', + { hostLabel: label } + ) + case 'removed': + return translate( + 'auto.components.automations.hostNotice.removed', + 'The selected host is no longer available. Showing all hosts.' + ) + case 'all': + case 'ready': + return null + } +} + +export function AutomationHostFilterNotice({ + resolution, + onRecover, + className +}: AutomationHostFilterNoticeProps): React.JSX.Element { + const [announcedFallback, setAnnouncedFallback] = useState('') + const message = noticeCopy(resolution) + const recovery = resolution.entry ? automationHostRecoveryActions(resolution.entry) : null + const action = recovery?.authority ?? recovery?.execution ?? null + const isLoading = resolution.status === 'loading' + + useEffect(() => { + setAnnouncedFallback( + resolution.announceFallback + ? translate( + 'auto.components.automations.hostNotice.removed', + 'The selected host is no longer available. Showing all hosts.' + ) + : '' + ) + }, [resolution.announceFallback]) + + return ( + <> + {/* Outlives every notice state: a region inserted with its text already in it is announced by nothing. */} +
+ {announcedFallback} +
+ {message ? ( +
+ {isLoading ? ( + + ) : ( + + )} + {message} + {resolution.entry ? : null} + {action && onRecover ? ( + + ) : null} +
+ ) : null} + + ) +} diff --git a/src/renderer/src/components/automations/AutomationListEmptyView.test.tsx b/src/renderer/src/components/automations/AutomationListEmptyView.test.tsx new file mode 100644 index 00000000000..cd37805ddd2 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationListEmptyView.test.tsx @@ -0,0 +1,104 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + AutomationListEmptyView, + type AutomationListEmptyViewProps +} from './AutomationListEmptyView' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import type { AutomationHostFilterResolution } from './automation-host-filter-resolution' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +function entry(overrides: Partial = {}): AutomationHostCatalogEntry { + return { + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 't1' } }, + owner: null, + stableKey: 'host:desktop:ssh:t1', + label: 'web-01', + authorityLabel: 'This computer', + kind: 'ssh', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'connected', + querySupport: 'scoped', + ...overrides + } +} + +function hostResolution(host = entry()): AutomationHostFilterResolution { + return { + effective: { kind: 'host', host: host.stableRef }, + entry: host, + status: 'ready', + announceFallback: false + } +} + +function render(overrides: Partial = {}): void { + act(() => { + root.render( + + ) + }) +} + +describe('AutomationListEmptyView', () => { + it('renders nothing while rows exist', () => { + render({ visibleRowCount: 4 }) + expect(container.querySelector('[data-empty-state]')).toBeNull() + }) + + it('shows the single-host empty copy', () => { + render() + const state = container.querySelector('[data-empty-state="host-empty"]') + expect(state?.textContent).toContain('No automations on web-01') + }) + + it('never renders an empty claim for a disconnected host', () => { + render({ resolution: hostResolution(entry({ executionHealth: 'disconnected' })) }) + expect(container.querySelector('[data-empty-state="host-empty"]')).toBeNull() + expect(container.textContent).not.toContain('No automations') + expect(container.querySelector('[data-empty-state="host-not-connected"]')).not.toBeNull() + }) + + it('invokes the recovery action the failure calls for', () => { + const onRecover = vi.fn() + render({ + resolution: hostResolution(entry({ authorityHealth: 'stale-error' })), + onRecover + }) + const button = container.querySelector('button') + expect(button?.textContent).toBe('Retry') + + act(() => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(onRecover).toHaveBeenCalledWith('retry') + }) + + it('omits the recovery button when the caller offers no handler', () => { + render({ resolution: hostResolution(entry({ authorityHealth: 'stale-error' })) }) + expect(container.querySelector('button')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/automations/AutomationListEmptyView.tsx b/src/renderer/src/components/automations/AutomationListEmptyView.tsx new file mode 100644 index 00000000000..83646b92e6d --- /dev/null +++ b/src/renderer/src/components/automations/AutomationListEmptyView.tsx @@ -0,0 +1,54 @@ +import React from 'react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { + resolveAutomationListEmptyState, + type AutomationListEmptyStateInput +} from './automation-list-empty-state' +import { + recoveryActionLabel, + type AutomationHostRecoveryAction +} from './automation-host-status-descriptors' + +/** Renders whichever empty/partial/failure state the list is in; nothing when rows exist. */ + +export type AutomationListEmptyViewProps = AutomationListEmptyStateInput & { + onRecover?: (action: AutomationHostRecoveryAction) => void + className?: string +} + +export function AutomationListEmptyView({ + onRecover, + className, + ...input +}: AutomationListEmptyViewProps): React.JSX.Element | null { + const state = resolveAutomationListEmptyState(input) + const recovery = state.recovery + if (state.kind === 'rows') { + return null + } + + return ( +
+

{state.title}

+ {state.detail ?

{state.detail}

: null} + {recovery && onRecover ? ( + + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/automations/AutomationListExternalRows.tsx b/src/renderer/src/components/automations/AutomationListExternalRows.tsx index b22a648b752..12e8f3c0c85 100644 --- a/src/renderer/src/components/automations/AutomationListExternalRows.tsx +++ b/src/renderer/src/components/automations/AutomationListExternalRows.tsx @@ -23,6 +23,7 @@ import type { } from '../../../../shared/automations-types' import type { SshConnectionState } from '../../../../shared/ssh-types' import type { ExternalAutomationListEntry } from './external-automation-list-entries' +import type { ExternalAutomationScope } from './external-automation-scope-client' import { formatExternalDate, getExternalProviderLabel, @@ -57,9 +58,14 @@ export function AutomationListExternalRows({ onRequestAction: ( manager: ExternalAutomationManager, job: ExternalAutomationJob, - action: ExternalAutomationAction + action: ExternalAutomationAction, + scope: ExternalAutomationScope + ) => void + onEdit: ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + scope: ExternalAutomationScope ) => void - onEdit: (manager: ExternalAutomationManager, job: ExternalAutomationJob) => void }): React.JSX.Element { return ( <> @@ -80,7 +86,8 @@ export function AutomationListExternalRows({ }) const actionDisabled = disabledMessage !== null const scheduleLabel = getExternalAutomationScheduleDisplay(entry.manager, entry.job).label - const projectLabel = `${providerLabel} / ${entry.manager.targetLabel}` + const hostLabel = entry.manager.targetLabel || entry.manager.label || 'Local' + const projectLabel = entry.job.workdir ?? providerLabel const nextRunLabel = entry.job.enabled ? formatExternalDate(entry.job.nextRunAt, relativeNow) : translate('auto.components.automations.AutomationsPage.paused', 'Paused') @@ -121,6 +128,9 @@ export function AutomationListExternalRows({ {projectLabel} + + {hostLabel} + {nextRunLabel} @@ -148,7 +158,7 @@ export function AutomationListExternalRows({ onRequestAction(entry.manager, entry.job, 'run')} + onSelect={() => onRequestAction(entry.manager, entry.job, 'run', entry.scope)} > @@ -162,7 +172,7 @@ export function AutomationListExternalRows({ {entry.manager.provider === 'hermes' ? ( onEdit(entry.manager, entry.job)} + onSelect={() => onEdit(entry.manager, entry.job, entry.scope)} > {translate( @@ -177,7 +187,8 @@ export function AutomationListExternalRows({ onRequestAction( entry.manager, entry.job, - entry.job.enabled ? 'pause' : 'resume' + entry.job.enabled ? 'pause' : 'resume', + entry.scope ) } > @@ -200,7 +211,9 @@ export function AutomationListExternalRows({ onRequestAction(entry.manager, entry.job, 'delete')} + onSelect={() => + onRequestAction(entry.manager, entry.job, 'delete', entry.scope) + } > {translate( @@ -215,7 +228,7 @@ export function AutomationListExternalRows({ onRequestAction(entry.manager, entry.job, 'run')} + onSelect={() => onRequestAction(entry.manager, entry.job, 'run', entry.scope)} > @@ -226,7 +239,7 @@ export function AutomationListExternalRows({ {entry.manager.provider === 'hermes' ? ( onEdit(entry.manager, entry.job)} + onSelect={() => onEdit(entry.manager, entry.job, entry.scope)} > {translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} @@ -235,7 +248,12 @@ export function AutomationListExternalRows({ - onRequestAction(entry.manager, entry.job, entry.job.enabled ? 'pause' : 'resume') + onRequestAction( + entry.manager, + entry.job, + entry.job.enabled ? 'pause' : 'resume', + entry.scope + ) } > {entry.job.enabled ? : } @@ -247,7 +265,7 @@ export function AutomationListExternalRows({ onRequestAction(entry.manager, entry.job, 'delete')} + onSelect={() => onRequestAction(entry.manager, entry.job, 'delete', entry.scope)} > {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')} diff --git a/src/renderer/src/components/automations/AutomationListFilterMenu.tsx b/src/renderer/src/components/automations/AutomationListFilterMenu.tsx index adebc4b285b..e9b2c2cab02 100644 --- a/src/renderer/src/components/automations/AutomationListFilterMenu.tsx +++ b/src/renderer/src/components/automations/AutomationListFilterMenu.tsx @@ -2,6 +2,7 @@ import React from 'react' import { ListFilter, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { DropdownMenu, DropdownMenuCheckboxItem, @@ -18,6 +19,7 @@ import { import { AgentIcon, getAgentCatalog } from '@/lib/agent-catalog' import { searchAgentPickerEntries } from '@/lib/agent-picker-search' import { translate } from '@/i18n/i18n' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' import { countAutomationListFilters, EMPTY_AUTOMATION_LIST_FILTER, @@ -38,7 +40,14 @@ function FilterPill({ return ( {label}: - {value} + + + {value} + + + {value} + + + {hostEntries ? ( + <> + + + {translate('auto.components.automations.AutomationsPage.tableHost', 'Host')} + + + onChange({ ...filter, hostStableKeys: [] })} + onSelect={(event) => event.preventDefault()} + > + {translate('auto.components.automations.hostPicker.allHosts', 'All hosts')} + + {hostEntries.map((entry) => ( + toggleHost(entry.stableKey)} + onSelect={(event) => event.preventDefault()} + > + {entry.label} + + ))} + + + + + ) : null} {statusLabel} diff --git a/src/renderer/src/components/automations/AutomationListHostGroups.test.tsx b/src/renderer/src/components/automations/AutomationListHostGroups.test.tsx new file mode 100644 index 00000000000..7854faab084 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationListHostGroups.test.tsx @@ -0,0 +1,168 @@ +// @vitest-environment happy-dom + +/** + * The All-hosts groups render a host that answered with nothing next to a host + * nobody could ask. These tests pin the difference, because a shared "no rows" + * sentence made the second read as the first — a claim about storage that no + * request ever backed. + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AutomationListHostGroups } from './AutomationListHostGroups' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import { filterAutomationHostGroups } from './automation-host-list-rows' +import type { AutomationListRow } from './automation-list-row-identity' +import { makeAutomation, makeAutomationListRow } from './automations-page-fixtures' + +vi.mock('./AutomationListLocalRows', () => ({ + AutomationListLocalRows: ({ rows }: { rows: readonly AutomationListRow[] }) => ( +
{rows.map((row) => row.automation.name).join(',')}
+ ) +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: () => null +})) + +function entry(overrides: Partial = {}): AutomationHostCatalogEntry { + return { + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 't1' } }, + owner: { + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId: 't1', targetGeneration: 1 } + }, + stableKey: 'host:desktop:ssh:t1', + label: 'web-01', + authorityLabel: 'This computer', + kind: 'ssh', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'connected', + querySupport: 'scoped', + ...overrides + } +} + +const roots: Root[] = [] + +async function render(options: { + entry: AutomationHostCatalogEntry + rows?: readonly AutomationListRow[] + visibleRowKeys?: ReadonlySet + searchActive?: boolean +}): Promise { + const rows = options.rows ?? [] + const groups = filterAutomationHostGroups( + [ + { + authorityKey: 'authority:desktop', + authorityLabel: 'This computer', + hosts: [{ entry: options.entry, rows }] + } + ], + options.visibleRowKeys ?? new Set(rows.map((row) => row.key)) + ) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render( + null} + automationSourceHostAvailabilityByRowKey={new Map()} + onSelect={vi.fn()} + onRunNow={vi.fn()} + onEdit={vi.fn()} + onToggle={vi.fn()} + onDelete={vi.fn()} + onRecover={vi.fn()} + /> + ) + }) + return container +} + +function emptyState(container: HTMLDivElement): { kind: string | null; text: string } { + const node = container.querySelector('[data-empty-state]') + return { kind: node?.getAttribute('data-empty-state') ?? null, text: node?.textContent ?? '' } +} + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true +}) + +afterEach(async () => { + await act(async () => { + roots.splice(0).forEach((root) => root.unmount()) + }) + document.body.innerHTML = '' +}) + +describe('AutomationListHostGroups empty groups', () => { + it('does not claim an unreachable host holds no automations', async () => { + const container = await render({ entry: entry({ authorityHealth: 'unavailable' }) }) + + expect(emptyState(container).kind).toBe('host-unavailable') + expect(container.textContent).not.toContain('No automations listed for this host') + expect(emptyState(container).text).toContain('could not be loaded from web-01') + }) + + it('does not claim a disconnected host holds no automations', async () => { + const container = await render({ entry: entry({ executionHealth: 'disconnected' }) }) + + expect(emptyState(container).kind).toBe('host-not-connected') + expect(emptyState(container).text).not.toContain('No automations') + }) + + it('does not claim an unhydrated host holds no automations', async () => { + const container = await render({ entry: entry({ catalogState: 'unhydrated' }) }) + + expect(emptyState(container).kind).toBe('host-loading') + expect(emptyState(container).text).not.toContain('No automations') + }) + + it('blames the query when a search hid every row of a healthy host', async () => { + const container = await render({ + entry: entry(), + rows: [makeAutomationListRow({ automation: makeAutomation({ name: 'Nightly' }) })], + visibleRowKeys: new Set(), + searchActive: true + }) + + expect(emptyState(container).kind).toBe('search-no-match') + expect(emptyState(container).text).toBe('No automations match your search') + }) + + it('says a healthy connected host is empty, naming the host', async () => { + const container = await render({ entry: entry() }) + + expect(emptyState(container).kind).toBe('host-empty') + expect(emptyState(container).text).toContain('No automations on web-01') + }) + + it('renders the rows a host did report', async () => { + const container = await render({ + entry: entry(), + rows: [makeAutomationListRow({ automation: makeAutomation({ name: 'Nightly' }) })] + }) + + expect(container.querySelector('[data-testid="rows"]')?.textContent).toBe('Nightly') + expect(emptyState(container).kind).toBeNull() + }) +}) diff --git a/src/renderer/src/components/automations/AutomationListHostGroups.tsx b/src/renderer/src/components/automations/AutomationListHostGroups.tsx new file mode 100644 index 00000000000..496eff017fa --- /dev/null +++ b/src/renderer/src/components/automations/AutomationListHostGroups.tsx @@ -0,0 +1,125 @@ +import React from 'react' +import { Button } from '@/components/ui/button' +import { AutomationHostLabel, AutomationHostStatusBadges } from './AutomationHostBadges' +import { + AutomationListLocalRows, + type AutomationListLocalRowsProps +} from './AutomationListLocalRows' +import type { AutomationHostVisibleGroup } from './automation-host-list-rows' +import { + resolveAutomationHostGroupEmptyState, + type AutomationListEmptyState +} from './automation-list-empty-state' +import { + automationHostRecoveryActions, + recoveryActionLabel, + type AutomationHostRecoveryAction +} from './automation-host-status-descriptors' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' + +/** + * The All-hosts list: rows grouped by authority, each host anchoring its own + * status row. + * + * The status sits on the host header rather than on the rows because it is a + * fact about the host, and because a host with no rows still has to say why — + * an unreachable host that rendered as an empty gap would read as "nothing + * scheduled here", which is the one thing we have not established. + */ + +export type AutomationListHostGroupsProps = Omit & { + groups: readonly AutomationHostVisibleGroup[] + searchActive: boolean + onRecover: (action: AutomationHostRecoveryAction, entry: AutomationHostCatalogEntry) => void +} + +function hostRecoveryAction( + entry: AutomationHostCatalogEntry +): AutomationHostRecoveryAction | null { + const recovery = automationHostRecoveryActions(entry) + return recovery.authority ?? recovery.execution +} + +function AutomationHostGroupState({ + state, + recovery, + onRecover +}: { + state: AutomationListEmptyState + recovery: AutomationHostRecoveryAction | null + onRecover: (action: AutomationHostRecoveryAction) => void +}): React.JSX.Element { + return ( +
+
{state.title}
+ {state.detail ?
{state.detail}
: null} + {recovery ? ( + + ) : null} +
+ ) +} + +export function AutomationListHostGroups({ + groups, + searchActive, + onRecover, + ...rowProps +}: AutomationListHostGroupsProps): React.JSX.Element { + return ( + <> + {groups.map((group) => ( +
+
+ {group.authorityLabel} +
+ {group.hosts.map(({ entry, rows, hostRowCount }) => { + const action = hostRecoveryAction(entry) + const state = resolveAutomationHostGroupEmptyState({ + entry, + hostRowCount, + visibleRowCount: rows.length, + searchActive + }) + return ( +
+
+ + + {action ? ( + + ) : null} +
+ {state.kind === 'rows' ? ( + + ) : ( + onRecover(recovery, entry)} + /> + )} +
+ ) + })} +
+ ))} + + ) +} diff --git a/src/renderer/src/components/automations/AutomationListLocalRows.test.tsx b/src/renderer/src/components/automations/AutomationListLocalRows.test.tsx index 221e0e45142..dee3ff4dc1f 100644 --- a/src/renderer/src/components/automations/AutomationListLocalRows.test.tsx +++ b/src/renderer/src/components/automations/AutomationListLocalRows.test.tsx @@ -10,6 +10,8 @@ import type { ExternalAutomationJob, ExternalAutomationManager } from '../../../../shared/automations-types' +import type { AutomationListRow } from './automation-list-row-identity' +import type { ExternalAutomationScope } from './external-automation-scope-client' import { AutomationListLocalRows } from './AutomationListLocalRows' import { AutomationListExternalRows } from './AutomationListExternalRows' import { indexLatestAutomationRuns } from './automation-list-last-run' @@ -112,14 +114,20 @@ function makeRun(overrides: Partial = {}): AutomationRun { } function renderLocalRows(handlers: { - onSelect: (automationId: string) => void - onDelete?: (automation: Automation) => void + onSelect: (rowKey: string) => void + onDelete?: (row: AutomationListRow) => void runs?: AutomationRun[] }) { + const row: AutomationListRow = { + key: 'row|local|automation-1', + automation: makeAutomation(), + hostLabel: 'Local', + usageSummary: null + } return render( null} + automationSourceHostAvailabilityByRowKey={new Map()} hostLabelById={new Map()} onSelect={handlers.onSelect} onRunNow={vi.fn()} @@ -145,14 +153,19 @@ function renderExternalRows(handlers: { onRequestAction?: ( manager: ExternalAutomationManager, job: ExternalAutomationJob, - action: 'run' | 'pause' | 'resume' | 'delete' + action: 'run' | 'pause' | 'resume' | 'delete', + scope: ExternalAutomationScope ) => void }) { const manager = makeExternalManager() const job = makeExternalJob() + const scope: ExternalAutomationScope = { + owner: { authority: { kind: 'desktop' }, selector: { kind: 'self' } }, + provider: 'hermes' + } return render( { await user.click(row as Element) expect(onSelect).toHaveBeenCalledTimes(1) - expect(onSelect).toHaveBeenCalledWith('automation-1') + expect(onSelect).toHaveBeenCalledWith('row|local|automation-1') }) it('opens the actions menu on keyboard activation without selecting the row', async () => { diff --git a/src/renderer/src/components/automations/AutomationListLocalRows.tsx b/src/renderer/src/components/automations/AutomationListLocalRows.tsx index 7e4e71d244d..d0aef5d899b 100644 --- a/src/renderer/src/components/automations/AutomationListLocalRows.tsx +++ b/src/renderer/src/components/automations/AutomationListLocalRows.tsx @@ -18,7 +18,7 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { AgentIcon } from '@/lib/agent-catalog' import { cn } from '@/lib/utils' -import type { Automation, AutomationRun } from '../../../../shared/automations-types' +import type { AutomationRun } from '../../../../shared/automations-types' import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity' import { formatUiAutomationSchedule } from './automation-schedule-label' import { @@ -32,67 +32,114 @@ import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import type { RuntimeStatus } from '../../../../shared/runtime-types' import type { TaskSourceHostAvailability } from '../task-source-context-summary' +import type { AutomationRowAction } from './automation-captured-owner' import type { AutomationHostTarget } from './automation-host-client' -import { getLocalAutomationLastRunSnapshot } from './automation-list-last-run' +import { + getAutomationRowLastRunSnapshot, + getLocalAutomationLastRunSnapshot +} from './automation-list-last-run' import { AutomationListLastRunCell } from './AutomationListLastRunCell' import { formatAutomationDateTimeWithRelative } from './automation-page-parts' import { getAutomationTargetAvailability } from './automation-target-availability' import { getAgentLabel } from './automation-draft-model' -import { formatAutomationCost } from './automation-usage-model' +import type { AutomationListRow } from './automation-list-row-identity' +import { + formatAutomationCost, + formatAutomationTokens, + type AutomationUsageSummary +} from './automation-usage-model' import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout' import { LIST_TABLE_ROW_CLASS, LIST_TABLE_ROW_SELECTED_CLASS } from '@/lib/list-table-layout' import { isPortaledRowMenuClick, isRowActivationKey } from '@/lib/list-row-interaction' import { AutomationListStatusCell } from './AutomationListStatusCell' import { translate } from '@/i18n/i18n' -export function AutomationListLocalRows({ - automations, - selectedId, - isSelectedLocal, - lastRunByAutomationId, - relativeNow, - repoMap, - worktreeMap, - projectHostSetups, - sshConnectionStates, - runtimeStatusByEnvironmentId, - automationHostTarget, - automationSourceHostAvailabilityById, - hostLabelById, - onSelect, - onRunNow, - onEdit, - onToggle, - onDelete -}: { - automations: readonly Automation[] - selectedId: string | null | undefined +export type AutomationListLocalRowsProps = { + rows: readonly AutomationListRow[] + selectedRowKey: string | null | undefined isSelectedLocal: boolean lastRunByAutomationId: ReadonlyMap relativeNow: number repoMap: ReadonlyMap worktreeMap: ReadonlyMap + repoForRow?: (row: AutomationListRow) => Repo | undefined + worktreeForRow?: (row: AutomationListRow, repo: Repo | undefined) => Worktree | undefined projectHostSetups: readonly ProjectHostSetup[] sshConnectionStates: ReadonlyMap> runtimeStatusByEnvironmentId: ReadonlyMap< string, { status: RuntimeStatus | null; checkedAt: number } > - automationHostTarget: AutomationHostTarget | null - automationSourceHostAvailabilityById: ReadonlyMap - hostLabelById: ReadonlyMap - onSelect: (automationId: string) => void - onRunNow: (automation: Automation) => void - onEdit: (automation: Automation) => void - onToggle: (automation: Automation) => void - onDelete: (automation: Automation) => void -}): React.JSX.Element { + hostTargetFor: (row: AutomationListRow) => AutomationHostTarget | null + automationSourceHostAvailabilityByRowKey: ReadonlyMap + hostLabelById?: ReadonlyMap + isActionEnabled?: (row: AutomationListRow, action: AutomationRowAction) => boolean + onSelect: (rowKey: string) => void + onRunNow: (row: AutomationListRow) => void + onEdit: (row: AutomationListRow) => void + onToggle: (row: AutomationListRow) => void + onDelete: (row: AutomationListRow) => void +} + +const EMPTY_HOST_LABELS: ReadonlyMap = new Map() + +function automationUsageText(summary: AutomationUsageSummary | undefined): string { + if (!summary || summary.unavailableRuns > 0) { + return summary?.knownRuns + ? usageAmountText(summary) + : translate( + 'auto.components.automations.AutomationsPage.usageUnavailable', + 'Usage unavailable' + ) + } + return summary.knownRuns > 0 + ? usageAmountText(summary) + : translate('auto.components.automations.AutomationsPage.noRunUsageYet', 'No run usage yet') +} + +function usageAmountText(summary: AutomationUsageSummary): string { + return translate( + 'auto.components.automations.AutomationsPage.runUsageSummary', + '{{cost}} est. · {{tokens}} tokens', + { + cost: formatAutomationCost(summary.estimatedCostUsd), + tokens: formatAutomationTokens(summary.totalTokens) + } + ) +} + +export function AutomationListLocalRows({ + rows, + selectedRowKey, + isSelectedLocal, + lastRunByAutomationId, + relativeNow, + repoMap, + worktreeMap, + repoForRow, + worktreeForRow, + projectHostSetups, + sshConnectionStates, + runtimeStatusByEnvironmentId, + hostTargetFor, + automationSourceHostAvailabilityByRowKey, + hostLabelById = EMPTY_HOST_LABELS, + isActionEnabled, + onSelect, + onRunNow, + onEdit, + onToggle, + onDelete +}: AutomationListLocalRowsProps): React.JSX.Element { + const allows = (row: AutomationListRow, action: AutomationRowAction): boolean => + isActionEnabled?.(row, action) ?? true return ( <> - {automations.map((automation) => { - const automationRepo = repoMap.get(getAutomationRunRepoId(automation)) + {rows.map((row) => { + const { automation } = row + const automationRepo = repoForRow?.(row) ?? repoMap.get(getAutomationRunRepoId(automation)) const automationWorktree = automation.workspaceId - ? worktreeMap.get(automation.workspaceId) + ? (worktreeForRow?.(row, automationRepo) ?? worktreeMap.get(automation.workspaceId)) : null const automationRunAvailability = getAutomationTargetAvailability({ automation, @@ -101,8 +148,8 @@ export function AutomationListLocalRows({ projectHostSetups, sshConnectionStates, runtimeStatusByEnvironmentId, - automationHostTarget, - sourceHostAvailability: automationSourceHostAvailabilityById.get(automation.id) + automationHostTarget: hostTargetFor(row), + sourceHostAvailability: automationSourceHostAvailabilityByRowKey.get(row.key) }) const projectLabel = automationRepo?.displayName ?? @@ -110,40 +157,45 @@ export function AutomationListLocalRows({ const scheduleLabel = formatUiAutomationSchedule(automation.rrule) const nextRunLabel = automation.enabled ? formatAutomationDateTimeWithRelative(automation.nextRunAt, relativeNow) - : translate('auto.components.automations.AutomationsPage.paused', 'Paused') - const isSelected = isSelectedLocal && selectedId === automation.id + : translate('auto.components.automations.enablement.paused', 'Paused') + const isSelected = isSelectedLocal && selectedRowKey === row.key const agentLabel = getAgentLabel(automation.agentId) const hostId = automation.runContext?.hostId ?? (automationRepo ? getRepoExecutionHostId(automationRepo) : null) - const hostLabel = hostId - ? (hostLabelById.get(hostId) ?? getExecutionHostLabel(hostId)) - : getLocalExecutionHostLabel() + const hostLabel = + row.hostLabel || + (hostId + ? (hostLabelById.get(hostId) ?? getExecutionHostLabel(hostId)) + : getLocalExecutionHostLabel()) + const agentTooltipLabel = `${agentLabel} · ${hostLabel} · ${automationUsageText(row.usageSummary ?? undefined)}` + const canRunNow = automationRunAvailability.canRunNow && allows(row, 'run') const lastRun = lastRunByAutomationId.get(automation.id) - const lastRunSnapshot = getLocalAutomationLastRunSnapshot(automation, lastRun) - const lastRunCost = - lastRun?.usage?.status === 'known' - ? formatAutomationCost(lastRun.usage.estimatedCostUsd) - : formatAutomationCost(null) - const agentTooltipLabel = `${agentLabel} · ${hostLabel} · ${lastRunCost}` + // Without a fetched run, the row's projected summary carries the newest + // retained run's status — the list never downloads run history for this. + const lastRunSnapshot = lastRun + ? getLocalAutomationLastRunSnapshot(automation, lastRun) + : getAutomationRowLastRunSnapshot(row) const actionItems = ( <> onRunNow(automation)} + onSelect={() => onRunNow(row)} /> } label={translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} - onSelect={() => onEdit(automation)} + onSelect={() => onEdit(row)} /> : } @@ -152,24 +204,26 @@ export function AutomationListLocalRows({ ? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause') : translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume') } - onSelect={() => onToggle(automation)} + onSelect={() => onToggle(row)} /> } label={translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')} variant="destructive" - onSelect={() => onDelete(automation)} + onSelect={() => onDelete(row)} /> ) return ( - +
{ // Why: Radix portals menus out of the row DOM, but React still @@ -177,14 +231,14 @@ export function AutomationListLocalRows({ if (isPortaledRowMenuClick(event)) { return } - onSelect(automation.id) + onSelect(row.key) }} onKeyDown={(event) => { if (!isRowActivationKey(event)) { return } event.preventDefault() - onSelect(automation.id) + onSelect(row.key) }} className={cn( AUTOMATIONS_TABLE_GRID_CLASS, @@ -199,6 +253,9 @@ export function AutomationListLocalRows({ {projectLabel} + + {hostLabel} + {nextRunLabel} @@ -235,10 +292,10 @@ export function AutomationListLocalRows({ { - if (automationRunAvailability.canRunNow) { - onRunNow(automation) + if (canRunNow) { + onRunNow(row) } }} > @@ -252,11 +309,14 @@ export function AutomationListLocalRows({ : automationRunAvailability.message} - onEdit(automation)}> + onEdit(row)}> {translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} - onToggle(automation)}> + onToggle(row)} + > {automation.enabled ? ( ) : ( @@ -273,7 +333,11 @@ export function AutomationListLocalRows({ )} - onDelete(automation)}> + onDelete(row)} + > {translate( 'auto.components.automations.AutomationsPage.15e0bfb13b', @@ -319,18 +383,20 @@ function MenuRunItem({ } function MenuItem({ + disabled, icon, label, onSelect, variant }: { + disabled?: boolean icon: React.ReactNode label: string onSelect: () => void variant?: 'destructive' }): React.JSX.Element { return ( - + {icon} {label} diff --git a/src/renderer/src/components/automations/AutomationListSortHeader.tsx b/src/renderer/src/components/automations/AutomationListSortHeader.tsx deleted file mode 100644 index 2c24a344328..00000000000 --- a/src/renderer/src/components/automations/AutomationListSortHeader.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React from 'react' -import { ArrowDown, ArrowUp } from 'lucide-react' -import { cn } from '@/lib/utils' -import { translate } from '@/i18n/i18n' -import type { AutomationListSort, AutomationListSortField } from './automation-list-view' - -export function AutomationListSortHeader({ - field, - label, - sort, - onSort -}: { - field: AutomationListSortField - label: string - sort: AutomationListSort | null - onSort: (field: AutomationListSortField) => void -}): React.JSX.Element { - const active = sort?.field === field - const direction = active ? sort.direction : null - // Why: one interpolated key per direction — word order and punctuation around - // the column name differ per language. - const sortedLabel = - direction === 'asc' - ? translate( - 'auto.components.automations.AutomationListSortHeader.sortedAscending', - '{{value0}}, sorted ascending', - { value0: label } - ) - : direction === 'desc' - ? translate( - 'auto.components.automations.AutomationListSortHeader.sortedDescending', - '{{value0}}, sorted descending', - { value0: label } - ) - : null - return ( - - ) -} diff --git a/src/renderer/src/components/automations/AutomationListTableHeader.tsx b/src/renderer/src/components/automations/AutomationListTableHeader.tsx index 11d412f5412..d76c749927d 100644 --- a/src/renderer/src/components/automations/AutomationListTableHeader.tsx +++ b/src/renderer/src/components/automations/AutomationListTableHeader.tsx @@ -1,45 +1,26 @@ import React from 'react' -import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout' import { LIST_TABLE_HEADER_CLASS } from '@/lib/list-table-layout' -import { AutomationListSortHeader } from './AutomationListSortHeader' -import type { AutomationListSort, AutomationListSortField } from './automation-list-view' -export function AutomationListTableHeader({ - sort, - onSort -}: { - sort: AutomationListSort | null - onSort: (field: AutomationListSortField) => void -}): React.JSX.Element { +export function AutomationListTableHeader(): React.JSX.Element { + const labels = [ + ['auto.components.automations.AutomationsPage.tableName', 'Name'], + ['auto.components.automations.AutomationDetail.18763ded26', 'Schedule'], + ['auto.components.automations.AutomationsPage.tableProject', 'Project'], + ['auto.components.automations.AutomationsPage.tableHost', 'Host'], + ['auto.components.automations.AutomationDetail.578ff46987', 'Next run'], + ['auto.components.automations.AutomationsPage.tableLastRun', 'Last run'], + ['auto.components.automations.AutomationsPage.tableStatus', 'Status'], + ['auto.components.automations.AutomationDetail.2df8970cd5', 'Agent'] + ] as const return ( -
- - - {translate('auto.components.automations.AutomationDetail.18763ded26', 'Schedule')} - - - {translate('auto.components.automations.AutomationsPage.tableProject', 'Project')} - - - {translate('auto.components.automations.AutomationDetail.578ff46987', 'Next run')} - - - {translate('auto.components.automations.AutomationsPage.tableStatus', 'Status')} - - {translate('auto.components.automations.AutomationDetail.2df8970cd5', 'Agent')} - +
+ {labels.map(([key, fallback], index) => ( + + {translate(key, fallback)} + + ))} {translate('auto.components.automations.AutomationsPage.tableActions', 'Actions')} diff --git a/src/renderer/src/components/automations/AutomationListToolbar.tsx b/src/renderer/src/components/automations/AutomationListToolbar.tsx deleted file mode 100644 index 260a5da573f..00000000000 --- a/src/renderer/src/components/automations/AutomationListToolbar.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import React from 'react' -import { Plus, RefreshCw } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { cn } from '@/lib/utils' -import { translate } from '@/i18n/i18n' -import { clampAutomationListSearchQueryInput } from './automation-list-search' -import type { AutomationListFilter } from './automation-list-view' -import type { AutomationListArrowKey } from './automation-list-keyboard-navigation' -import { AutomationListFilterMenu, AutomationListFilterPills } from './AutomationListFilterMenu' -import { AutomationListSearchField } from './AutomationListSearchField' -import type { AutomationTemplate } from './automation-templates' - -export function AutomationListToolbar({ - listSearchQuery, - isListSearchQueryTooLarge, - onListSearchQueryChange, - onSearchArrowNavigate, - filter, - onFilterChange, - onRefresh, - isRefreshing, - openCreateDialog, - canCreateAutomation -}: { - listSearchQuery: string - isListSearchQueryTooLarge: boolean - onListSearchQueryChange: (query: string) => void - onSearchArrowNavigate: (key: AutomationListArrowKey) => void - filter: AutomationListFilter - onFilterChange: (filter: AutomationListFilter) => void - onRefresh: () => void - isRefreshing: boolean - openCreateDialog: (template?: AutomationTemplate) => void - canCreateAutomation: boolean -}): React.JSX.Element { - return ( -
-
-
- - onListSearchQueryChange(clampAutomationListSearchQueryInput(query)) - } - onClear={() => onListSearchQueryChange('')} - onArrowNavigate={onSearchArrowNavigate} - /> - - - - - - - {translate( - 'auto.components.automations.AutomationsPage.19a6e30eae', - 'Refresh automations' - )} - - -
- -
- -
- ) -} diff --git a/src/renderer/src/components/automations/AutomationOwnerConflictNotice.test.tsx b/src/renderer/src/components/automations/AutomationOwnerConflictNotice.test.tsx new file mode 100644 index 00000000000..8b9d0d8fa33 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationOwnerConflictNotice.test.tsx @@ -0,0 +1,119 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + AutomationOwnerConflictNotice, + ownerConflictNotice, + actionBlockNotice +} from './AutomationOwnerConflictNotice' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +describe('AutomationOwnerConflictNotice', () => { + it('renders nothing when notice is null', () => { + act(() => { + root.render() + }) + expect(container.querySelector('[role="alert"]')).toBeNull() + }) + + it('renders warning notice message and icon', () => { + const notice = ownerConflictNotice({ + code: 'automation_owner_changed', + message: 'Editing automations on this host requires a newer Orca server.', + recovery: 'update-server' + }) + + act(() => { + root.render() + }) + + const alert = container.querySelector('[role="alert"][data-testid="automation-owner-conflict"]') + expect(alert).not.toBeNull() + expect(alert?.textContent).toContain( + 'Editing automations on this host requires a newer Orca server.' + ) + expect(alert?.querySelector('svg')).not.toBeNull() + }) + + it('renders recovery button when onRecover is provided and handles click', () => { + const onRecover = vi.fn() + const notice = ownerConflictNotice({ + code: 'automation_owner_changed', + message: 'Host needs update', + recovery: 'update-server' + }) + + act(() => { + root.render() + }) + + const button = container.querySelector('button') + expect(button?.textContent).toBe('Update server') + + act(() => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(onRecover).toHaveBeenCalledWith('update-server') + }) + + it('renders dismiss button when onDismiss is provided and handles click', () => { + const onDismiss = vi.fn() + const notice = actionBlockNotice({ + reason: 'orphan', + message: 'Action blocked', + recovery: null + }) + + act(() => { + root.render() + }) + + const button = container.querySelector('button') + expect(button?.textContent).toBe('Dismiss') + + act(() => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(onDismiss).toHaveBeenCalledTimes(1) + }) + + it('renders both recovery and dismiss buttons when both are configured', () => { + const onRecover = vi.fn() + const onDismiss = vi.fn() + const notice = ownerConflictNotice({ + code: 'automation_owner_changed', + message: 'Host conflict', + recovery: 'retry' + }) + + act(() => { + root.render( + + ) + }) + + const buttons = container.querySelectorAll('button') + expect(buttons.length).toBe(2) + expect(buttons[0]?.textContent).toBe('Retry') + expect(buttons[1]?.textContent).toBe('Dismiss') + }) +}) diff --git a/src/renderer/src/components/automations/AutomationOwnerConflictNotice.tsx b/src/renderer/src/components/automations/AutomationOwnerConflictNotice.tsx new file mode 100644 index 00000000000..8cebb710b55 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationOwnerConflictNotice.tsx @@ -0,0 +1,88 @@ +import React from 'react' +import { TriangleAlert } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { + recoveryActionLabel, + type AutomationHostRecoveryAction +} from './automation-host-status-descriptors' +import type { AutomationOwnerConflict } from './automation-owner-action-runner' +import type { AutomationActionBlock } from './automation-captured-owner' + +/** + * An owner conflict shown where the user was working, not as a toast: the + * request performed nothing, so the message has to survive long enough to be + * read and acted on. It offers a recovery action only when one exists — a host + * that was deregistered gets a plain explanation rather than a button that + * cannot help. + */ + +type AutomationOwnerNotice = { + message: string + recovery: AutomationHostRecoveryAction | null +} + +export function ownerConflictNotice(conflict: AutomationOwnerConflict): AutomationOwnerNotice { + return { message: conflict.message, recovery: conflict.recovery } +} + +export function actionBlockNotice(block: AutomationActionBlock): AutomationOwnerNotice { + return { message: block.message, recovery: block.recovery } +} + +type AutomationOwnerConflictNoticeProps = { + notice: AutomationOwnerNotice | null + onRecover?: (action: AutomationHostRecoveryAction) => void + onDismiss?: () => void + className?: string +} + +export function AutomationOwnerConflictNotice({ + notice, + onRecover, + onDismiss, + className +}: AutomationOwnerConflictNoticeProps): React.JSX.Element | null { + if (!notice) { + return null + } + const recovery = notice.recovery + return ( +
+ + {notice.message} +
+ {recovery && onRecover ? ( + + ) : null} + {onDismiss ? ( + + ) : null} +
+
+ ) +} diff --git a/src/renderer/src/components/automations/AutomationRunHistory.test.tsx b/src/renderer/src/components/automations/AutomationRunHistory.test.tsx new file mode 100644 index 00000000000..7742f74b26a --- /dev/null +++ b/src/renderer/src/components/automations/AutomationRunHistory.test.tsx @@ -0,0 +1,132 @@ +// @vitest-environment happy-dom + +/** + * Coalescing folds consecutive identical refusals into one row, which is a large + * improvement over the hundred rows it replaced — but the surviving row carries + * the *first* occurrence's timestamp. Without the fold count and the latest + * occurrence, a failure that is still happening every hour reads as one thing + * that happened once, days ago. + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AutomationRun } from '../../../../shared/automations-types' +import { AutomationRunHistory } from './AutomationRunHistory' +import { makeRun } from './automations-page-fixtures' + +const roots: Root[] = [] + +const FIRST = Date.UTC(2026, 7, 9, 14, 0) +const LATEST = Date.UTC(2026, 7, 11, 9, 0) + +async function render(overrides: Partial): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render( + + ) + }) + return container +} + +async function renderFailure( + onRecoverHistory: (action: 'retry' | 'reconnect' | 'update-server') => void +): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render( + + ) + }) + return container +} + +function occurrences(container: HTMLDivElement): string | null { + return container.querySelector('[data-testid="automation-run-occurrences"]')?.textContent ?? null +} + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true +}) + +afterEach(async () => { + await act(async () => { + roots.splice(0).forEach((root) => root.unmount()) + }) + document.body.innerHTML = '' +}) + +describe('AutomationRunHistory occurrences', () => { + it('says how many times a folded row stands for and when it last happened', async () => { + const container = await render({ occurrenceCount: 12, lastOccurrenceAt: LATEST }) + + expect(occurrences(container)).toContain('12 times') + // The recency is the part that answers "is this over?" — the row's own date + // is the first occurrence and cannot. + expect(occurrences(container)).toContain('most recently') + expect(occurrences(container)).not.toContain(String(LATEST)) + }) + + it('adds nothing to a row that stands for a single occurrence', async () => { + const container = await render({}) + + expect(occurrences(container)).toBeNull() + }) + + it('adds nothing when a host reported a count of one', async () => { + const container = await render({ occurrenceCount: 1 }) + + expect(occurrences(container)).toBeNull() + }) + + it('still reports the count when an older host folded without a timestamp', async () => { + const container = await render({ occurrenceCount: 4 }) + + expect(occurrences(container)).toContain('4 times') + expect(occurrences(container)).not.toContain('most recently') + }) +}) + +describe('AutomationRunHistory unanswered history', () => { + it('states the failure instead of reporting zero runs', async () => { + const container = await renderFailure(vi.fn()) + + expect(container.textContent).not.toContain('No runs yet.') + // "0 runs" is a count of something nobody managed to read. + expect(container.textContent).not.toContain('0 runs') + expect(container.textContent).toContain('Run history could not be loaded') + expect(container.textContent).toContain('web-01 is not connected') + }) + + it('offers the recovery the failure named rather than a dead end', async () => { + const onRecoverHistory = vi.fn() + const container = await renderFailure(onRecoverHistory) + const button = container.querySelector('[data-testid="automation-owner-conflict"] button') + + expect(button?.textContent).toBe('Reconnect') + await act(async () => { + ;(button as HTMLButtonElement).click() + }) + + expect(onRecoverHistory).toHaveBeenCalledWith('reconnect') + }) +}) diff --git a/src/renderer/src/components/automations/AutomationRunHistory.tsx b/src/renderer/src/components/automations/AutomationRunHistory.tsx index 323accb7259..cd2ece0d7dc 100644 --- a/src/renderer/src/components/automations/AutomationRunHistory.tsx +++ b/src/renderer/src/components/automations/AutomationRunHistory.tsx @@ -13,13 +13,20 @@ import { formatAutomationTokens, getAutomationUsageStatusLabel } from './automation-usage-model' +import { automationRunOccurrenceLabel } from './automation-run-occurrences' import { getAutomationRunWorkspaceDisplay } from './automation-run-workspace-display' +import { AutomationOwnerConflictNotice } from './AutomationOwnerConflictNotice' +import type { AutomationActionNotice } from './automation-row-action-dispatch' +import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors' import { translate } from '@/i18n/i18n' type AutomationRunHistoryProps = { runs: AutomationRun[] automationId: string worktreeMap: ReadonlyMap + /** Set when the history read failed; the runs below are unknown, not zero. */ + notice?: AutomationActionNotice | null + onRecoverHistory?: (action: AutomationHostRecoveryAction) => void onOpenRun: (run: AutomationRun) => void } @@ -27,6 +34,8 @@ export function AutomationRunHistory({ runs, automationId, worktreeMap, + notice, + onRecoverHistory, onOpenRun }: AutomationRunHistoryProps): React.JSX.Element { const [selectedRunState, setSelectedRunState] = useState<{ @@ -51,7 +60,8 @@ export function AutomationRunHistory({
{translate('auto.components.automations.AutomationRunHistory.53fc5f07ab', 'Run history')}
-
{runCountLabel}
+ {/* A failed read knows no counts; "0 runs" would answer a question nobody asked the host. */} + {notice ? null :
{runCountLabel}
}
@@ -79,6 +89,7 @@ export function AutomationRunHistory({ worktree: runWorktree }) const usageLabel = getAutomationUsageStatusLabel(run.usage) + const occurrenceLabel = automationRunOccurrenceLabel(run) return ( ) })} - {runs.length === 0 ? ( + {notice ? ( +
+

+ {translate( + 'auto.components.automations.AutomationRunHistory.historyUnavailable', + 'Run history could not be loaded for this automation.' + )} +

+ +
+ ) : runs.length === 0 ? (
{translate( 'auto.components.automations.AutomationRunHistory.402651bfb6', diff --git a/src/renderer/src/components/automations/AutomationTemplateEmptyState.tsx b/src/renderer/src/components/automations/AutomationTemplateEmptyState.tsx new file mode 100644 index 00000000000..e0c9cbf9184 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationTemplateEmptyState.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import { Plus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import { getAutomationTemplates, type AutomationTemplate } from './automation-templates' + +type AutomationTemplateEmptyStateProps = { + onOpenCreate: (template?: AutomationTemplate) => void +} + +export function AutomationTemplateEmptyState({ + onOpenCreate +}: AutomationTemplateEmptyStateProps): React.JSX.Element { + return ( +
+
+ {translate( + 'auto.components.automations.AutomationsPage.d207ab4c25', + 'Start from a template' + )} +
+ {getAutomationTemplates().map((template) => ( + + ))} + +
+ ) +} diff --git a/src/renderer/src/components/automations/AutomationsDetailPane.tsx b/src/renderer/src/components/automations/AutomationsDetailPane.tsx index 4de2a9ed795..f596c21c36b 100644 --- a/src/renderer/src/components/automations/AutomationsDetailPane.tsx +++ b/src/renderer/src/components/automations/AutomationsDetailPane.tsx @@ -20,6 +20,7 @@ import { AutomationRunHistory } from './AutomationRunHistory' import { ExternalAutomationManagers } from './ExternalAutomationManagers' import type { FetchExternalAutomationRuns } from './ExternalAutomationRunTable' import type { ExternalAutomationListEntry } from './external-automation-list-entries' +import type { ExternalAutomationScope } from './external-automation-scope-client' import { formatExternalDate, getExternalProviderLabel, @@ -33,6 +34,8 @@ import { getAutomationRunStatusVariant } from './automation-page-parts' import { getAutomationRunContent } from './automation-run-content' +import type { AutomationActionNotice } from './automation-row-action-dispatch' +import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors' import type { AutomationTargetAvailability } from './automation-target-availability' import type { AutomationRunViewState } from './automation-run-view-state' import type { AutomationRunWorkspaceDisplay } from './automation-run-workspace-display' @@ -45,6 +48,8 @@ type AutomationsDetailPaneProps = { selectedExternalRunPage: SelectedExternalRunPage | null selectedAutomationRunPage: AutomationRun | null selectedRuns: AutomationRun[] + /** Set when the selected automation's history read failed; its runs are unknown. */ + selectedRunsNotice: AutomationActionNotice | null activePaneTab: AutomationPaneTab relativeNow: number externalActionKey: string | null @@ -65,14 +70,19 @@ type AutomationsDetailPaneProps = { requestExternalAction: ( manager: ExternalAutomationManager, job: ExternalAutomationJob, - action: ExternalAutomationAction + action: ExternalAutomationAction, + scope: ExternalAutomationScope ) => void openExternalRunPage: ( manager: ExternalAutomationManager, job: ExternalAutomationJob, run: ExternalAutomationRun ) => void - openEditExternalDialog: (manager: ExternalAutomationManager, job: ExternalAutomationJob) => void + openEditExternalDialog: ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + scope: ExternalAutomationScope + ) => void runNow: (automation: Automation) => void openEditDialog: (automation: Automation) => void toggleAutomation: (automation: Automation) => void @@ -81,6 +91,7 @@ type AutomationsDetailPaneProps = { openRunWorkspace: (run: AutomationRun) => void openAutomationRunPage: (run: AutomationRun) => void onBackToList: () => void + recoverSelectedRuns: (action: AutomationHostRecoveryAction) => void } export function AutomationsDetailPane({ @@ -89,6 +100,7 @@ export function AutomationsDetailPane({ selectedExternalRunPage, selectedAutomationRunPage, selectedRuns, + selectedRunsNotice, activePaneTab, relativeNow, externalActionKey, @@ -116,7 +128,8 @@ export function AutomationsDetailPane({ rerunAutomationRun, openRunWorkspace, openAutomationRunPage, - onBackToList + onBackToList, + recoverSelectedRuns }: AutomationsDetailPaneProps): React.JSX.Element { return (
@@ -150,8 +163,13 @@ export function AutomationsDetailPane({ ) : ( diff --git a/src/renderer/src/components/automations/AutomationsListPanel.test.tsx b/src/renderer/src/components/automations/AutomationsListPanel.test.tsx new file mode 100644 index 00000000000..ab0be05df3b --- /dev/null +++ b/src/renderer/src/components/automations/AutomationsListPanel.test.tsx @@ -0,0 +1,180 @@ +// @vitest-environment happy-dom + +/** + * The list panel's persistent chrome: the picker and the search field stay put + * across loading, empty, no-match, and failure states, so a refresh that briefly + * returns no rows cannot take the query and the caret with it. + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { AutomationsListPanel } from './AutomationsListPanel' +import { EMPTY_AUTOMATION_LIST_FILTER } from './automation-list-view' +import type { AutomationHostCatalogView } from './use-automation-host-catalog' +import { makeAutomation, makeAutomationListRow } from './automations-page-fixtures' +import type { AutomationListRow } from './automation-list-row-identity' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +const HOST_CATALOG = { + catalog: { entries: [], byStableKey: new Map(), hydration: {} }, + entries: [], + resolution: { + effective: { kind: 'all' }, + entry: null, + status: 'all', + announceFallback: false + }, + rows: { rows: [], automations: [], capturedOwners: new Map(), groups: [], answered: true }, + loadCounts: { failedHostCount: 0, totalHostCount: 1 }, + selectHost: () => undefined, + recover: () => undefined, + refreshHosts: () => undefined, + notifyAuthorityChange: () => undefined +} as unknown as AutomationHostCatalogView + +function renderPanel( + rows: readonly AutomationListRow[], + query: string, + onQueryChange: (next: string) => void = () => undefined, + uncheckedNotice: string | null = null +): void { + act(() => { + root.render( + + 0} + hasFilteredListItems={rows.length > 0} + listFilter={EMPTY_AUTOMATION_LIST_FILTER} + onListFilterChange={() => undefined} + listSearchQuery={query} + isListSearchQueryTooLarge={false} + onListSearchQueryChange={onQueryChange} + searchCounts={{ + hostRowCount: rows.length, + visibleRowCount: rows.length, + searchActive: query !== '' + }} + hostCatalog={HOST_CATALOG} + externalManagersUncheckedNotice={uncheckedNotice} + onSelectHost={() => undefined} + onRecoverHost={() => undefined} + filteredRows={rows} + filteredExternalAutomationEntries={[]} + selectedRowKey={null} + selectedExternalKey={null} + relativeNow={0} + repoMap={new Map()} + worktreeMap={new Map()} + projectHostSetups={[]} + sshConnectionStates={new Map()} + runtimeStatusByEnvironmentId={new Map()} + hostTargetFor={() => null} + automationSourceHostAvailabilityByRowKey={new Map()} + isActionEnabled={() => true} + externalActionKey={null} + selectAutomationRow={() => undefined} + selectExternalKey={() => undefined} + setActivePaneTab={() => undefined} + runNow={() => undefined} + openEditDialog={() => undefined} + toggleAutomation={() => undefined} + requestDeleteAutomation={() => undefined} + requestExternalAction={() => undefined} + openEditExternalDialog={() => undefined} + openCreateDialog={() => undefined} + onOpenDetail={() => undefined} + onRefresh={() => undefined} + isRefreshing={false} + /> + + ) + }) +} + +function searchField(): HTMLInputElement | null { + return container.querySelector('input[aria-label="Search automations"]') +} + +describe('AutomationsListPanel search field persistence', () => { + it('keeps the search field on screen when the host has no rows', () => { + renderPanel([], '') + + expect(searchField()).not.toBeNull() + }) + + it('survives a refresh tick that momentarily empties the list', () => { + const row = makeAutomationListRow() + renderPanel([row], 'night') + const before = searchField() + before?.focus() + + renderPanel([], 'night') + + // Identity, not presence: a remount is what loses the caret and the query. + expect(searchField()).toBe(before) + expect(searchField()?.value).toBe('night') + expect(document.activeElement).toBe(before) + }) +}) + +describe('AutomationsListPanel unchecked hosts', () => { + it('shows a host it could not check instead of an unqualified empty list', () => { + renderPanel( + [], + '', + () => undefined, + 'External automation managers on web-01 could not be checked.' + ) + + expect(container.textContent).toContain( + 'External automation managers on web-01 could not be checked.' + ) + }) + + it('says nothing about unchecked hosts when there are none', () => { + renderPanel([], '') + + expect(container.textContent).not.toContain('could not be checked') + }) +}) + +describe('AutomationsListPanel flat table layout', () => { + it('renders table headers including Host column and displays row with host cell', () => { + const row = makeAutomationListRow({ + hostLabel: 'Remote Linux', + automation: makeAutomation({ + id: 'auto-1', + name: 'Nightly Sync', + agentId: 'codex', + rrule: 'FREQ=DAILY;BYHOUR=2;BYMINUTE=0', + nextRunAt: 10000, + lastRunAt: 5000, + enabled: true + }) + }) + renderPanel([row], '') + + expect(container.textContent).toContain('Name') + expect(container.textContent).toContain('Schedule') + expect(container.textContent).toContain('Project') + expect(container.textContent).toContain('Host') + expect(container.textContent).toContain('Nightly Sync') + expect(container.textContent).toContain('Remote Linux') + }) +}) diff --git a/src/renderer/src/components/automations/AutomationsListPanel.tsx b/src/renderer/src/components/automations/AutomationsListPanel.tsx index b4b44dd28e3..93a829b8257 100644 --- a/src/renderer/src/components/automations/AutomationsListPanel.tsx +++ b/src/renderer/src/components/automations/AutomationsListPanel.tsx @@ -1,85 +1,110 @@ -import React from 'react' -import { Plus } from 'lucide-react' +import React, { useRef } from 'react' +import { Plus, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import type { - Automation, AutomationRun, ExternalAutomationAction, ExternalAutomationJob, ExternalAutomationManager } from '../../../../shared/automations-types' +import type { AutomationHostFilter } from '../../../../shared/automation-host-filter' import type { SshConnectionState } from '../../../../shared/ssh-types' import type { ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import type { RuntimeStatus } from '../../../../shared/runtime-types' import type { TaskSourceHostAvailability } from '../task-source-context-summary' +import type { AutomationRowAction } from './automation-captured-owner' import type { AutomationHostTarget } from './automation-host-client' -import type { AutomationPaneTab } from './automation-page-state' -import { getAutomationTemplates, type AutomationTemplate } from './automation-templates' -import { AutomationListLocalRows } from './AutomationListLocalRows' -import { AutomationListExternalRows } from './AutomationListExternalRows' -import { LIST_TABLE_CONTAINER_CLASS } from '@/lib/list-table-layout' -import { translate } from '@/i18n/i18n' -import type { - AutomationListFilter, - AutomationListSort, - AutomationListSortField, - AutomationListViewItem -} from './automation-list-view' -import { AutomationListTableHeader } from './AutomationListTableHeader' -import { AutomationListToolbar } from './AutomationListToolbar' -import { indexLatestAutomationRuns } from './automation-list-last-run' +import { clampAutomationListSearchQueryInput } from './automation-list-search' import { getAutomationListArrowNavigationTarget, type AutomationListArrowKey } from './automation-list-keyboard-navigation' +import type { AutomationListRow } from './automation-list-row-identity' +import type { AutomationPaneTab } from './automation-page-state' +import { AutomationListSearchField } from './AutomationListSearchField' +import { AutomationListFilterMenu, AutomationListFilterPills } from './AutomationListFilterMenu' +import { isAutomationListFilterActive, type AutomationListFilter } from './automation-list-view' +import { automationHostFilterStableKey } from '../../../../shared/automation-host-filter' +import type { AutomationTemplate } from './automation-templates' +import type { ExternalAutomationListEntry } from './external-automation-list-entries' +import type { ExternalAutomationScope } from './external-automation-scope-client' +import { AutomationListLocalRows } from './AutomationListLocalRows' +import { AutomationListExternalRows } from './AutomationListExternalRows' +import { AutomationHostFilterNotice, AutomationHostLoadSummary } from './AutomationHostFilterNotice' +import { AutomationListEmptyView } from './AutomationListEmptyView' +import { resolveAutomationListEmptyState } from './automation-list-empty-state' +import type { AutomationListSearchCounts } from './use-automation-list-search' +import type { AutomationHostCatalogView } from './use-automation-host-catalog' +import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import { useAutomationListFocusRecovery } from './use-automation-list-focus-recovery' +import { LIST_TABLE_CONTAINER_CLASS } from '@/lib/list-table-layout' +import { translate } from '@/i18n/i18n' +import { AutomationTemplateEmptyState } from './AutomationTemplateEmptyState' +import { AutomationListTableHeader } from './AutomationListTableHeader' + +const TEMPLATE_EMPTY_STATES: ReadonlySet = new Set(['host-empty', 'all-hosts-empty']) +const EMPTY_AUTOMATION_RUNS: ReadonlyMap = new Map() type AutomationsListPanelProps = { hasListItems: boolean hasFilteredListItems: boolean - isListSearchActive: boolean - isListFilterActive: boolean listSearchQuery: string isListSearchQueryTooLarge: boolean onListSearchQueryChange: (query: string) => void - visibleItems: readonly AutomationListViewItem[] listFilter: AutomationListFilter onListFilterChange: (filter: AutomationListFilter) => void - listSort: AutomationListSort | null - onListSort: (field: AutomationListSortField) => void - // Why: use explicit ids, not resolved detail selection (which falls back to the - // first row) — list highlight should only mark a user-chosen row. - selectedId: string | null + searchCounts: AutomationListSearchCounts + hostCatalog: AutomationHostCatalogView + externalManagersUncheckedNotice: string | null + onSelectHost: (filter: AutomationHostFilter) => void + onRecoverHost: ( + action: AutomationHostRecoveryAction, + entry?: AutomationHostCatalogEntry | null + ) => void + filteredRows: readonly AutomationListRow[] + filteredExternalAutomationEntries: readonly ExternalAutomationListEntry[] + selectedRowKey: string | null selectedExternalKey: string | null - runs: readonly AutomationRun[] + selectedExternal?: ExternalAutomationListEntry | null relativeNow: number repoMap: ReadonlyMap worktreeMap: ReadonlyMap + repoForRow?: (row: AutomationListRow) => Repo | undefined + worktreeForRow?: (row: AutomationListRow, repo: Repo | undefined) => Worktree | undefined projectHostSetups: readonly ProjectHostSetup[] sshConnectionStates: ReadonlyMap> runtimeStatusByEnvironmentId: ReadonlyMap< string, { status: RuntimeStatus | null; checkedAt: number } > - automationHostTarget: AutomationHostTarget | null - automationSourceHostAvailabilityById: ReadonlyMap - hostLabelById: ReadonlyMap + hostTargetFor: (row: AutomationListRow) => AutomationHostTarget | null + automationSourceHostAvailabilityByRowKey: ReadonlyMap + hostLabelById?: ReadonlyMap + isActionEnabled: (row: AutomationListRow, action: AutomationRowAction) => boolean externalActionKey: string | null - selectAutomationId: (automationId: string | null) => void + selectAutomationRow: (rowKey: string | null) => void selectExternalKey: (externalKey: string | null) => void setActivePaneTab: (tab: AutomationPaneTab) => void - runNow: (automation: Automation) => void - openEditDialog: (automation: Automation) => void - toggleAutomation: (automation: Automation) => void - requestDeleteAutomation: (automation: Automation) => void + runNow: (row: AutomationListRow) => void + openEditDialog: (row: AutomationListRow) => void + toggleAutomation: (row: AutomationListRow) => void + requestDeleteAutomation: (row: AutomationListRow) => void requestExternalAction: ( manager: ExternalAutomationManager, job: ExternalAutomationJob, - action: ExternalAutomationAction + action: ExternalAutomationAction, + scope: ExternalAutomationScope + ) => void + openEditExternalDialog: ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + scope: ExternalAutomationScope ) => void - openEditExternalDialog: (manager: ExternalAutomationManager, job: ExternalAutomationJob) => void openCreateDialog: (template?: AutomationTemplate) => void canCreateAutomation: boolean onOpenDetail: () => void @@ -87,58 +112,73 @@ type AutomationsListPanelProps = { isRefreshing: boolean } -export function AutomationsListPanel({ - hasListItems, - hasFilteredListItems, - isListSearchActive, - isListFilterActive, - listSearchQuery, - isListSearchQueryTooLarge, - onListSearchQueryChange, - visibleItems, - listFilter, - onListFilterChange, - listSort, - onListSort, - selectedId, - selectedExternalKey, - runs, - relativeNow, - repoMap, - worktreeMap, - projectHostSetups, - sshConnectionStates, - runtimeStatusByEnvironmentId, - automationHostTarget, - automationSourceHostAvailabilityById, - hostLabelById, - externalActionKey, - selectAutomationId, - selectExternalKey, - setActivePaneTab, - runNow, - openEditDialog, - toggleAutomation, - requestDeleteAutomation, - requestExternalAction, - openEditExternalDialog, - openCreateDialog, - canCreateAutomation, - onOpenDetail, - onRefresh, - isRefreshing -}: AutomationsListPanelProps): React.JSX.Element { - // Why: one pass over runs for the whole list — each row renders its own - // component, so indexing per row would be O(rows × runs) on every re-render. - const lastRunByAutomationId = React.useMemo(() => indexLatestAutomationRuns(runs), [runs]) - const listScrollRef = React.useRef(null) - const pendingKeyboardScrollRef = React.useRef(false) - +export function AutomationsListPanel(props: AutomationsListPanelProps): React.JSX.Element { + const { + hasListItems, + hasFilteredListItems, + listSearchQuery, + isListSearchQueryTooLarge, + onListSearchQueryChange, + listFilter, + onListFilterChange, + searchCounts, + hostCatalog, + externalManagersUncheckedNotice, + onSelectHost, + onRecoverHost, + filteredRows, + filteredExternalAutomationEntries, + selectedRowKey, + selectedExternalKey, + relativeNow, + repoMap, + worktreeMap, + repoForRow, + worktreeForRow, + projectHostSetups, + sshConnectionStates, + runtimeStatusByEnvironmentId, + hostTargetFor, + automationSourceHostAvailabilityByRowKey, + hostLabelById, + isActionEnabled, + externalActionKey, + selectAutomationRow, + selectExternalKey, + setActivePaneTab, + runNow, + openEditDialog, + toggleAutomation, + requestDeleteAutomation, + requestExternalAction, + openEditExternalDialog, + openCreateDialog, + canCreateAutomation, + onOpenDetail, + onRefresh, + isRefreshing + } = props + const listRef = useRef(null) + // Hosts moved into the Filters menu, so its toolbar row is the focus fallback now. + const toolbarRef = useRef(null) + const pendingKeyboardScrollRef = useRef(false) + const rowKeys = React.useMemo(() => filteredRows.map((row) => row.key), [filteredRows]) + const visibleItems = React.useMemo( + () => [ + ...filteredRows.map((row) => ({ kind: 'local' as const, id: row.key })), + ...filteredExternalAutomationEntries.map((entry) => ({ + kind: 'external' as const, + id: entry.key + })) + ], + [filteredExternalAutomationEntries, filteredRows] + ) + useAutomationListFocusRecovery({ rowKeys, containerRef: listRef, fallbackRef: toolbarRef }) const handleSearchArrowNavigate = React.useCallback( (key: AutomationListArrowKey) => { const next = getAutomationListArrowNavigationTarget({ items: visibleItems, - selectedId, + selectedId: selectedRowKey, selectedExternalKey, key }) @@ -147,175 +187,211 @@ export function AutomationsListPanel({ } const alreadySelected = next.kind === 'local' - ? selectedExternalKey === null && selectedId === next.id + ? selectedExternalKey === null && selectedRowKey === next.id : selectedExternalKey === next.id if (alreadySelected) { - listScrollRef.current + listRef.current ?.querySelector('[data-current="true"]') ?.scrollIntoView({ block: 'nearest' }) return } pendingKeyboardScrollRef.current = true - // Why: arrows only move the highlight — detail is a full-page drill-in that - // unmounts this list, so opening it here would end navigation on first press. if (next.kind === 'local') { selectExternalKey(null) - selectAutomationId(next.id) + selectAutomationRow(next.id) } else { - selectAutomationId(null) + selectAutomationRow(null) selectExternalKey(next.id) - // External items have no runs tab; keep the pane tab valid for the next open. setActivePaneTab('overview') } }, [ - selectAutomationId, + selectAutomationRow, selectExternalKey, selectedExternalKey, - selectedId, + selectedRowKey, setActivePaneTab, visibleItems ] ) - React.useEffect(() => { if (!pendingKeyboardScrollRef.current) { return } pendingKeyboardScrollRef.current = false - listScrollRef.current - ?.querySelector('[data-current="true"]') - ?.scrollIntoView({ block: 'nearest' }) - }, [selectedExternalKey, selectedId]) + listRef.current?.querySelector('[data-current="true"]')?.scrollIntoView({ block: 'nearest' }) + }, [selectedExternalKey, selectedRowKey]) + const listFilterActive = isAutomationListFilterActive(listFilter) + // A leftover single-host query scope from before hosts moved into the Filters menu. + const legacyScopeStableKey = automationHostFilterStableKey(hostCatalog.resolution.effective) + const menuHostKeys = listFilter.hostStableKeys ?? [] + const selectedHostLabel = + menuHostKeys.length > 0 + ? menuHostKeys + .map( + (stableKey) => + hostCatalog.entries.find((entry) => entry.stableKey === stableKey)?.label ?? stableKey + ) + .join(', ') + : legacyScopeStableKey === null + ? null + : (hostCatalog.resolution.entry?.label ?? + translate('auto.components.automations.hostPicker.loadingHost', 'Loading host…')) + const emptyStateInput = { + resolution: hostCatalog.resolution, + ...searchCounts, + filterActive: listFilterActive + } + const emptyState = resolveAutomationListEmptyState(emptyStateInput) + const rowProps = { + selectedRowKey, + isSelectedLocal: selectedExternalKey === null, + lastRunByAutomationId: EMPTY_AUTOMATION_RUNS, + relativeNow, + repoMap, + worktreeMap, + repoForRow, + worktreeForRow, + projectHostSetups, + sshConnectionStates, + runtimeStatusByEnvironmentId, + hostTargetFor, + automationSourceHostAvailabilityByRowKey, + hostLabelById, + isActionEnabled, + onSelect: (rowKey: string) => { + selectExternalKey(null) + selectAutomationRow(rowKey) + onOpenDetail() + }, + onRunNow: runNow, + onEdit: openEditDialog, + onToggle: toggleAutomation, + onDelete: requestDeleteAutomation + } return (
- {!hasListItems ? ( -
-
-
- {translate( - 'auto.components.automations.AutomationsPage.d207ab4c25', - 'Start from a template' - )} -
- {getAutomationTemplates().map((template) => ( - - ))} - -
-
- ) : ( -
- +
+ + onListSearchQueryChange(clampAutomationListSearchQueryInput(query)) + } + onClear={() => onListSearchQueryChange('')} + onArrowNavigate={handleSearchArrowNavigate} /> - -
- {hasFilteredListItems ? ( - <> - -
- {visibleItems.map((item) => - item.kind === 'local' ? ( - { - selectExternalKey(null) - selectAutomationId(automationId) - onOpenDetail() - }} - onRunNow={runNow} - onEdit={openEditDialog} - onToggle={toggleAutomation} - onDelete={requestDeleteAutomation} - /> - ) : ( - { - selectAutomationId(null) - selectExternalKey(entryKey) - setActivePaneTab('overview') - onOpenDetail() - }} - onRequestAction={requestExternalAction} - onEdit={openEditExternalDialog} - /> - ) - )} -
- - ) : isListSearchActive || isListFilterActive ? ( -
- {translate( - 'auto.components.automations.AutomationsPage.noListMatches', - 'No automations match.' + + + +
+ onClick={onRefresh} + disabled={isRefreshing} + className="shrink-0 border border-border bg-background shadow-none hover:bg-muted/50" + > + + + + + {translate( + 'auto.components.automations.AutomationsPage.19a6e30eae', + 'Refresh automations' + )} + +
- )} + +
+ + {listFilterActive || legacyScopeStableKey !== null ? ( +
+ { + onListFilterChange({ ...listFilter, hostStableKeys: [] }) + onSelectHost({ kind: 'all' }) + }} + /> +
+ ) : null} + onRecoverHost(action)} + className="mb-2" + /> + + {externalManagersUncheckedNotice ? ( +

+ {externalManagersUncheckedNotice} +

+ ) : null} + +
+ {hasFilteredListItems ? ( + <> + +
+ + { + selectAutomationRow(null) + selectExternalKey(entryKey) + setActivePaneTab('overview') + onOpenDetail() + }} + onRequestAction={requestExternalAction} + onEdit={openEditExternalDialog} + /> +
+ + ) : ( + onRecoverHost(action)} + /> + )} + + {!hasListItems && TEMPLATE_EMPTY_STATES.has(emptyState.kind) ? ( + + ) : null} +
) } diff --git a/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx new file mode 100644 index 00000000000..8525545668a --- /dev/null +++ b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx @@ -0,0 +1,370 @@ +// @vitest-environment happy-dom + +/** + * Where a new automation lands. + * + * Every case here is one the page previously answered by inferring a host from + * the draft's run context: the storage authority came from whichever machine the + * workspace happened to execute on, which is exactly what required invariant 1 + * forbids. These pin the stated-destination contract instead — the destination + * is chosen, shown, re-checked at submit, and refused when it no longer holds. + */ + +import { act } from 'react' +import { describe, expect, it } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import { hostStableKey } from '../../../../shared/automation-owner-key' +import { unscopedAutomationListRows, type AutomationListRow } from './automation-list-row-identity' +import { + addRuntimeProject, + api, + installAutomationsPageHarness, + listedRow, + mocks, + renderPage, + runtimeHost, + RUNTIME_ID, + RUNTIME_REPO_ID, + RUNTIME_SELF_FILTER, + RUNTIME_WORKSPACE_ID, + scopedList, + settleHostQueries +} from './automations-page-test-harness' +import { makeAutomation, REPO_ID, WORKSPACE_ID } from './automations-page-fixtures' +import type { Repo } from '../../../../shared/repo-types' + +installAutomationsPageHarness() + +const SSH_TARGET_ID = 'ssh-target-1' +const SSH_REPO_ID = 'repo-ssh' +const SSH_HOST_KEY = hostStableKey({ + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId: SSH_TARGET_ID } +}) + +const DESKTOP_SELF_KEY = hostStableKey({ + authority: { kind: 'desktop' }, + selector: { kind: 'self' } +}) +const RUNTIME_SELF_KEY = hostStableKey({ + authority: { kind: 'runtime', environmentId: RUNTIME_ID }, + selector: { kind: 'self' } +}) + +/** A desktop-registered SSH host, with the generation that makes it fenceable. */ +function addSshHost(): void { + mocks.state.sshTargetLabels = new Map([[SSH_TARGET_ID, 'openclaw']]) + mocks.state.sshTargetGenerations = new Map([[SSH_TARGET_ID, 1]]) + mocks.state.sshConnectionStates = new Map([[SSH_TARGET_ID, { status: 'connected' }]]) +} + +/** A project checked out on that SSH host rather than locally. */ +function addSshProject(): void { + const repo = { + id: SSH_REPO_ID, + displayName: 'orca', + path: '/repos/orca', + badgeColor: '#222222', + addedAt: 1, + worktreeBaseRef: 'main', + connectionId: SSH_TARGET_ID + } as Repo + mocks.state.repos = [...(mocks.state.repos as Repo[]), repo] + mocks.repoMap.set(SSH_REPO_ID, repo) +} + +/** The runtime answers a create; without this the RPC double returns an empty result. */ +function runtimeCreateReturns(automation: Automation): void { + const previous = mocks.callRuntimeRpc.getMockImplementation() + mocks.callRuntimeRpc.mockImplementation( + async (target: unknown, method: string, params: unknown, options: unknown) => { + if (method === 'automation.create') { + return { automation } + } + return await previous?.(target, method, params, options) + } + ) +} + +async function openCreateDialogFor(projectId: string, workspaceId: string): Promise { + await act(async () => { + mocks.listPanel?.openCreateDialog() + }) + await act(async () => { + mocks.editorDialog?.onDraftChange((current) => ({ + ...(current as Record), + name: 'Sweep', + prompt: 'Do the sweep', + projectId, + workspaceMode: 'existing', + workspaceId + })) + }) +} + +async function save(): Promise { + await act(async () => { + mocks.editorDialog?.onSave() + }) +} + +function runtimeCreateCalls(): unknown[][] { + return mocks.callRuntimeRpc.mock.calls.filter((call) => call[1] === 'automation.create') +} + +describe('AutomationsPage create destination', () => { + it('creates on the selected runtime rather than under the desktop authority', async () => { + api.automations.list.mockResolvedValue([]) + scopedList([]) + runtimeHost([], []) + runtimeCreateReturns(makeAutomation({ id: 'a-new', name: 'Sweep' })) + addRuntimeProject() + mocks.state.automationHostFilter = RUNTIME_SELF_FILTER + + await renderPage() + await settleHostQueries() + await openCreateDialogFor(RUNTIME_REPO_ID, RUNTIME_WORKSPACE_ID) + await save() + + // The desktop is the client's own authority, never the selected host — and + // the id sent is the runtime's own, never a desktop repo id it cannot resolve. + expect(api.automations.create).not.toHaveBeenCalled() + expect(runtimeCreateCalls()).toHaveLength(1) + expect(runtimeCreateCalls()[0]?.[2]).toMatchObject({ + repo: RUNTIME_REPO_ID, + destination: { selector: { kind: 'self' } } + }) + }) + + it('refuses a desktop project for a runtime destination instead of sending its id', async () => { + // The repo_not_found bug: the desktop repo id can never resolve on the + // runtime, so the submit is refused here rather than by the remote host. + api.automations.list.mockResolvedValue([]) + scopedList([]) + runtimeHost([], []) + runtimeCreateReturns(makeAutomation({ id: 'a-new', name: 'Sweep' })) + mocks.state.automationHostFilter = RUNTIME_SELF_FILTER + + await renderPage() + await settleHostQueries() + await openCreateDialogFor(REPO_ID, WORKSPACE_ID) + await save() + + expect(api.automations.create).not.toHaveBeenCalled() + expect(runtimeCreateCalls()).toHaveLength(0) + expect(mocks.editorDialog?.notice?.message).toBeTruthy() + expect(mocks.editorDialog?.open).toBe(true) + }) + + it('refuses a runtime-owned project under the desktop destination', async () => { + api.automations.list.mockResolvedValue([]) + scopedList([]) + addRuntimeProject() + + await renderPage() + await settleHostQueries() + await openCreateDialogFor(RUNTIME_REPO_ID, RUNTIME_WORKSPACE_ID) + await save() + + // A repo with no connection ID is not evidence of local: this one is the + // runtime's, and the desktop's Self host cannot hold an automation for it. + expect(api.automations.create).not.toHaveBeenCalled() + expect(mocks.editorDialog?.notice?.message).toBeTruthy() + expect(mocks.editorDialog?.open).toBe(true) + }) + + it('requires an explicit host choice when All hosts spans more than one host', async () => { + api.automations.list.mockResolvedValue([]) + scopedList([]) + runtimeHost([], []) + runtimeCreateReturns(makeAutomation({ id: 'a-new', name: 'Sweep' })) + addRuntimeProject() + + await renderPage() + await settleHostQueries() + await openCreateDialogFor(REPO_ID, WORKSPACE_ID) + await save() + + expect(api.automations.create).not.toHaveBeenCalled() + expect(runtimeCreateCalls()).toHaveLength(0) + expect(mocks.editorDialog?.notice?.message).toBeTruthy() + expect(mocks.editorDialog?.createDestination?.entries.map((entry) => entry.stableKey)).toEqual( + expect.arrayContaining([DESKTOP_SELF_KEY, RUNTIME_SELF_KEY]) + ) + + await act(async () => { + mocks.editorDialog?.createDestination?.onSelect(RUNTIME_SELF_KEY) + }) + expect(mocks.editorDialog?.createDestination?.resolution.status).toBe('ready') + // The stated host stranded the desktop project; the draft moves to its own. + await act(async () => { + mocks.editorDialog?.onDraftChange((current) => ({ + ...(current as Record), + projectId: RUNTIME_REPO_ID, + workspaceId: RUNTIME_WORKSPACE_ID + })) + }) + await save() + + expect(runtimeCreateCalls()).toHaveLength(1) + expect(api.automations.create).not.toHaveBeenCalled() + }) + + it('offers only the projects the chosen host actually has', async () => { + api.automations.list.mockResolvedValue([]) + scopedList([]) + addSshHost() + addSshProject() + + await renderPage() + await settleHostQueries() + await act(async () => { + mocks.listPanel?.openCreateDialog() + }) + await act(async () => { + mocks.editorDialog?.createDestination?.onSelect(SSH_HOST_KEY) + }) + + // The local project shares this one's name, so offering both leaves the user + // no way to tell which is the one on that host. + expect(mocks.editorDialog?.repos?.map((repo) => repo.id)).toEqual([SSH_REPO_ID]) + }) + + it('moves a stranded project to one the newly chosen host has', async () => { + api.automations.list.mockResolvedValue([]) + scopedList([]) + addSshHost() + addSshProject() + + await renderPage() + await settleHostQueries() + await openCreateDialogFor(REPO_ID, WORKSPACE_ID) + expect(mocks.editorDialog?.draft?.projectId).toBe(REPO_ID) + + await act(async () => { + mocks.editorDialog?.createDestination?.onSelect(SSH_HOST_KEY) + }) + + // Keeping the local project selected only defers the same refusal to submit. + expect(mocks.editorDialog?.draft?.projectId).toBe(SSH_REPO_ID) + }) + + it('offers nothing rather than a stranded project when the host has no projects', async () => { + api.automations.list.mockResolvedValue([]) + scopedList([]) + addSshHost() + + await renderPage() + await settleHostQueries() + await act(async () => { + mocks.listPanel?.openCreateDialog() + }) + await act(async () => { + mocks.editorDialog?.createDestination?.onSelect(SSH_HOST_KEY) + }) + + expect(mocks.editorDialog?.repos).toEqual([]) + // Nothing is left selected to submit against a host that cannot hold it. + expect(mocks.editorDialog?.draft?.projectId).toBe('') + }) + + it('states the chosen host on the form before submit', async () => { + api.automations.list.mockResolvedValue([]) + scopedList([]) + runtimeHost([], []) + + await renderPage() + await settleHostQueries() + await openCreateDialogFor(REPO_ID, WORKSPACE_ID) + await act(async () => { + mocks.editorDialog?.createDestination?.onSelect(RUNTIME_SELF_KEY) + }) + + const resolution = mocks.editorDialog?.createDestination?.resolution + // The owner shown is the storage authority, not the workspace's run host. + expect(resolution?.status === 'ready' && resolution.entry.stableKey).toBe(RUNTIME_SELF_KEY) + expect(resolution?.status === 'ready' && resolution.authority).toEqual({ + kind: 'runtime', + environmentId: RUNTIME_ID, + pairingRevision: 4 + }) + }) + + it('fails closed when the destination host changes incarnation while the form is open', async () => { + api.automations.list.mockResolvedValue([]) + scopedList([]) + runtimeHost([], []) + runtimeCreateReturns(makeAutomation({ id: 'a-new', name: 'Sweep' })) + addRuntimeProject() + mocks.state.automationHostFilter = RUNTIME_SELF_FILTER + + const { rerender } = await renderPage() + await settleHostQueries() + await openCreateDialogFor(RUNTIME_REPO_ID, RUNTIME_WORKSPACE_ID) + + // The host re-paired while the form was open: same stable key, new owner. + mocks.state.runtimeEnvironments = [ + { id: RUNTIME_ID, name: 'GPU box', createdAt: 1, pairingRevision: 9 } + ] + await rerender() + await save() + + expect(runtimeCreateCalls()).toHaveLength(0) + expect(api.automations.create).not.toHaveBeenCalled() + expect(mocks.editorDialog?.notice?.message).toBeTruthy() + expect(mocks.editorDialog?.open).toBe(true) + }) +}) + +describe('AutomationsPage edit fencing', () => { + async function editAndSave(row: AutomationListRow): Promise { + await act(async () => { + void mocks.listPanel?.openEditDialog(row) + }) + await act(async () => { + mocks.editorDialog?.onDraftChange((current) => ({ + ...(current as Record), + name: 'Renamed' + })) + }) + await save() + } + + function updatePreconditions(): unknown[] { + return api.automations.update.mock.calls.map( + ([payload]) => (payload as { expectedOwner?: unknown }).expectedOwner + ) + } + + it('fences the save even when the record cannot be re-read', async () => { + const automation = makeAutomation({ id: 'a-1' }) + api.automations.list.mockResolvedValue([]) + scopedList([automation]) + + await renderPage() + await settleHostQueries() + // The row is gone from its host by the time the form is saved. + scopedList([]) + await editAndSave(listedRow(automation.id)) + + expect(api.automations.update).toHaveBeenCalled() + expect(updatePreconditions()).not.toContain(undefined) + }) + + it('refuses the save when nothing names the record’s host', async () => { + const automation = makeAutomation({ id: 'a-1' }) + // No host answered, so no owner was ever captured, and the record is gone + // from the one list the client can still address. + api.automations.listScoped.mockRejectedValue(new Error('offline')) + api.automations.list.mockResolvedValue([]) + + await renderPage() + await settleHostQueries() + // No host answered, so the page never listed a row for it; the pre-catalog + // projection is the only key the user could have clicked. + await editAndSave(unscopedAutomationListRows([automation])[0]!) + + expect(api.automations.update).not.toHaveBeenCalled() + expect(mocks.editorDialog?.notice?.message).toBeTruthy() + }) +}) diff --git a/src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx b/src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx new file mode 100644 index 00000000000..5c7225a9bd9 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx @@ -0,0 +1,126 @@ +// @vitest-environment happy-dom + +/** + * Row actions under a cross-authority ID collision. + * + * Two authorities may legitimately hold the same automation ID, so the copy the + * user acted on is named by its row, never by that ID. These pin the case a + * bare-ID owner map cannot express: the desktop's copy is listed first and the + * runtime's copy is listed second, so anything keyed by the ID alone answers + * with the runtime's owner and sends the desktop's row to the wrong machine. + */ + +import { act } from 'react' +import { describe, expect, it } from 'vitest' +import { + api, + installAutomationsPageHarness, + mocks, + renderPage, + runtimeHost, + scopedList, + SELF_PRECONDITION, + settleHostQueries +} from './automations-page-test-harness' +import { makeAutomation } from './automations-page-fixtures' + +installAutomationsPageHarness() + +/** Desktop and runtime both hold `a-1`; the runtime's row is committed last. */ +async function collidingHosts(): Promise { + runtimeHost([makeAutomation({ id: 'a-1', name: 'Remote nightly' })], []) + const desktop = makeAutomation({ id: 'a-1', name: 'Desktop nightly' }) + scopedList([desktop]) + api.automations.list.mockResolvedValue([desktop]) + mocks.state.selectedAutomationId = 'a-1' +} + +function selectDesktopRow(): string { + const row = mocks.listPanel?.filteredRows.find( + (candidate) => candidate.automation.name === 'Desktop nightly' + ) + expect(row).toBeDefined() + return row?.key ?? '' +} + +/** Every runtime-environment automation call, so a misrouted action cannot hide + * in the noise. Local-target calls are the desktop authority's own and excluded. */ +function runtimeAutomationCalls(): string[] { + return mocks.callRuntimeRpc.mock.calls + .filter((call) => (call[0] as { kind?: string } | null)?.kind === 'environment') + .map((call) => String(call[1])) + .filter((method) => method.startsWith('automation.')) +} + +describe('AutomationsPage row actions under a colliding automation id', () => { + it('runs the selected desktop row on the desktop, not on the runtime holding the same id', async () => { + await collidingHosts() + const { container } = await renderPage() + await settleHostQueries() + expect( + [...container.querySelectorAll('[data-testid="automation-row"]')].map( + (row) => row.textContent + ) + ).toEqual(['Desktop nightly', 'Remote nightly']) + + await act(async () => { + mocks.listPanel?.selectAutomationRow(selectDesktopRow()) + }) + expect(mocks.detailPane?.selected?.name).toBe('Desktop nightly') + mocks.callRuntimeRpc.mockClear() + await act(async () => { + mocks.detailPane?.runNow(mocks.detailPane.selected!) + }) + + expect(runtimeAutomationCalls()).not.toContain('automation.runNow') + expect(api.automations.runNow).toHaveBeenCalledWith({ + id: 'a-1', + expectedOwner: SELF_PRECONDITION + }) + }) + + it('pauses the selected desktop row on the desktop', async () => { + await collidingHosts() + await renderPage() + await settleHostQueries() + + await act(async () => { + mocks.listPanel?.selectAutomationRow(selectDesktopRow()) + }) + mocks.callRuntimeRpc.mockClear() + await act(async () => { + mocks.detailPane?.toggleAutomation(mocks.detailPane.selected!) + }) + + expect(api.automations.update).toHaveBeenCalledWith({ + id: 'a-1', + updates: { enabled: false }, + expectedOwner: SELF_PRECONDITION, + destination: undefined + }) + expect(runtimeAutomationCalls()).not.toContain('automation.update') + }) + + it('deletes the selected desktop row on the desktop', async () => { + await collidingHosts() + await renderPage() + await settleHostQueries() + + await act(async () => { + mocks.listPanel?.selectAutomationRow(selectDesktopRow()) + }) + await act(async () => { + mocks.detailPane?.requestDeleteAutomation(mocks.detailPane.selected!) + }) + mocks.callRuntimeRpc.mockClear() + await act(async () => { + mocks.deleteDialog?.onConfirm() + }) + + expect(api.automations.delete).toHaveBeenCalledWith({ + id: 'a-1', + expectedOwner: SELF_PRECONDITION + }) + expect(runtimeAutomationCalls()).not.toContain('automation.delete') + }) +}) diff --git a/src/renderer/src/components/automations/AutomationsPage.external-scope.test.tsx b/src/renderer/src/components/automations/AutomationsPage.external-scope.test.tsx new file mode 100644 index 00000000000..cd966dcbcd7 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationsPage.external-scope.test.tsx @@ -0,0 +1,170 @@ +// @vitest-environment happy-dom + +/** + * What the page actually asks the main process about external managers. + * + * The engine-level tests hand a pre-narrowed scope list to the client, so they + * prove the plumbing and nothing about the producer. These drive the page: the + * selection is the input and the IPC calls it issues are the observation. + */ + +import { describe, expect, it } from 'vitest' +import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref' +import { + api, + DESKTOP_SELF_OWNER, + installAutomationsPageHarness, + mocks, + renderPage, + runtimeHost, + RUNTIME_SELF_FILTER, + settleHostQueries +} from './automations-page-test-harness' +import { makeExternalManager } from './automations-page-fixtures' + +installAutomationsPageHarness() + +const DESKTOP_SELF_FILTER = { + kind: 'host' as const, + host: { authority: { kind: 'desktop' as const }, selector: { kind: 'self' as const } } +} + +function sshFilter(targetId: string): unknown { + return { + kind: 'host', + host: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId } } + } +} + +/** Registered SSH targets, each with the generation that makes it fenceable. */ +function withSshHosts(...targetIds: string[]): void { + mocks.state.sshTargetLabels = new Map(targetIds.map((targetId) => [targetId, targetId])) + mocks.state.sshTargetGenerations = new Map(targetIds.map((targetId) => [targetId, 1])) + mocks.state.sshConnectionStates = new Map( + targetIds.map((targetId) => [targetId, { status: 'connected' }]) + ) +} + +function probedOwners(): AutomationOwnerRef[] { + return api.automations.listExternalManagerForOwner.mock.calls.map( + (call) => (call[0] as { owner: AutomationOwnerRef }).owner + ) +} + +function lastRetainedOwners(): readonly AutomationOwnerRef[] { + const calls = api.automations.retainExternalScopes.mock.calls + return (calls.at(-1)?.[0] as { owners: readonly AutomationOwnerRef[] } | undefined)?.owners ?? [] +} + +function hermesOnly(): void { + api.automations.listExternalManagerForOwner.mockImplementation( + async ({ provider }: { provider: string }) => + provider === 'hermes' + ? { manager: makeExternalManager(), error: null, updatedAt: 1 } + : { manager: null, error: null, updatedAt: 1 } + ) +} + +describe('AutomationsPage external manager probes', () => { + it('probes no SSH host when the selection is the local desktop', async () => { + withSshHosts('staging', 'web-01', 'web-02') + mocks.state.automationHostFilter = DESKTOP_SELF_FILTER + hermesOnly() + + await renderPage() + await settleHostQueries() + + const sshProbes = probedOwners().filter((owner) => owner.selector.kind === 'ssh') + expect(sshProbes).toEqual([]) + expect(probedOwners()).toEqual([DESKTOP_SELF_OWNER, DESKTOP_SELF_OWNER]) + }) + + it('retains only the selected host, so probes elsewhere are cancelled', async () => { + withSshHosts('staging', 'web-01', 'web-02') + mocks.state.automationHostFilter = DESKTOP_SELF_FILTER + hermesOnly() + + await renderPage() + await settleHostQueries() + + expect(lastRetainedOwners()).toEqual([DESKTOP_SELF_OWNER]) + }) + + it('cancels the previous host as soon as the filter moves off it', async () => { + withSshHosts('staging') + mocks.state.automationHostFilter = DESKTOP_SELF_FILTER + hermesOnly() + + const { rerender } = await renderPage() + await settleHostQueries() + mocks.state.automationHostFilter = sshFilter('staging') + await rerender() + await settleHostQueries() + + expect(lastRetainedOwners()).toEqual([ + { + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId: 'staging', targetGeneration: 1 } + } + ]) + }) + + it('lists no external rows for a host whose managers it says are not listed', async () => { + runtimeHost([], []) + mocks.state.automationHostFilter = RUNTIME_SELF_FILTER + hermesOnly() + + await renderPage() + await settleHostQueries() + + expect(mocks.listPanel?.filteredExternalAutomationEntries).toEqual([]) + }) + + it('drops the previous host rows when the selection moves, not when the new probe lands', async () => { + withSshHosts('staging') + mocks.state.automationHostFilter = DESKTOP_SELF_FILTER + hermesOnly() + + const { rerender } = await renderPage() + await settleHostQueries() + expect(mocks.listPanel?.filteredExternalAutomationEntries.length).toBeGreaterThan(0) + + // The new host never answers, so anything still listed belongs to the old one. + api.automations.listExternalManagerForOwner.mockImplementation( + async () => await new Promise(() => undefined) + ) + mocks.state.automationHostFilter = sshFilter('staging') + await rerender() + await settleHostQueries() + + expect(mocks.listPanel?.filteredExternalAutomationEntries).toEqual([]) + }) + + it('reports a host it could not check rather than showing it as clean', async () => { + mocks.state.automationHostFilter = DESKTOP_SELF_FILTER + api.automations.listExternalManagerForOwner.mockImplementation( + async ({ provider }: { provider: string }) => + provider === 'hermes' + ? { manager: null, error: 'hermes probe failed: channel closed', updatedAt: 0 } + : { manager: null, error: null, updatedAt: 0 } + ) + + await renderPage() + await settleHostQueries() + + const desktopLabel = mocks.listPanel?.hostCatalog.entries[0]?.label + expect(mocks.listPanel?.externalManagersUncheckedNotice).toBe( + `External automation managers on ${desktopLabel} could not be checked.` + ) + }) + + it('says nothing about unchecked hosts when every probe answered', async () => { + mocks.state.automationHostFilter = DESKTOP_SELF_FILTER + hermesOnly() + + await renderPage() + await settleHostQueries() + + expect(mocks.listPanel?.externalManagersUncheckedNotice).toBeNull() + }) +}) diff --git a/src/renderer/src/components/automations/AutomationsPage.notice-recovery.test.tsx b/src/renderer/src/components/automations/AutomationsPage.notice-recovery.test.tsx new file mode 100644 index 00000000000..503d0877ed5 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationsPage.notice-recovery.test.tsx @@ -0,0 +1,121 @@ +// @vitest-environment happy-dom + +/** + * What a refused action's recovery button actually does. + * + * Both notices used to swallow every verb but Retry, so Update server and + * Reconnect rendered as buttons that performed nothing. These pin the routing: + * the verb runs against the host the refusal came from — the row's own owner, or + * the destination the create dialog captured — not the list's current filter. + */ + +import { act } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { + addRuntimeProject, + api, + installAutomationsPageHarness, + listedRow, + mocks, + renderPage, + runtimeHost, + RUNTIME_REPO_ID, + RUNTIME_SELF_FILTER, + RUNTIME_WORKSPACE_ID, + scopedList, + settleHostQueries +} from './automations-page-test-harness' +import { makeAutomation } from './automations-page-fixtures' + +installAutomationsPageHarness() + +const FENCING_REQUIRED = 'The host changed: automation_owner_fencing_required' + +/** Fails one runtime RPC the way an under-versioned server does, leaving the rest answering. */ +function runtimeRpcRefuses(method: string): void { + const previous = mocks.callRuntimeRpc.getMockImplementation() + mocks.callRuntimeRpc.mockImplementation( + async (target: unknown, called: string, params: unknown, options: unknown) => { + if (called === method) { + throw new Error(FENCING_REQUIRED) + } + return await previous?.(target, called, params, options) + } + ) +} + +function watchSettings(): { open: ReturnType; target: ReturnType } { + const open = vi.fn() + const target = vi.fn() + mocks.state.openSettingsPage = open + mocks.state.openSettingsTarget = target + return { open, target } +} + +describe('AutomationsPage notice recovery', () => { + it('sends the create dialog Update server to where that destination host is versioned', async () => { + api.automations.list.mockResolvedValue([]) + scopedList([]) + runtimeHost([], []) + runtimeRpcRefuses('automation.create') + // The runtime's own project: a desktop repo id is refused before any RPC. + addRuntimeProject() + mocks.state.automationHostFilter = RUNTIME_SELF_FILTER + const settings = watchSettings() + + await renderPage() + await settleHostQueries() + await act(async () => { + mocks.listPanel?.openCreateDialog() + }) + await act(async () => { + mocks.editorDialog?.onDraftChange((current) => ({ + ...(current as Record), + name: 'Sweep', + prompt: 'Do the sweep', + projectId: RUNTIME_REPO_ID, + workspaceMode: 'existing', + workspaceId: RUNTIME_WORKSPACE_ID + })) + }) + await act(async () => { + mocks.editorDialog?.onSave() + }) + + expect(mocks.editorDialog?.notice?.recovery).toBe('update-server') + await act(async () => { + mocks.editorDialog?.onNoticeRecover?.('update-server') + }) + + expect(settings.open).toHaveBeenCalled() + expect(settings.target).toHaveBeenCalledWith({ pane: 'servers', repoId: null }) + }) + + it('sends a row Update server to the row own host, not to the unfiltered list', async () => { + const runtimeAutomation = makeAutomation({ id: 'a-runtime', name: 'On the box' }) + api.automations.list.mockResolvedValue([]) + scopedList([]) + runtimeHost([runtimeAutomation], []) + runtimeRpcRefuses('automation.update') + const settings = watchSettings() + + const { container } = await renderPage() + await settleHostQueries() + await act(async () => { + mocks.listPanel?.toggleAutomation(listedRow(runtimeAutomation.id)) + }) + + const notice = container.querySelector('[data-testid="automation-owner-conflict"]') + const recover = [...(notice?.querySelectorAll('button') ?? [])].find( + (button) => button.textContent === 'Update server' + ) + expect(recover).toBeDefined() + await act(async () => { + recover?.click() + }) + + // All hosts selects no host, so only the row's captured owner names one. + expect(settings.open).toHaveBeenCalled() + expect(settings.target).toHaveBeenCalledWith({ pane: 'servers', repoId: null }) + }) +}) diff --git a/src/renderer/src/components/automations/AutomationsPage.refresh-selection.test.tsx b/src/renderer/src/components/automations/AutomationsPage.refresh-selection.test.tsx new file mode 100644 index 00000000000..523cc50df7d --- /dev/null +++ b/src/renderer/src/components/automations/AutomationsPage.refresh-selection.test.tsx @@ -0,0 +1,243 @@ +// @vitest-environment happy-dom + +/** + * What the page refetches, and whose row survives it. + * + * A refresh reaches one authority at a time, so re-deriving the selection or the + * run history from its answer drops — or worse, mislabels — every row another + * host holds. Under All hosts that is the normal case, not the edge case. + */ + +import { act } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { + api, + installAutomationsPageHarness, + mocks, + refreshOnFocus, + renderPage, + RUNTIME_SELF_FILTER, + runtimeHost, + scopedList, + SELF_PRECONDITION, + settleHostQueries +} from './automations-page-test-harness' +import { makeAutomation, makeRun } from './automations-page-fixtures' + +installAutomationsPageHarness() + +async function openAutomationRow(container: HTMLElement, name: string): Promise { + const row = [ + ...container.querySelectorAll('[data-testid="automation-row"]') + ].find((candidate) => candidate.textContent === name) + expect(row).toBeTruthy() + await act(async () => row?.click()) + await settleHostQueries() +} + +describe('AutomationsPage refresh', () => { + it('never asks a host for every run it has retained', async () => { + const run = makeRun({ id: 'run-1', automationId: 'a-1' }) + api.automations.list.mockResolvedValue([makeAutomation({ id: 'a-1' })]) + api.automations.listRuns.mockResolvedValue([run]) + + await renderPage() + + // Every call names one automation: a host's whole history is never the + // page's to hold, and asking for it made the cost scale with retention. + for (const [args] of api.automations.listRuns.mock.calls) { + expect(args?.automationId).toBeTruthy() + } + }) + + it('reads row usage from the authority projection rather than from runs', async () => { + const usageSummary = { + knownRuns: 2, + unavailableRuns: 0, + inputTokens: 900, + outputTokens: 300, + cacheTokens: 0, + reasoningOutputTokens: 34, + totalTokens: 1234, + estimatedCostUsd: 0.5 + } + api.automations.listScoped.mockResolvedValue({ + automations: [makeAutomation({ id: 'a-1' })], + items: [{ automationId: 'a-1', selector: { kind: 'self' }, usageSummary }], + orphanCount: 0 + }) + + await renderPage() + + expect(mocks.listPanel?.filteredRows[0]?.usageSummary).toEqual(usageSummary) + }) + + it('does not re-list through the active runtime just because one is selected', async () => { + mocks.state.settings = { + ...(mocks.state.settings as Record), + activeRuntimeEnvironmentId: 'gpu' + } + + await renderPage() + + // The ambient runtime is not evidence about any row. The desktop answers + // the unscoped arm; a runtime's rows arrive through its own catalog entry. + expect(api.automations.list).toHaveBeenCalled() + expect(mocks.callRuntimeRpc).not.toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'gpu' }, + 'automation.list', + undefined, + { timeoutMs: 15_000 } + ) + }) + + // The manual All-hosts refresh: without a control wired to it, a host whose + // entry stopped short has nothing that re-queries it. + it('re-queries every host in view from the header refresh action', async () => { + const { container } = await renderPage() + api.automations.listScoped.mockClear() + + const refresh = container.querySelector('[aria-label="Refresh automations"]') + expect(refresh).not.toBeNull() + await act(async () => refresh?.click()) + + // Inside the 30s TTL, so only a manual refresh can produce this call. + expect(api.automations.listScoped).toHaveBeenCalledWith({ selector: { kind: 'self' } }) + }) + + it('does not re-run the whole refresh when the settings identity changes', async () => { + const { rerender } = await renderPage() + expect(api.automations.list).toHaveBeenCalledTimes(1) + + mocks.state.settings = { ...(mocks.state.settings as Record) } + await rerender() + + // Settings no longer aim the list, so a new settings object is not a reason + // to re-fetch every host. + expect(api.automations.list).toHaveBeenCalledTimes(1) + }) +}) + +describe('AutomationsPage selection', () => { + it('loads the first automation into the detail pane and fetches its runs', async () => { + const run = makeRun({ id: 'run-1', automationId: 'a-1' }) + api.automations.list.mockResolvedValue([makeAutomation({ id: 'a-1', name: 'Nightly' })]) + api.automations.listRuns.mockImplementation(async (args?: { automationId?: string }) => + args?.automationId === 'a-1' ? [run] : [] + ) + + const { container } = await renderPage() + await openAutomationRow(container, 'Nightly') + + // One fenced read, owned by the row's own host: the page-level refresh used + // to fetch this history too, unfenced and always against the desktop. + expect(api.automations.listRuns).toHaveBeenCalledWith({ + automationId: 'a-1', + expectedOwner: SELF_PRECONDITION + }) + expect(container.querySelector('[data-testid="detail-name"]')?.textContent).toBe('Nightly') + expect(mocks.detailPane?.selectedRuns).toEqual([run]) + }) + + it('fetches the newly selected automation runs when the selection changes', async () => { + scopedList([ + makeAutomation({ id: 'a-1', name: 'Nightly' }), + makeAutomation({ id: 'a-2', name: 'Weekly' }) + ]) + + const { container, rerender } = await renderPage() + api.automations.listRuns.mockClear() + + mocks.state.selectedAutomationId = 'a-2' + await rerender() + await openAutomationRow(container, 'Weekly') + + // The history read carries the row's fence too: a read against a host that + // no longer owns the record is refused rather than answered from the wrong one. + expect(api.automations.listRuns).toHaveBeenCalledWith({ + automationId: 'a-2', + expectedOwner: SELF_PRECONDITION + }) + expect(mocks.detailPane?.selected?.id).toBe('a-2') + }) +}) + +describe('AutomationsPage multi-host selection', () => { + it('keeps a selection that lives on another host when the window regains focus', async () => { + runtimeHost([makeAutomation({ id: 'a-remote', name: 'Remote nightly' })], []) + const desktop = makeAutomation({ id: 'a-desktop', name: 'Desktop nightly' }) + scopedList([desktop]) + api.automations.list.mockResolvedValue([desktop]) + const setSelectedAutomationId = vi.fn((id: string | null) => { + mocks.state.selectedAutomationId = id + }) + mocks.state.selectedAutomationId = 'a-remote' + mocks.state.setSelectedAutomationId = setSelectedAutomationId + + const { container, rerender } = await renderPage() + await settleHostQueries() + await refreshOnFocus() + await rerender() + + // The refresh reaches one authority, so re-deriving the selection from its + // list dropped every row that authority does not hold. + expect(setSelectedAutomationId).not.toHaveBeenCalled() + await openAutomationRow(container, 'Remote nightly') + expect(container.querySelector('[data-testid="detail-name"]')?.textContent).toBe( + 'Remote nightly' + ) + }) + + it('shows the selected host’s runs when another authority holds the same id', async () => { + runtimeHost( + [makeAutomation({ id: 'a-1', name: 'Remote nightly' })], + [makeRun({ id: 'run-remote', automationId: 'a-1' })] + ) + // Same ID, different machine: legal, because an ID is unique only inside + // one authority. The desktop is the host the page can always reach. + api.automations.list.mockResolvedValue([makeAutomation({ id: 'a-1', name: 'Desktop nightly' })]) + api.automations.listRuns.mockResolvedValue([ + makeRun({ id: 'run-desktop', automationId: 'a-1' }) + ]) + scopedList([]) + mocks.state.automationHostFilter = RUNTIME_SELF_FILTER + mocks.state.selectedAutomationId = 'a-1' + + const { container } = await renderPage() + await settleHostQueries() + await refreshOnFocus() + await openAutomationRow(container, 'Remote nightly') + + expect(mocks.detailPane?.selected?.name).toBe('Remote nightly') + expect(mocks.detailPane?.selectedRuns.map((run) => run.id)).toEqual(['run-remote']) + }) + + it('keeps both authorities’ rows on screen and selectable under All hosts', async () => { + runtimeHost( + [makeAutomation({ id: 'a-1', name: 'Remote nightly' })], + [makeRun({ id: 'run-remote', automationId: 'a-1' })] + ) + scopedList([makeAutomation({ id: 'a-1', name: 'Desktop nightly' })]) + // The stored id names the record; only the row key can say whose copy. + mocks.state.selectedAutomationId = 'a-1' + + const { container } = await renderPage() + await settleHostQueries() + + // Two stored records, not one row and a silent drop into #44's empty state. + expect( + [...container.querySelectorAll('[data-testid="automation-row"]')].map( + (row) => row.textContent + ) + ).toEqual(['Desktop nightly', 'Remote nightly']) + + const remote = mocks.listPanel?.filteredRows.find( + (row) => row.automation.name === 'Remote nightly' + ) + await act(async () => { + mocks.listPanel?.selectAutomationRow(remote?.key ?? '') + }) + + expect(mocks.detailPane?.selected?.name).toBe('Remote nightly') + }) +}) diff --git a/src/renderer/src/components/automations/AutomationsPage.run-visibility.test.tsx b/src/renderer/src/components/automations/AutomationsPage.run-visibility.test.tsx new file mode 100644 index 00000000000..f7ad0be65a7 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationsPage.run-visibility.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment happy-dom + +/** + * A run must change the list the user is looking at. + * + * Same shape as the save path: once any host has answered, the page renders the + * per-host cache, so the legacy `automations` state that `refresh()` writes is + * off screen and the cached row keeps its pre-run schedule until the entry is + * invalidated. A run is not a definition write, but it does rewrite what this + * host reports for the row, and that is what the list shows. + */ + +import { act } from 'react' +import { describe, expect, it } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import { + api, + installAutomationsPageHarness, + listedRow, + mocks, + renderPage, + scopedList, + settleHostQueries +} from './automations-page-test-harness' +import { makeAutomation } from './automations-page-fixtures' + +installAutomationsPageHarness() + +const DESKTOP_SELF_FILTER = { + kind: 'host' as const, + host: { authority: { kind: 'desktop' as const }, selector: { kind: 'self' as const } } +} + +const BEFORE_RUN = makeAutomation({ id: 'a-1', name: 'Nightly', nextRunAt: 1_000 }) +const AFTER_RUN: Automation = { ...BEFORE_RUN, nextRunAt: 2_000, lastRunAt: 1_500 } + +/** The desktop store now reports the post-run projection; every later read must say so. */ +function desktopStoreHolds(automations: Automation[]): void { + api.automations.list.mockResolvedValue(automations) + scopedList(automations) +} + +/** The next-run column reads this; the mocked list panel renders only names. */ +function listedNextRunAt(): number | null | undefined { + return mocks.listPanel?.filteredRows[0]?.automation.nextRunAt +} + +describe('AutomationsPage run visibility', () => { + it('shows the post-run schedule the host reports, not the row the run started from', async () => { + desktopStoreHolds([BEFORE_RUN]) + mocks.state.automationHostFilter = DESKTOP_SELF_FILTER + + await renderPage() + await settleHostQueries() + expect(listedNextRunAt()).toBe(1_000) + + desktopStoreHolds([AFTER_RUN]) + const row = listedRow('a-1') + await act(async () => { + mocks.listPanel?.runNow(row) + }) + await settleHostQueries() + + expect(api.automations.runNow).toHaveBeenCalledTimes(1) + expect(listedNextRunAt()).toBe(2_000) + }) + + it('re-asks the host the run was dispatched to', async () => { + desktopStoreHolds([BEFORE_RUN]) + mocks.state.automationHostFilter = DESKTOP_SELF_FILTER + + await renderPage() + await settleHostQueries() + const listedBefore = api.automations.listScoped.mock.calls.length + + await act(async () => { + mocks.listPanel?.runNow(listedRow('a-1')) + }) + await settleHostQueries() + + // The cache TTL has not expired, so a refetch here can only come from the + // invalidation the run itself fed in. + expect(api.automations.listScoped.mock.calls.length).toBeGreaterThan(listedBefore) + }) +}) diff --git a/src/renderer/src/components/automations/AutomationsPage.save-visibility.test.tsx b/src/renderer/src/components/automations/AutomationsPage.save-visibility.test.tsx new file mode 100644 index 00000000000..4581a5fee7e --- /dev/null +++ b/src/renderer/src/components/automations/AutomationsPage.save-visibility.test.tsx @@ -0,0 +1,117 @@ +// @vitest-environment happy-dom + +/** + * A save must change the list the user is looking at. + * + * The page renders the per-host cache once any host has answered, so the legacy + * `automations` state a save writes is no longer on screen. These pin the only + * thing the user can observe: the row is there after the save returns, whether + * or not the authority's `automationsChanged` event ever arrives. + */ + +import { act } from 'react' +import { describe, expect, it } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import { AUTOMATIONS_CHANGED_EVENT } from '@/lib/automations-changed-window-event' +import { + api, + installAutomationsPageHarness, + mocks, + renderPage, + rows, + scopedList, + settleHostQueries +} from './automations-page-test-harness' +import { makeAutomation, REPO_ID, WORKSPACE_ID } from './automations-page-fixtures' + +installAutomationsPageHarness() + +const DESKTOP_SELF_FILTER = { + kind: 'host' as const, + host: { authority: { kind: 'desktop' as const }, selector: { kind: 'self' as const } } +} + +const CREATED = makeAutomation({ id: 'a-new', name: 'Sweep' }) + +/** The desktop store now holds the created row; every later read must say so. */ +function desktopStoreHolds(automations: Automation[]): void { + api.automations.list.mockResolvedValue(automations) + scopedList(automations) +} + +async function createSweep(): Promise { + await act(async () => { + mocks.listPanel?.openCreateDialog() + }) + await act(async () => { + mocks.editorDialog?.onDraftChange((current) => ({ + ...(current as Record), + name: 'Sweep', + prompt: 'Do the sweep', + projectId: REPO_ID, + workspaceMode: 'existing', + workspaceId: WORKSPACE_ID + })) + }) + await act(async () => { + await mocks.editorDialog?.onSave() + }) + await settleHostQueries() +} + +describe('AutomationsPage save visibility', () => { + it('lists a newly created automation on the host it was saved to', async () => { + desktopStoreHolds([]) + mocks.state.automationHostFilter = DESKTOP_SELF_FILTER + api.automations.create.mockResolvedValue(CREATED) + + const { container } = await renderPage() + await settleHostQueries() + expect(rows(container, 'automation-row')).toEqual([]) + + desktopStoreHolds([CREATED]) + await createSweep() + + expect(api.automations.create).toHaveBeenCalledTimes(1) + expect(rows(container, 'automation-row')).toEqual(['Sweep']) + }) + + it('lists it when the authority publishes its scoped change event', async () => { + desktopStoreHolds([]) + mocks.state.automationHostFilter = DESKTOP_SELF_FILTER + api.automations.create.mockResolvedValue(CREATED) + + const { container } = await renderPage() + await settleHostQueries() + + desktopStoreHolds([CREATED]) + await createSweep() + // What `automations:create` publishes for a Self record, verbatim. + await act(async () => { + window.dispatchEvent( + new CustomEvent(AUTOMATIONS_CHANGED_EVENT, { + detail: { reason: 'definition', selector: { kind: 'self' } } + }) + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(rows(container, 'automation-row')).toEqual(['Sweep']) + }) + + it('lists it after the page is reopened', async () => { + desktopStoreHolds([]) + mocks.state.automationHostFilter = DESKTOP_SELF_FILTER + api.automations.create.mockResolvedValue(CREATED) + + await renderPage() + await settleHostQueries() + desktopStoreHolds([CREATED]) + await createSweep() + + const reopened = await renderPage() + await settleHostQueries() + + expect(rows(reopened.container, 'automation-row')).toEqual(['Sweep']) + }) +}) diff --git a/src/renderer/src/components/automations/AutomationsPage.test.tsx b/src/renderer/src/components/automations/AutomationsPage.test.tsx new file mode 100644 index 00000000000..1efff6d98f8 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationsPage.test.tsx @@ -0,0 +1,628 @@ +// @vitest-environment happy-dom + +/** + * Characterization tests for the automations page. + * + * This file pins the page's *current* observable orchestration — which requests + * it makes, in what shape, and what it hands to its children — so the Step 6/7/9 + * rewrites have a safety net. Several assertions describe behavior those steps + * will deliberately replace; each is marked with the step that owns the change, + * so an edit there reads as intended rather than as a regression. + * + * The mount rig, child stand-ins, and preload API double live in + * `automations-page-test-harness`; the refresh triggers and which row's history + * they leave on screen live in `AutomationsPage.refresh-selection.test.tsx`. + */ + +import { act } from 'react' +import { describe, expect, it } from 'vitest' +import { + AUTOMATION_ORPHAN_ISSUES, + type AutomationListParams +} from '../../../../shared/automation-list-scope' +import { + api, + DESKTOP_SELF_OWNER, + installAutomationsPageHarness, + listedRow, + mocks, + renderPage, + rows, + scopedList, + SELF_PRECONDITION +} from './automations-page-test-harness' +import { + makeAutomation, + makeExternalManager, + REPO_ID, + WORKSPACE_ID +} from './automations-page-fixtures' + +installAutomationsPageHarness() + +describe('AutomationsPage list rendering', () => { + it('renders the rows the host-scoped read returned, not the unscoped list', async () => { + api.automations.list.mockResolvedValue([makeAutomation({ id: 'a-9', name: 'Unscoped' })]) + scopedList([ + makeAutomation({ id: 'a-1', name: 'Nightly' }), + makeAutomation({ id: 'a-2', name: 'Weekly' }) + ]) + + const { container } = await renderPage() + + expect(api.automations.listScoped).toHaveBeenCalledWith({ selector: { kind: 'self' } }) + expect(rows(container, 'automation-row')).toEqual(['Nightly', 'Weekly']) + }) + + it('falls back to the unscoped list until a host has answered', async () => { + api.automations.list.mockResolvedValue([makeAutomation({ id: 'a-9', name: 'Unscoped' })]) + api.automations.listScoped.mockRejectedValue(new Error('offline')) + + const { container } = await renderPage() + + // Why: a host that never answered is not an empty host, and the rows the + // page already had are better than none. Step 9 removes this arm. + expect(rows(container, 'automation-row')).toEqual(['Unscoped']) + }) + + it('renders the empty state when the host has no automations', async () => { + api.automations.list.mockResolvedValue([]) + scopedList([]) + + const { container } = await renderPage() + + expect(container.querySelector('[data-testid="empty-state"]')).not.toBeNull() + expect(rows(container, 'automation-row')).toEqual([]) + }) + + it('handles a failed list instead of leaking an unhandled rejection', async () => { + // Before Step 7b this was pinned as *unhandled*: refresh() was try/finally + // with no catch and was called as `void refresh()`. + const unhandled: unknown[] = [] + const capture = (reason: unknown): void => { + unhandled.push(reason) + } + process.on('unhandledRejection', capture) + api.automations.list.mockRejectedValue(new Error('offline')) + scopedList([makeAutomation({ id: 'a-1', name: 'Nightly' })]) + + const { container } = await renderPage() + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + process.off('unhandledRejection', capture) + + expect(unhandled).toEqual([]) + expect(container.querySelector('[data-testid="list-panel"]')).not.toBeNull() + // The host answered for itself, so its rows survive the unscoped failure. + expect(rows(container, 'automation-row')).toEqual(['Nightly']) + }) + + it('reports a host that could not be loaded, with a way to retry it', async () => { + api.automations.list.mockRejectedValue(new Error('offline')) + api.automations.listScoped.mockRejectedValue(new Error('offline')) + + await renderPage() + + expect(mocks.listPanel?.hostCatalog.loadCounts).toEqual({ + failedHostCount: 1, + totalHostCount: 1 + }) + const entry = mocks.listPanel?.hostCatalog.entries[0] + api.automations.listScoped.mockClear() + await act(async () => { + mocks.listPanel?.hostCatalog.recover('retry', entry) + }) + + // Retry re-queries that host rather than reloading the page. + expect(api.automations.listScoped).toHaveBeenCalledWith({ selector: { kind: 'self' } }) + }) + + it('lists external managers per captured owner rather than probing the machine', async () => { + api.automations.list.mockResolvedValue([]) + api.automations.listExternalManagerForOwner.mockImplementation( + async ({ provider }: { provider: string }) => + provider === 'hermes' + ? { manager: makeExternalManager(), error: null, updatedAt: 1 } + : { manager: null, error: null, updatedAt: 1 } + ) + + const { container } = await renderPage() + + expect(api.automations.listExternalManagerForOwner).toHaveBeenCalledWith({ + owner: DESKTOP_SELF_OWNER, + provider: 'hermes' + }) + expect(rows(container, 'external-row')).toEqual(['Hermes job']) + }) + + it('keeps the edited external automation selected after its save', async () => { + api.automations.list.mockResolvedValue([]) + api.automations.listExternalManagerForOwner.mockImplementation( + async ({ provider }: { provider: string }) => + provider === 'hermes' + ? { manager: makeExternalManager(), error: null, updatedAt: 1 } + : { manager: null, error: null, updatedAt: 1 } + ) + api.automations.updateExternalForOwner.mockResolvedValue(undefined) + + await renderPage() + const entry = mocks.listPanel?.filteredExternalAutomationEntries[0] + if (!entry) { + throw new Error('no external entry to edit') + } + + await act(async () => { + mocks.listPanel?.openEditExternalDialog(entry.manager, entry.job, entry.scope) + }) + await act(async () => { + mocks.editorDialog?.onSave() + }) + + // The row key and the post-save selection are built by two different call + // sites; if they ever stop agreeing, selection silently falls through to the + // first row and the user's edit appears to open someone else's automation. + expect(api.automations.updateExternalForOwner).toHaveBeenCalled() + expect(mocks.listPanel?.selectedExternal?.key).toBe(entry.key) + }) + + it('routes each authority’s action to its own host when both report hermes:local', async () => { + api.automations.list.mockResolvedValue([]) + api.automations.listExternalManagerForOwner.mockImplementation( + async ({ provider }: { provider: string }) => + provider === 'hermes' + ? { manager: makeExternalManager({ id: 'hermes:local' }), error: null, updatedAt: 1 } + : { manager: null, error: null, updatedAt: 1 } + ) + api.automations.runExternalActionForOwner.mockResolvedValue(undefined) + + await renderPage() + const entry = mocks.listPanel?.filteredExternalAutomationEntries[0] + if (!entry) { + throw new Error('no external entry to act on') + } + // The second authority a multi-host build will list: same provider, same + // `hermes:local` manager ID, different machine. The desktop fixture is the + // only host that actually answers, so a scope recovered from the manager ID + // would resolve BOTH rows to it and pause the wrong host's cron job. + const runtimeScope = { + ...entry.scope, + owner: { + authority: { kind: 'runtime' as const, environmentId: 'env-7', pairingRevision: 1 }, + selector: { kind: 'self' as const } + } + } + + await act(async () => { + mocks.listPanel?.requestExternalAction(entry.manager, entry.job, 'pause', entry.scope) + }) + await act(async () => { + mocks.listPanel?.requestExternalAction(entry.manager, entry.job, 'pause', runtimeScope) + }) + + const owners = api.automations.runExternalActionForOwner.mock.calls.map( + (call) => (call[0] as { owner: unknown }).owner + ) + expect(owners).toEqual([entry.scope.owner, runtimeScope.owner]) + }) + + it('reads runs from the host the table names, not the one holding the manager ID', async () => { + api.automations.list.mockResolvedValue([]) + api.automations.listExternalManagerForOwner.mockImplementation( + async ({ provider }: { provider: string }) => + provider === 'hermes' + ? { manager: makeExternalManager({ id: 'hermes:local' }), error: null, updatedAt: 1 } + : { manager: null, error: null, updatedAt: 1 } + ) + api.automations.listExternalRunsForOwner.mockResolvedValue({ runs: [], total: 0 }) + + const { container } = await renderPage() + const entry = mocks.listPanel?.filteredExternalAutomationEntries[0] + if (!entry) { + throw new Error('no external entry to read runs for') + } + const runtimeScope = { + ...entry.scope, + owner: { + authority: { kind: 'runtime' as const, environmentId: 'env-7', pairingRevision: 1 }, + selector: { kind: 'self' as const } + } + } + + await act(async () => { + container.querySelector('[data-testid="external-row"]')?.click() + }) + + await act(async () => { + await mocks.detailPane?.fetchExternalAutomationRuns({ + scope: runtimeScope, + manager: entry.manager, + job: entry.job, + page: 0, + pageSize: 8 + }) + }) + + // Run output is read, not written, but a wrong host still shows one machine's + // logs under another's automation — and the desktop is the only host listed, + // so an ID-keyed lookup would answer with it every time. + expect(api.automations.listExternalRunsForOwner).toHaveBeenCalledWith( + expect.objectContaining({ owner: runtimeScope.owner, jobId: entry.job.id }) + ) + }) +}) + +describe('AutomationsPage host filter', () => { + it('hands the catalog and the resolved filter to the picker', async () => { + await renderPage() + + expect(mocks.listPanel?.hostCatalog.resolution.effective).toEqual({ kind: 'all' }) + expect(mocks.listPanel?.hostCatalog.entries.map((entry) => entry.kind)).toEqual(['self']) + }) + + it('groups rows by authority under All hosts', async () => { + scopedList([makeAutomation({ id: 'a-1', name: 'Nightly' })]) + + await renderPage() + + const groups = mocks.listPanel?.hostCatalog.rows.groups ?? [] + expect(groups.map((group) => group.authorityKey)).toEqual(['authority:desktop']) + expect(groups[0]?.hosts[0]?.rows.map((row) => row.automation.name)).toEqual(['Nightly']) + }) + + it('feeds the search layer the counts and host labels it already computed', async () => { + scopedList([makeAutomation({ id: 'a-1', name: 'Nightly' })]) + + await renderPage() + + expect(mocks.listPanel?.searchCounts).toEqual({ + hostRowCount: 1, + visibleRowCount: 1, + searchActive: false + }) + expect(mocks.listPanel?.hostCatalog.rows.rows[0]?.hostLabel).toBeTruthy() + }) + + it('persists a host selection through the store rather than page state', async () => { + await renderPage() + await act(async () => { + mocks.listPanel?.onSelectHost({ kind: 'all' }) + }) + + expect(mocks.setAutomationHostFilter).toHaveBeenCalledWith({ kind: 'all' }) + }) +}) + +describe('AutomationsPage run navigation', () => { + it('does not leave a run navigation pending when the history read fails', async () => { + api.automations.list.mockResolvedValue([ + makeAutomation({ id: 'a-1' }), + makeAutomation({ id: 'a-2' }) + ]) + scopedList([makeAutomation({ id: 'a-1' }), makeAutomation({ id: 'a-2' })]) + api.automations.listRuns.mockImplementation( + async (args: { automationId: string; expectedOwner?: unknown }) => { + if (args.automationId === 'a-2' && args.expectedOwner) { + throw new Error('web-01 is not connected') + } + return [] + } + ) + mocks.state.setPendingAutomationRunNavigation = mocks.setPendingRunNavigation + const { rerender } = await renderPage() + + mocks.state.selectedAutomationId = 'a-2' + mocks.state.pendingAutomationRunNavigation = { + automationId: 'a-2', + runId: 'run-9', + hostId: null + } + await rerender() + await rerender() + + // The navigation is persisted UI state, so stuck here means stuck across reopens. + expect(mocks.setPendingRunNavigation).toHaveBeenCalledWith(null) + expect(mocks.detailPane?.selectedRunsNotice?.message).toContain('web-01 is not connected') + }) +}) + +describe('AutomationsPage mutations', () => { + it('routes Run Now to the desktop authority', async () => { + const automation = makeAutomation({ id: 'a-1' }) + api.automations.list.mockResolvedValue([automation]) + + await renderPage() + await act(async () => { + mocks.listPanel?.runNow(listedRow(automation.id)) + }) + + // Step 6: the request names the owner captured when the row was listed. + expect(api.automations.runNow).toHaveBeenCalledWith({ + id: 'a-1', + expectedOwner: SELF_PRECONDITION + }) + }) + + it('routes enable/disable to an update on the owning authority', async () => { + const automation = makeAutomation({ id: 'a-1', enabled: true }) + api.automations.list.mockResolvedValue([automation]) + + await renderPage() + await act(async () => { + mocks.listPanel?.toggleAutomation(listedRow(automation.id)) + }) + + expect(api.automations.update).toHaveBeenCalledWith({ + id: 'a-1', + updates: { enabled: false }, + expectedOwner: SELF_PRECONDITION, + destination: undefined + }) + }) + + it('re-reads the record before opening the edit dialog', async () => { + const automation = makeAutomation({ id: 'a-1' }) + api.automations.list.mockResolvedValue([automation]) + + await renderPage() + api.automations.list.mockClear() + await act(async () => { + mocks.listPanel?.openEditDialog(listedRow(automation.id)) + }) + + // Step 6: hydration re-reads the row's own scope; the ambient list is untouched. + expect(api.automations.list).not.toHaveBeenCalled() + expect(api.automations.listScoped).toHaveBeenCalledWith({ selector: { kind: 'self' } }) + expect(mocks.editorDialog?.open).toBe(true) + expect(mocks.editorDialog?.isEditing).toBe(true) + }) + + it('opens the create dialog without an editing target', async () => { + await renderPage() + await act(async () => { + mocks.listPanel?.openCreateDialog() + }) + + expect(mocks.editorDialog?.open).toBe(true) + expect(mocks.editorDialog?.isEditing).toBe(false) + }) + + it('deletes through the owning authority once the dialog confirms', async () => { + const automation = makeAutomation({ id: 'a-1' }) + api.automations.list.mockResolvedValue([automation]) + + await renderPage() + await act(async () => { + mocks.listPanel?.requestDeleteAutomation(listedRow(automation.id)) + }) + expect(mocks.deleteDialog?.deleteTarget?.id).toBe('a-1') + + await act(async () => { + mocks.deleteDialog?.onConfirm() + }) + + expect(api.automations.delete).toHaveBeenCalledWith({ + id: 'a-1', + expectedOwner: SELF_PRECONDITION + }) + }) + + it('creates at an explicitly stated destination rather than an inferred host', async () => { + api.automations.list.mockResolvedValue([]) + + await renderPage() + await act(async () => { + mocks.listPanel?.openCreateDialog() + }) + await act(async () => { + mocks.editorDialog?.onDraftChange((current) => ({ + ...(current as Record), + name: 'Sweep', + prompt: 'Do the sweep', + projectId: REPO_ID, + workspaceMode: 'existing', + workspaceId: WORKSPACE_ID + })) + }) + await act(async () => { + mocks.editorDialog?.onSave() + }) + + expect(api.automations.create).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Sweep', + destination: { selector: { kind: 'self' } } + }) + ) + }) + + it('refreshes after a mutation so the list reflects the write', async () => { + const automation = makeAutomation({ id: 'a-1' }) + api.automations.list.mockResolvedValue([automation]) + + await renderPage() + api.automations.list.mockClear() + await act(async () => { + mocks.listPanel?.toggleAutomation(listedRow(automation.id)) + }) + + expect(api.automations.list).toHaveBeenCalled() + }) +}) + +describe('AutomationsPage owner conflicts', () => { + function conflict(code: string): Error { + return new Error(`The host changed: ${code}`) + } + + function notice(container: HTMLElement): HTMLElement | null { + return container.querySelector('[data-testid="automation-owner-conflict"]') + } + + it('surfaces a changed owner as a recoverable notice instead of a thrown error', async () => { + const automation = makeAutomation({ id: 'a-1', enabled: true }) + api.automations.list.mockResolvedValue([automation]) + api.automations.update.mockRejectedValue(conflict('automation_owner_changed')) + + const { container } = await renderPage() + await act(async () => { + mocks.listPanel?.toggleAutomation(listedRow(automation.id)) + }) + + expect(notice(container)?.textContent).toContain("This automation's host changed") + expect(notice(container)?.textContent).toContain('Retry') + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it("offers no recovery action when the automation's SSH host was deregistered", async () => { + const automation = makeAutomation({ id: 'a-1' }) + api.automations.list.mockResolvedValue([automation]) + api.automations.delete.mockRejectedValue(conflict('automation_target_removed')) + + const { container } = await renderPage() + await act(async () => { + mocks.listPanel?.requestDeleteAutomation(listedRow(automation.id)) + }) + await act(async () => { + mocks.deleteDialog?.onConfirm() + }) + + // Nothing the page can retry or reconnect re-registers a removed host. + expect(notice(container)?.textContent).toContain('SSH host was removed') + expect(notice(container)?.textContent).not.toContain('Retry') + }) + + it('refuses a save inside the dialog that hides the page, not behind it', async () => { + const automation = makeAutomation({ id: 'a-1' }) + api.automations.list.mockResolvedValue([automation]) + api.automations.update.mockRejectedValue(conflict('automation_owner_changed')) + + const { container } = await renderPage() + await act(async () => { + mocks.listPanel?.openEditDialog(listedRow(automation.id)) + }) + await act(async () => { + mocks.editorDialog?.onDraftChange((current) => ({ + ...(current as Record), + prompt: 'Edited' + })) + }) + await act(async () => { + mocks.editorDialog?.onSave() + }) + + // The dialog stays open over the page, so a notice posted to the page is a + // save that did nothing at all as far as the user can tell. + expect(mocks.editorDialog?.open).toBe(true) + expect(mocks.editorDialog?.notice?.message).toContain("This automation's host changed") + expect(notice(container)).toBeNull() + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it('tells the user to update a server that cannot fence the request', async () => { + const automation = makeAutomation({ id: 'a-1' }) + api.automations.list.mockResolvedValue([automation]) + api.automations.runNow.mockRejectedValue(conflict('automation_owner_fencing_required')) + + const { container } = await renderPage() + await act(async () => { + mocks.listPanel?.runNow(listedRow(automation.id)) + }) + + expect(notice(container)?.textContent).toContain('Update server') + expect(mocks.toastMessage).not.toHaveBeenCalled() + }) + + it('refuses to open the editor on a row whose owner moved', async () => { + const automation = makeAutomation({ id: 'a-1' }) + + const { container } = await renderPage() + // The row was captured by the initial host read; the re-read is what fails. + api.automations.listScoped.mockRejectedValue(conflict('automation_owner_changed')) + await act(async () => { + mocks.listPanel?.openEditDialog(listedRow(automation.id)) + }) + + expect(mocks.editorDialog?.open).toBe(false) + expect(notice(container)).not.toBeNull() + }) + + it('still opens the editor when the re-read merely failed', async () => { + const automation = makeAutomation({ id: 'a-1' }) + + await renderPage() + api.automations.listScoped.mockRejectedValue(new Error('offline')) + await act(async () => { + mocks.listPanel?.openEditDialog(listedRow(automation.id)) + }) + + // Why: a transport failure degrades to the copy on screen, as it did before Step 6. + expect(mocks.editorDialog?.open).toBe(true) + expect(mocks.editorDialog?.isEditing).toBe(true) + }) + + it('keeps the unfenced call for a row no host read has answered for', async () => { + // The host never answered, so no row carries an owner and none may be + // invented; the request goes out unfenced rather than against a guess. + const automation = makeAutomation({ id: 'a-1', enabled: true }) + api.automations.list.mockResolvedValue([automation]) + api.automations.listScoped.mockRejectedValue(new Error('offline')) + + await renderPage() + await act(async () => { + mocks.listPanel?.toggleAutomation(listedRow(automation.id)) + }) + + // Why: Step 9 removes this legacy arm once every row carries a captured owner. + expect(api.automations.update).toHaveBeenCalledWith({ + id: 'a-1', + updates: { enabled: false } + }) + }) + + // A mixed-host folder workspace is the path that reaches this without a host + // ever being removed, so the orphan bucket has to hold rows on a healthy machine. + it('lists an ambiguous-workspace row in the orphan bucket and keeps History on it', async () => { + const orphaned = makeAutomation({ id: 'a-2', name: 'Ambiguous' }) + api.automations.listScoped.mockImplementation(async (params: AutomationListParams) => + params.selector?.kind === 'orphan' + ? { + automations: [orphaned], + items: [ + { + automationId: 'a-2', + selector: { kind: 'orphan', issue: AUTOMATION_ORPHAN_ISSUES.workspaceHostAmbiguous } + } + ], + orphanCount: 1 + } + : { + automations: [makeAutomation()], + items: [{ automationId: 'a-1', selector: { kind: 'self' } }], + orphanCount: 1 + } + ) + + const { container, rerender } = await renderPage() + // The orphan scope is only listed once an authority has reported a count, + // so the entry appears on the catalog that follows the first answer. + await rerender() + + expect(api.automations.listScoped).toHaveBeenCalledWith({ selector: { kind: 'orphan' } }) + expect(rows(container, 'automation-row')).toContain('Ambiguous') + const orphanedRow = listedRow(orphaned.id) + expect(mocks.listPanel?.isActionEnabled(orphanedRow, 'history')).toBe(true) + expect(mocks.listPanel?.isActionEnabled(orphanedRow, 'run-now')).toBe(false) + }) + + it('greys out the actions on a row no owner could be captured for', async () => { + const automation = makeAutomation({ id: 'a-1' }) + api.automations.list.mockResolvedValue([automation]) + api.automations.listScoped.mockRejectedValue(new Error('offline')) + + await renderPage() + + // Uncaptured is not blocked: the legacy arm still works, so nothing is greyed. + expect(mocks.listPanel?.isActionEnabled(listedRow(automation.id), 'delete')).toBe(true) + }) +}) diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 8de54d9e598..7e3beff585a 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -5,13 +5,12 @@ import { toast } from 'sonner' import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import { useAppStore } from '@/store' -import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' -import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' import { getAgentCatalog } from '@/lib/agent-catalog' import { useRepoMap, useWorktreeMap } from '@/store/selectors' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import type { Automation, + AutomationCreateInput, ExternalAutomationAction, ExternalAutomationJob, ExternalAutomationManager, @@ -19,23 +18,25 @@ import type { AutomationRun, AutomationUpdateInput } from '../../../../shared/automations-types' -import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity' +import { + AUTOMATION_OWNER_CONFLICT_CODES, + automationOwnerConflictMessage +} from '../../../../shared/automation-owner-conflict' import { getLocalExecutionHostLabel, getRepoExecutionHostId, + getWorktreeExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' -import type { PreflightStatus } from '../../../../preload/api-types' -import type { TaskSourceContext } from '../../../../shared/task-source-context' import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types' import type { Repo } from '../../../../shared/repo-types' import { getWorktreePathBasenameFromId } from '../../../../shared/worktree/id' +import type { Worktree } from '../../../../shared/worktree/types' import { buildAutomationRrule, isValidAutomationCronSchedule, - isValidAutomationSchedule, - tryParseAutomationRrule + isValidAutomationSchedule } from '../../../../shared/automation-schedules' import { canRerunAutomationRun, @@ -48,7 +49,6 @@ import { getAutomationRunOpenTabId, resolveAutomationRunOpenTarget } from './automation-run-open-target' -import { hasAutomationRunCompletionEvidence } from './automation-run-completion-evidence' import { getAutomationRunWorkspaceDisplay } from './automation-run-workspace-display' import { AutomationEditorDialog, @@ -56,7 +56,6 @@ import { type AutomationDraft } from './AutomationEditorDialog' import { - getAutomationSetupDecisionDraftValue, getVisibleAutomationSetupDecision, resolveAutomationSetupDecisionForSave } from './automation-setup-decision' @@ -71,21 +70,19 @@ import { import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' +import { useAutomationSourceHostAvailability } from './use-automation-source-host-availability' import { - getRepoBackedProviderAvailability, - type RuntimeProviderPreflightStatus -} from '../task-source-provider-availability' -import type { TaskSourceHostAvailability } from '../task-source-context-summary' + useSelectedAutomationRunHistory, + type SelectedAutomationRunHistoryOutcome +} from './use-selected-automation-run-history' import { - createAutomationForTarget, deleteAutomationForTarget, + type AutomationHostTarget, getAutomationHostTargetFromKey, getAutomationHostTargetKey, getAutomationListTarget, - getAutomationOwnerTarget, getAutomationTargetFromHostId, - listAutomationRunsForTarget, listAutomationsForTarget, runAutomationNowForTarget, updateAutomationForTarget @@ -96,39 +93,99 @@ import { AUTOMATION_DEFAULT_TIME, buildDraftPrecheck, buildHermesCronSchedule, - formatTimeInput, getDefaultWorktree, parseDraftTime } from './automation-draft-model' -import { - getRepoBackedAutomationSourceContext, - getRuntimeSourceHostAvailability, - type RepoBackedAutomationSourceContext -} from './automation-source-context' import type { AutomationPaneTab, SelectedExternalRunPage } from './automation-page-state' +import { isMissingExternalRunsApiError } from './external-automation-display' import { - getExternalAutomationKey, - isMissingExternalRunsApiError -} from './external-automation-display' + externalAutomationActionKey, + externalAutomationJobKey +} from './external-automation-scope-keys' import { buildExternalAutomationListEntries } from './external-automation-list-entries' -import { shouldCloseDetailForLostSelection } from './automation-detail-selection' +import type { ExternalAutomationScope } from './external-automation-scope-client' import { useAutomationListSearch } from './use-automation-list-search' -import { useAutomationListView } from './use-automation-list-view' import { EMPTY_AUTOMATION_LIST_FILTER, - nextAutomationListSort, - type AutomationListFilter, - type AutomationListSort + filterAutomationListRows, + filterExternalAutomationListEntries, + type AutomationListFilter } from './automation-list-view' +import { + automationRepoForRow, + automationWorktreeForRow, + unscopedAutomationListRows, + type AutomationListRow +} from './automation-list-row-identity' import { AutomationDeleteDialog, ExternalAutomationDeleteDialog } from './AutomationDeleteDialogs' import { AutomationsListPanel } from './AutomationsListPanel' import { AutomationsDetailPane } from './AutomationsDetailPane' import { AutomationsPageSkeleton } from './AutomationsPageSkeleton' +import type { + AutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import type { AutomationDestination } from '../../../../shared/automation-owner-precondition' +import { automationAuthorityCatalogKey } from './automation-host-catalog-types' +import { + capturedAutomationOwner, + capturedAutomationOwnerKey, + isAutomationActionEnabled, + type AutomationRowAction +} from './automation-captured-owner' +import { useAutomationHostCatalog } from './use-automation-host-catalog' +import { useScopedExternalAutomations } from './use-scoped-external-automations' +import { externalAutomationScopeEntries } from './external-automation-scope-gating' +import { externalAutomationUncheckedNotice } from './external-automation-unchecked-hosts' +import { + automationRuntimePairingRevision, + groupReposByAutomationAuthority +} from './automation-authority-identity' +import { + automationCreateHostStableKey, + resolveAutomationCreateDestination, + revalidateAutomationCreateDestination, + type AutomationCreateDestination +} from './automation-create-destination' +import { useAutomationCreateDestination } from './use-automation-create-destination' +import { persistSkipDeleteAutomationConfirm } from './automation-delete-confirm-preference' +import { buildAutomationEditDraft, buildExternalAutomationEditDraft } from './automation-edit-draft' +import { createAutomationAtDestination } from './automation-owner-action-runner' +import { + dispatchAutomationDelete, + dispatchAutomationReread, + dispatchAutomationRunNow, + dispatchAutomationUpdate, + toDispatchResult, + type AutomationActionNotice, + type AutomationDispatchResult +} from './automation-row-action-dispatch' +import { + automationRowCatalogRef, + automationWriteChangeEvent +} from './automation-write-invalidation' +import { automationRowRecoveryHost } from './automation-notice-recovery-host' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import type { AutomationAuthorityChangeReason } from './automation-host-invalidation' +import { AutomationOwnerConflictNotice } from './AutomationOwnerConflictNotice' import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour' import { translate } from '@/i18n/i18n' +import { AUTOMATIONS_CHANGED_EVENT } from '@/lib/automations-changed-window-event' const AGENTS = getAgentCatalog().map((agent) => agent.id) -const AUTOMATIONS_CHANGED_EVENT = 'orca:automations-changed' + +const EMPTY_AUTOMATION_RUNS: readonly AutomationRun[] = [] + +/** Returns the same set when nothing was removed, so an unchanged catalog memo holds. */ +function withoutKey(keys: ReadonlySet, key: string): ReadonlySet { + if (!keys.has(key)) { + return keys + } + const next = new Set(keys) + next.delete(key) + return next +} + export default function AutomationsPage(): React.JSX.Element { const repos = useAppStore((s) => s.repos) const projectHostSetups = useAppStore((s) => s.projectHostSetups) @@ -144,26 +201,30 @@ export default function AutomationsPage(): React.JSX.Element { const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const closeAutomationsPage = useAppStore((s) => s.closeAutomationsPage) - const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey) - const retainedAgentsByPaneKey = useAppStore((s) => s.retainedAgentsByPaneKey) const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) const settings = useAppStore((s) => s.settings) - const preflightStatus = useAppStore((s) => s.preflightStatus) - const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked) - const preflightStatusContextKey = useAppStore((s) => s.preflightStatusContextKey) - const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus) - const expectedPreflightContextKey = useAppStore((s) => - localPreflightContextKey(getLocalPreflightContext(s)) - ) const selectedId = useAppStore((s) => s.selectedAutomationId) const setSelectedId = useAppStore((s) => s.setSelectedAutomationId) const pendingAutomationRunNavigation = useAppStore((s) => s.pendingAutomationRunNavigation) const setPendingAutomationRunNavigation = useAppStore((s) => s.setPendingAutomationRunNavigation) const repoMap = useRepoMap() const worktreeMap = useWorktreeMap() + const repoForRow = useCallback( + (row: AutomationListRow): Repo | undefined => automationRepoForRow(row, repos, repoMap), + [repoMap, repos] + ) + const worktreeForRow = useCallback( + ( + row: AutomationListRow, + repo: Repo | undefined, + workspaceId: string | null | undefined = row.automation.workspaceId + ): Worktree | undefined => + automationWorktreeForRow(row, worktreesByRepo, repo, worktreeMap, workspaceId), + [worktreeMap, worktreesByRepo] + ) const enabledAgents = filterEnabledTuiAgents(AGENTS, settings?.disabledTuiAgents) const defaultAgent = settings?.defaultTuiAgent && @@ -173,13 +234,31 @@ export default function AutomationsPage(): React.JSX.Element { : (enabledAgents[0] ?? AGENTS[0]) const [automations, setAutomations] = useState([]) - const [runs, setRuns] = useState([]) const [automationHostTargetKey, setAutomationHostTargetKey] = useState(null) - const [selectedAutomationRuns, setSelectedAutomationRuns] = useState<{ - automationId: string | null - runs: AutomationRun[] - }>({ automationId: null, runs: [] }) - const [externalManagers, setExternalManagers] = useState([]) + const [selectedAutomationRuns, setSelectedAutomationRuns] = + useState({ + automationId: null, + rowKey: null, + ownerKey: null, + runs: [], + notice: null + }) + // Bumped by the run-history Retry; the read is otherwise keyed only by the row. + const [runHistoryReloadToken, setRunHistoryReloadToken] = useState(0) + // Why a set of authority keys: a failed list is a fact about the authority that + // answered, and the catalog needs it per authority to mark its hosts unloaded. + const [failedAuthorityKeys, setFailedAuthorityKeys] = useState>( + () => new Set() + ) + // Held with the host it was raised against: Reconnect and Update server act on + // the row's own host, not on whatever the list happens to be scoped to. + const [ownerAction, setOwnerAction] = useState<{ + notice: AutomationActionNotice + host: AutomationHostCatalogEntry | null + } | null>(null) + // Separate from the page notice: while the editor is open it covers the page, + // so a refusal posted there is a save that visibly did nothing at all. + const [editorNotice, setEditorNotice] = useState(null) const [externalActionKey, setExternalActionKey] = useState(null) const [rerunRunIdsInFlight, setRerunRunIdsInFlight] = useState>( () => new Set() @@ -188,37 +267,40 @@ export default function AutomationsPage(): React.JSX.Element { const [isSaving, setIsSaving] = useState(false) const [listSearchQuery, setListSearchQuery] = useState('') const [listFilter, setListFilter] = useState(EMPTY_AUTOMATION_LIST_FILTER) - const [listSort, setListSort] = useState(null) const [createOpen, setCreateOpen] = useState(false) const [createTarget, setCreateTarget] = useState('orca') const [editingAutomationId, setEditingAutomationId] = useState(null) + // Held beside the id: a save fences on the row the user opened, and under All + // hosts that id alone names two rows. + const [editingRowKey, setEditingRowKey] = useState(null) + // Captured with the editor's selected project so an SSH re-registration while + // the form is open cannot silently retarget the saved automation. + const [editingDestination, setEditingDestination] = useState<{ + projectId: string + destination: AutomationCreateDestination + } | null>(null) const [relativeNow, setRelativeNow] = useState(Date.now()) const [activePaneTab, setActivePaneTab] = useState('overview') const [selectedAutomationRunPageId, setSelectedAutomationRunPageId] = useState( null ) + // Held next to the store's automation id rather than in it: the id is what + // other pages navigate by, and only this side knows which host's copy is shown. + const [selectedRowKey, setSelectedRowKey] = useState(null) const [selectedExternalKey, setSelectedExternalKey] = useState(null) const [selectedExternalRunPage, setSelectedExternalRunPage] = useState(null) // Why: list is the primary surface; detail is a full-page drill-in, not a side pane. const [isDetailOpen, setIsDetailOpen] = useState(false) const selectedExternalKeyRef = useRef(null) - const isDetailOpenRef = useRef(false) // Keep async refresh/delete handlers reading the latest selection without render-time mutation. useEffect(() => { selectedExternalKeyRef.current = selectedExternalKey }, [selectedExternalKey]) - useEffect(() => { - isDetailOpenRef.current = isDetailOpen - }, [isDetailOpen]) - const runtimePreflightMountedRef = useRef(true) - const runtimePreflightRequestedHostIdsRef = useRef>(new Set()) - const [runtimePreflightStatusByHostId, setRuntimePreflightStatusByHostId] = useState< - ReadonlyMap - >(() => new Map()) const selectAutomationId = useCallback( (automationId: string | null): void => { setSelectedAutomationRunPageId(null) + setSelectedRowKey(null) setSelectedId(automationId) }, [setSelectedId] @@ -228,10 +310,12 @@ export default function AutomationsPage(): React.JSX.Element { setSelectedExternalKey(externalKey) }, []) const [draftAtOpen, setDraftAtOpen] = useState(null) - const [deleteTarget, setDeleteTarget] = useState(null) + const [deleteTarget, setDeleteTarget] = useState(null) const [externalDeleteTarget, setExternalDeleteTarget] = useState<{ manager: ExternalAutomationManager job: ExternalAutomationJob + /** Captured with the row, so the confirmed delete cannot land on another host. */ + scope: ExternalAutomationScope } | null>(null) useContextualTour( 'automations', @@ -241,6 +325,9 @@ export default function AutomationsPage(): React.JSX.Element { const [editingExternalTarget, setEditingExternalTarget] = useState<{ manager: ExternalAutomationManager job: ExternalAutomationJob + // Captured from the row that opened the dialog: the manager ID alone cannot + // name an authority, so a re-lookup could save to the wrong host. + scope: ExternalAutomationScope } | null>(null) const [dontAskDeleteAgain, setDontAskDeleteAgain] = useState(false) const editRequestRef = useRef(0) @@ -248,7 +335,6 @@ export default function AutomationsPage(): React.JSX.Element { // Why: both dialogs stay mounted, so a shared ref would let one dialog's // unmount clear the focus target the other still needs. const externalDeleteConfirmButtonRef = useRef(null) - const completionInFlightRef = useRef>(new Set()) const rerunRunIdsInFlightRef = useRef>(new Set()) const workspaceNameCacheRef = useRef>(new Map()) const setupDecisionPolicyDefaultRef = useRef(undefined) @@ -279,67 +365,125 @@ export default function AutomationsPage(): React.JSX.Element { missedRunGraceMinutes: '720', scheduleWarning: null }) + const draftRef = useRef(draft) + draftRef.current = draft - const externalAutomationEntries = useMemo( - () => buildExternalAutomationListEntries(externalManagers), - [externalManagers] + const hostCatalog = useAutomationHostCatalog({ failedAuthorityKeys }) + // Probing follows the selection, not the catalog: a Local selection must not + // reach out to every SSH host the user happens to have registered. + const externalScopeEntries = useMemo( + () => externalAutomationScopeEntries(hostCatalog.entries, hostCatalog.resolution), + [hostCatalog.entries, hostCatalog.resolution] ) - const { - isListSearchQueryTooLarge, - isListSearchActive, - filteredAutomations, - filteredExternalAutomationEntries, - hasListItems - } = useAutomationListSearch({ - listSearchQuery, - automations, - externalAutomationEntries, - repoMap, - selectedId, - selectedExternalKey, - selectAutomationId, - selectExternalKey - }) - const { - visibleItems, - isListFilterActive, - hasVisibleListItems: hasFilteredListItems - } = useAutomationListView({ - automations: filteredAutomations, - externalEntries: filteredExternalAutomationEntries, - runs, - filter: listFilter, - sort: listSort, - selectedId, - selectedExternalKey, - selectAutomationId, - selectExternalKey + const scopedExternal = useScopedExternalAutomations({ + catalogEntries: hostCatalog.entries, + scopeEntries: externalScopeEntries }) + // Held apart from the view object, which is a fresh literal each render and + // would refetch the runs table on every render of this page. + const fetchScopedExternalRuns = scopedExternal.fetchRuns + // Why the cache wins once it has answered: only its rows carry the host each + // record came from, which is what the picker, the groups, and every fenced + // action key on. Until then the unscoped list is all the page has, and it has + // no host to qualify its rows with. + const unscopedRows = useMemo(() => unscopedAutomationListRows(automations), [automations]) + const visibleRows = hostCatalog.rows.answered ? hostCatalog.rows.rows : unscopedRows + // Picking a row records both: the key names the host's copy, the id is what + // the rest of the page (and other pages) still address the record by. + const selectAutomationRow = useCallback( + (rowKey: string | null): void => { + const row = rowKey === null ? null : visibleRows.find((candidate) => candidate.key === rowKey) + setSelectedAutomationRunPageId(null) + setSelectedRowKey(row?.key ?? null) + setSelectedId(row?.automation.id ?? null) + }, + [setSelectedId, visibleRows] + ) + const capturedAutomationOwners = hostCatalog.rows.capturedOwners + const externalAutomationEntries = useMemo( + () => buildExternalAutomationListEntries(scopedExternal.managers), + [scopedExternal.managers] + ) + // The status/last-run/agent menu narrows rows before search, so both compose. + const attributeFilteredRows = useMemo( + () => filterAutomationListRows(visibleRows, listFilter), + [listFilter, visibleRows] + ) + const attributeFilteredExternalEntries = useMemo( + () => filterExternalAutomationListEntries(externalAutomationEntries, listFilter), + [externalAutomationEntries, listFilter] + ) const selectedExternal = externalAutomationEntries.find((entry) => entry.key === selectedExternalKey) ?? - (automations.length === 0 ? (externalAutomationEntries[0] ?? null) : null) - const selected = + (visibleRows.length === 0 ? (externalAutomationEntries[0] ?? null) : null) + // The row key decides which of two same-id rows is selected; the stored id + // still decides which record, so a selection arriving from another page (which + // only carries an id) still lands somewhere. + const selectedRow = selectedExternal === null ? selectedId - ? (automations.find((automation) => automation.id === selectedId) ?? null) - : (automations[0] ?? null) + ? (visibleRows.find( + (row) => row.key === selectedRowKey && row.automation.id === selectedId + ) ?? + visibleRows.find((row) => row.automation.id === selectedId) ?? + null) + : (visibleRows[0] ?? null) : null - const runsWithWorkspaceNames = useMemo( - () => - runs.map((run) => { - if (!run.workspaceId || run.workspaceDisplayName?.trim()) { - return run - } - const displayName = - worktreeMap.get(run.workspaceId)?.displayName ?? - workspaceNameCacheRef.current.get(run.workspaceId) ?? - getWorktreePathBasenameFromId(run.workspaceId) - const trimmedDisplayName = displayName?.trim() - return trimmedDisplayName ? { ...run, workspaceDisplayName: trimmedDisplayName } : run - }), - [runs, worktreeMap] - ) + const selected = selectedRow?.automation ?? null + useEffect(() => { + if (!isDetailOpen || pendingAutomationRunNavigation) { + return + } + const hasSelectedLocal = selectedRowKey + ? visibleRows.some((row) => row.key === selectedRowKey) + : selectedId !== null && selectedRow !== null + const hasSelectedExternal = + selectedExternalKey !== null && + externalAutomationEntries.some((entry) => entry.key === selectedExternalKey) + if (hasSelectedLocal || hasSelectedExternal) { + return + } + setIsDetailOpen(false) + setSelectedAutomationRunPageId(null) + setSelectedExternalRunPage(null) + setActivePaneTab('overview') + }, [ + externalAutomationEntries, + isDetailOpen, + pendingAutomationRunNavigation, + selectedExternalKey, + selectedId, + selectedRow, + selectedRowKey, + visibleRows + ]) + // The detail pane renders `selectedRow.automation`, so the record it echoes back + // names no row; the page already holds the one the user is looking at. + const onSelectedRow = (act: (row: AutomationListRow) => void): void => { + if (selectedRow) { + act(selectedRow) + } + } + + const { + isListSearchQueryTooLarge, + filteredRows, + filteredExternalAutomationEntries, + hasListItems, + hasFilteredListItems, + searchCounts + } = useAutomationListSearch({ + listSearchQuery, + rows: attributeFilteredRows, + externalAutomationEntries: attributeFilteredExternalEntries, + repoMap, + worktreeMap, + selectedRowKey: selectedRow?.key ?? null, + selectedExternalKey, + selectAutomationRow, + selectExternalKey + }) const selectedAutomationRunsWithWorkspaceNames = useMemo( () => selectedAutomationRuns.runs.map((run) => { @@ -347,13 +491,15 @@ export default function AutomationsPage(): React.JSX.Element { return run } const displayName = - worktreeMap.get(run.workspaceId)?.displayName ?? + (selectedRow + ? worktreeForRow(selectedRow, repoForRow(selectedRow), run.workspaceId)?.displayName + : worktreeMap.get(run.workspaceId)?.displayName) ?? workspaceNameCacheRef.current.get(run.workspaceId) ?? getWorktreePathBasenameFromId(run.workspaceId) const trimmedDisplayName = displayName?.trim() return trimmedDisplayName ? { ...run, workspaceDisplayName: trimmedDisplayName } : run }), - [selectedAutomationRuns.runs, worktreeMap] + [repoForRow, selectedAutomationRuns.runs, selectedRow, worktreeForRow, worktreeMap] ) const getDraftSetupDecisionDefault = useCallback( ( @@ -425,11 +571,22 @@ export default function AutomationsPage(): React.JSX.Element { const markSetupDecisionTouched = useCallback((): void => { setupDecisionTouchedRef.current = true }, []) - // Why: keep the detail tab scoped even while the selected-run fetch catches up. - const selectedRunsSource = - selected && selectedAutomationRuns.automationId === selected.id - ? selectedAutomationRunsWithWorkspaceNames - : runsWithWorkspaceNames + // Row-qualified *and* incarnation-qualified: the row key says whose copy the + // history was read for, the owner key says whether that host is still the + // incarnation that answered. + const selectedRunsMatchSelection = + selectedRow !== null && + selectedAutomationRuns.rowKey === selectedRow.key && + selectedAutomationRuns.ownerKey === + capturedAutomationOwnerKey(capturedAutomationOwner(capturedAutomationOwners, selectedRow.key)) + // Empty rather than another row's history while the scoped fetch is in flight: + // the page no longer holds every run, and showing the previous row's is worse + // than showing none. + const selectedRunsSource = selectedRunsMatchSelection + ? selectedAutomationRunsWithWorkspaceNames + : EMPTY_AUTOMATION_RUNS + // Why: an unanswered history is not an empty one, so the pane states the refusal instead. + const selectedRunsNotice = selectedRunsMatchSelection ? selectedAutomationRuns.notice : null const selectedRuns = useMemo( () => (selected ? selectedRunsSource.filter((run) => run.automationId === selected.id) : []), [selected, selectedRunsSource] @@ -437,6 +594,18 @@ export default function AutomationsPage(): React.JSX.Element { const selectedAutomationRunPage = selectedAutomationRunPageId ? (selectedRuns.find((run) => run.id === selectedAutomationRunPageId) ?? null) : null + const selectedRunWorktreeMap = useMemo(() => { + if (!selectedRow) { + return worktreeMap + } + const repo = repoForRow(selectedRow) + return new Map( + selectedRuns.flatMap((run) => { + const worktree = worktreeForRow(selectedRow, repo, run.workspaceId) + return worktree ? [[worktree.id, worktree] as const] : [] + }) + ) + }, [repoForRow, selectedRow, selectedRuns, worktreeForRow, worktreeMap]) const worktrees = useMemo( () => worktreesByRepo[draft.projectId] ?? [], [draft.projectId, worktreesByRepo] @@ -445,6 +614,139 @@ export default function AutomationsPage(): React.JSX.Element { () => getAutomationHostTargetFromKey(automationHostTargetKey), [automationHostTargetKey] ) + // The authority behind the *selected host*, used for orphan actions and for + // stating a create destination. Owned rows never read it: their owner already + // names an authority. Under All hosts no host is selected, so this is the + // desktop — the client's own authority, never a guess at which host is meant. + const automationAuthority = useMemo((): AutomationAuthorityRef => { + const selectedAuthority = hostCatalog.resolution.entry?.stableRef.authority + if (selectedAuthority?.kind !== 'runtime') { + return { kind: 'desktop' } + } + const environmentId = selectedAuthority.environmentId + return { + kind: 'runtime', + environmentId, + pairingRevision: automationRuntimePairingRevision(runtimeEnvironments, environmentId) + } + }, [hostCatalog.resolution.entry, runtimeEnvironments]) + // Scoped per owning authority: a repo ID is unique only inside one, so the + // flat map cannot say whether *this* authority holds the project. + const repoTables = useMemo(() => groupReposByAutomationAuthority(repos), [repos]) + const activeWorkspaceHostStableKey = useMemo(() => { + const worktree = activeWorktreeId ? worktreeMap.get(activeWorktreeId) : null + return worktree + ? automationCreateHostStableKey( + getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId)) + ) + : null + }, [activeWorktreeId, repoMap, worktreeMap]) + const createDestination = useAutomationCreateDestination({ + open: createOpen && editingAutomationId === null && createTarget === 'orca', + catalog: hostCatalog.catalog, + entries: hostCatalog.entries, + // Non-null only for a concrete host filter, which constrains the destination. + filterStableKey: hostCatalog.resolution.entry?.stableKey ?? null, + activeWorkspaceStableKey: activeWorkspaceHostStableKey, + repoTables, + projects: repos + }) + const editorProjects = createDestination.control.projects + // The destination's own repo table decides project eligibility, so a runtime + // destination refreshes its mirror the moment the dialog captures it — + // otherwise a never-fetched host offers no projects at all. + const createDestinationRuntimeEnvironmentId = + createDestination.control.resolution.status === 'ready' && + createDestination.control.resolution.authority.kind === 'runtime' + ? createDestination.control.resolution.authority.environmentId + : null + useEffect(() => { + if (createDestinationRuntimeEnvironmentId) { + void useAppStore + .getState() + .fetchRuntimeEnvironmentRepos(createDestinationRuntimeEnvironmentId) + } + }, [createDestinationRuntimeEnvironmentId]) + // A destination change can strand the chosen project on another host; leaving it + // selected only defers the same refusal to submit. + useEffect(() => { + if (!createOpen || editingAutomationId !== null || createTarget !== 'orca') { + return + } + setDraft((current) => + !current.projectId || editorProjects.some((project) => project.id === current.projectId) + ? current + : { ...current, projectId: '', workspaceId: '', baseBranch: '' } + ) + }, [createOpen, createTarget, editingAutomationId, editorProjects]) + // The row's own host, from its captured owner. A page-level target cannot + // speak for a list spanning authorities, and the legacy arm below it is only + // ever reached by rows the desktop's unscoped list produced. + const automationHostTargetFor = useCallback( + (row: AutomationListRow): AutomationHostTarget | null => { + const owner = capturedAutomationOwner(capturedAutomationOwners, row.key).owner + if (owner?.authority.kind === 'runtime') { + return { kind: 'environment', environmentId: owner.authority.environmentId } + } + return owner ? { kind: 'local' } : automationHostTarget + }, + [automationHostTarget, capturedAutomationOwners] + ) + const automationDispatchContext = useMemo( + () => ({ capturedOwners: capturedAutomationOwners, authority: automationAuthority }), + [automationAuthority, capturedAutomationOwners] + ) + const rowRecoveryHost = useCallback( + (rowKey: string | null): AutomationHostCatalogEntry | null => + automationRowRecoveryHost( + hostCatalog.catalog, + capturedAutomationOwner(capturedAutomationOwners, rowKey), + automationAuthority + ), + [automationAuthority, capturedAutomationOwners, hostCatalog.catalog] + ) + const reportOwnerAction = useCallback( + (rowKey: string | null, notice: AutomationActionNotice | null): void => { + setOwnerAction(notice ? { notice, host: rowRecoveryHost(rowKey) } : null) + }, + [rowRecoveryHost] + ) + // A create is refused by the destination the dialog captured, a save by the row + // it addresses; neither is the host the list is filtered to. + const editorRecoveryHost = useMemo((): AutomationHostCatalogEntry | null => { + if (editingAutomationId !== null) { + return rowRecoveryHost(editingRowKey) + } + const resolution = createDestination.control.resolution + return resolution.status === 'ready' ? resolution.entry : null + }, [createDestination.control.resolution, editingAutomationId, editingRowKey, rowRecoveryHost]) + const notifyAuthorityChange = hostCatalog.notifyAuthorityChange + // The list renders the per-host cache, so a write is only visible once that + // host is refetched. The authority publishes the same event, but a round trip + // later — and it cannot name a host the cache has no entry for, which is + // precisely the host a create lands on. + const invalidateWrittenHost = useCallback( + (ref: StableAutomationCatalogRef | null, reason: AutomationAuthorityChangeReason): void => { + notifyAuthorityChange(automationWriteChangeEvent(ref, automationAuthority, reason)) + }, + [automationAuthority, notifyAuthorityChange] + ) + const invalidateRowHost = useCallback( + (rowKey: string | null, reason: AutomationAuthorityChangeReason): void => { + const captured = capturedAutomationOwner(capturedAutomationOwners, rowKey) + invalidateWrittenHost(automationRowCatalogRef(captured, automationAuthority), reason) + }, + [automationAuthority, capturedAutomationOwners, invalidateWrittenHost] + ) + const isAutomationRowActionEnabled = useCallback( + (row: AutomationListRow, action: AutomationRowAction): boolean => + isAutomationActionEnabled(capturedAutomationOwner(capturedAutomationOwners, row.key), action), + [capturedAutomationOwners] + ) + const externalManagersUncheckedNotice = useMemo( + () => externalAutomationUncheckedNotice(scopedExternal.failures, hostCatalog.entries), + [scopedExternal.failures, hostCatalog.entries] + ) useEffect(() => { for (const [workspaceId, worktree] of worktreeMap) { @@ -498,6 +800,18 @@ export default function AutomationsPage(): React.JSX.Element { setPendingAutomationRunNavigation(null) return } + if ( + selectedAutomationRuns.notice && + selectedAutomationRuns.automationId === pending.automationId + ) { + // The host refused the history: the runs pane states that and offers its own + // recovery, so the navigation must not sit pending waiting on runs never coming. + setIsDetailOpen(true) + setActivePaneTab('runs') + setSelectedAutomationRunPageId(null) + setPendingAutomationRunNavigation(null) + return + } if (selectedAutomationRuns.automationId !== pending.automationId) { return } @@ -525,6 +839,7 @@ export default function AutomationsPage(): React.JSX.Element { selectExternalKey, selectedAutomationRuns.automationId, selectedExternalKey, + selectedAutomationRuns.notice, selectedId, selectedRuns, setPendingAutomationRunNavigation, @@ -542,7 +857,13 @@ export default function AutomationsPage(): React.JSX.Element { return ids }, [unifiedTabsByWorktree]) const selectedAutomationRunPageWorktree = selectedAutomationRunPage?.workspaceId - ? (worktreeMap.get(selectedAutomationRunPage.workspaceId) ?? null) + ? selectedRow + ? (worktreeForRow( + selectedRow, + repoForRow(selectedRow), + selectedAutomationRunPage.workspaceId + ) ?? null) + : (worktreeMap.get(selectedAutomationRunPage.workspaceId) ?? null) : null const selectedAutomationRunPageWorkspaceDisplay = selectedAutomationRunPage ? getAutomationRunWorkspaceDisplay({ @@ -579,141 +900,22 @@ export default function AutomationsPage(): React.JSX.Element { }) const isSelectedAutomationRunPageRerunPending = selectedAutomationRunPage !== null && rerunRunIdsInFlight.has(selectedAutomationRunPage.id) - const preflightStatusCurrent = preflightStatusContextKey === expectedPreflightContextKey - const repoBackedAutomationSourceContexts = useMemo( - () => - automations - .map((automation) => getRepoBackedAutomationSourceContext(automation)) - .filter((context): context is RepoBackedAutomationSourceContext => context !== null), - [automations] - ) - const runtimeAutomationSourceHostIds = useMemo(() => { - const hostIds = new Set() - for (const context of repoBackedAutomationSourceContexts) { - const parsed = parseExecutionHostId(context.hostId) - if (parsed?.kind !== 'runtime') { - continue - } - const hostAvailability = getRuntimeSourceHostAvailability( - context, - runtimeStatusByEnvironmentId - ) - if (hostAvailability) { - continue - } - hostIds.add(parsed.id) - } - return [...hostIds].sort() - }, [repoBackedAutomationSourceContexts, runtimeStatusByEnvironmentId]) - useEffect( - () => () => { - runtimePreflightMountedRef.current = false - }, - [] - ) - useEffect(() => { - if (!preflightStatusCurrent || !preflightStatusChecked) { - void refreshPreflightStatus() - } - }, [preflightStatusChecked, preflightStatusCurrent, refreshPreflightStatus]) - useEffect(() => { - const unrequestedHostIds = runtimeAutomationSourceHostIds.filter( - (hostId) => !runtimePreflightRequestedHostIdsRef.current.has(hostId) - ) - if (unrequestedHostIds.length === 0) { - return - } - setRuntimePreflightStatusByHostId((current) => { - const next = new Map(current) - for (const hostId of unrequestedHostIds) { - next.set(hostId, { checked: false, status: null }) - } - return next - }) - for (const hostId of unrequestedHostIds) { - runtimePreflightRequestedHostIdsRef.current.add(hostId) - const parsed = parseExecutionHostId(hostId) - if (parsed?.kind !== 'runtime') { - continue - } - // Why: automation sources can be owned by a different remote server than - // the run target; provider auth/tooling must be checked on the source host. - void callRuntimeRpc( - { kind: 'environment', environmentId: parsed.environmentId }, - 'preflight.check', - undefined, - { timeoutMs: 15_000 } - ) - .then((status) => { - if (!runtimePreflightMountedRef.current) { - return - } - setRuntimePreflightStatusByHostId((current) => { - const next = new Map(current) - next.set(hostId, { checked: true, status }) - return next - }) - }) - .catch(() => { - if (!runtimePreflightMountedRef.current) { - return - } - setRuntimePreflightStatusByHostId((current) => { - const next = new Map(current) - next.set(hostId, { checked: true, status: null }) - return next - }) - }) - } - }, [runtimeAutomationSourceHostIds]) - const automationSourceHostAvailabilityById = useMemo(() => { - const availabilityById = new Map() - for (const automation of automations) { - const context = getRepoBackedAutomationSourceContext(automation) - if (!context) { - continue - } - const hostAvailability = getRuntimeSourceHostAvailability( - context, - runtimeStatusByEnvironmentId - ) - const providerAvailability = getRepoBackedProviderAvailability({ - provider: context.provider, - contexts: [context], - preflightStatus, - preflightReady: preflightStatusCurrent && preflightStatusChecked, - runtimePreflightStatusByHostId - }) - const availability = [ - ...(hostAvailability ? [hostAvailability] : []), - ...providerAvailability - ] - if (availability.length > 0) { - availabilityById.set(automation.id, availability) - } - } - return availabilityById - }, [ - automations, - preflightStatus, - preflightStatusChecked, - preflightStatusCurrent, - runtimePreflightStatusByHostId, - runtimeStatusByEnvironmentId - ]) - const selectedRepo = selected ? (repoMap.get(getAutomationRunRepoId(selected)) ?? null) : null + const automationSourceHostAvailabilityByRowKey = useAutomationSourceHostAvailability(visibleRows) + const selectedRepo = selectedRow ? (repoForRow(selectedRow) ?? null) : null const selectedWorktree = - selected && selected.workspaceId ? (worktreeMap.get(selected.workspaceId) ?? null) : null - const selectedRunNowAvailability = selected + selectedRow && selected?.workspaceId + ? (worktreeForRow(selectedRow, selectedRepo ?? undefined) ?? null) + : null + const selectedRunNowAvailability = selectedRow ? getAutomationTargetAvailability({ - automation: selected, + automation: selectedRow.automation, repo: selectedRepo, workspace: selectedWorktree, projectHostSetups, sshConnectionStates, runtimeStatusByEnvironmentId, - automationHostTarget, - sourceHostAvailability: automationSourceHostAvailabilityById.get(selected.id) + automationHostTarget: automationHostTargetFor(selectedRow), + sourceHostAvailability: automationSourceHostAvailabilityByRowKey.get(selectedRow.key) }) : null const canSaveDraft = @@ -759,31 +961,27 @@ export default function AutomationsPage(): React.JSX.Element { }, [activePaneTab, selected, selectedExternal]) const getDefaultTarget = useCallback(() => { - const destinationTarget = getAutomationListTarget(settings) - const eligibleRepos = getAutomationCreateRepos(repos, destinationTarget) const activeWorktree = activeWorktreeId ? worktreeMap.get(activeWorktreeId) : null const activeRepo = activeWorktree ? (repoMap.get(activeWorktree.repoId) ?? null) : null - const fallbackRepo = - (activeRepo && - eligibleRepos.some( - (repo) => - repo.id === activeRepo.id && - getRepoExecutionHostId(repo) === getRepoExecutionHostId(activeRepo) - ) + // The stated destination decides which projects exist for this draft, so an + // active workspace on another host is not a candidate here. + const eligibleActiveRepo = + activeRepo && editorProjects.some((project) => project.id === activeRepo.id) ? activeRepo - : null) ?? - eligibleRepos[0] ?? - null + : null + const fallbackRepo = eligibleActiveRepo ?? editorProjects[0] ?? null const fallbackWorktrees = fallbackRepo ? (worktreesByRepo[fallbackRepo.id] ?? []) : [] // Why: automation-created workspaces can be active; new automations should start from // the repo's stable main worktree unless the user explicitly chooses otherwise. - const targetWorktree = fallbackRepo ? getDefaultWorktree(fallbackWorktrees) : null - const targetProjectId = fallbackRepo?.id ?? '' + const targetWorktree = + getDefaultWorktree(fallbackWorktrees) ?? + (activeWorktree && activeWorktree.repoId === fallbackRepo?.id ? activeWorktree : null) + const targetProjectId = fallbackRepo?.id ?? targetWorktree?.repoId ?? '' return { projectId: targetProjectId, workspaceId: targetWorktree?.id ?? '' } - }, [activeWorktreeId, repoMap, repos, settings, worktreeMap, worktreesByRepo]) + }, [activeWorktreeId, editorProjects, repoMap, worktreeMap, worktreesByRepo]) const canCreateAutomation = getAutomationCreateAvailability({ automationHostTarget: getAutomationListTarget(settings), @@ -800,63 +998,62 @@ export default function AutomationsPage(): React.JSX.Element { ? getAutomationCreateRepos(repos, automationDialogTarget) : getAutomationCreateRepos(repos, { kind: 'local' }) + const destinationForProject = useCallback( + (projectId: string): AutomationCreateDestination | null => { + const runContext = buildAutomationRunContextForRepo({ + repoId: projectId, + repos, + projectHostSetups + }) + if (!runContext) { + return null + } + const stableKey = automationCreateHostStableKey(runContext.hostId) + const entry = stableKey + ? hostCatalog.entries.find((candidate) => candidate.stableKey === stableKey) + : undefined + const resolved = resolveAutomationCreateDestination(entry) + return resolved.status === 'ready' ? resolved : null + }, + [hostCatalog.entries, projectHostSetups, repos] + ) + + const reloadExternalManagers = scopedExternal.reload + const refresh = useCallback(async () => { setIsLoading(true) const pendingNavigation = useAppStore.getState().pendingAutomationRunNavigation - const automationHostTarget = pendingNavigation + // The desktop unless navigation named a host: this arm exists for rows the + // per-host reads have not answered for, and the client's own authority is + // the only one it can address without guessing which server is meant. + const automationHostTarget: AutomationHostTarget = pendingNavigation ? getAutomationTargetFromHostId(pendingNavigation.hostId) - : getAutomationListTarget(settings) + : { kind: 'local' } + const authorityKey = automationAuthorityCatalogKey( + automationHostTarget.kind === 'environment' + ? { kind: 'runtime', environmentId: automationHostTarget.environmentId } + : { kind: 'desktop' } + ) try { - const [nextAutomations, nextRuns, nextExternalManagers] = await Promise.all([ + const [nextAutomations] = await Promise.all([ listAutomationsForTarget(automationHostTarget), - listAutomationRunsForTarget(automationHostTarget), - window.api.automations.listExternalManagers() + // Managers are per host and failures are per provider, so this settles + // on its own and must not be able to fail the automation list with it. + reloadExternalManagers().catch(() => undefined) ]) - const currentSelectedId = useAppStore.getState().selectedAutomationId - const hasCurrentSelection = nextAutomations.some( - (automation) => automation.id === currentSelectedId - ) - let nextSelectedId: string | null - if (hasCurrentSelection) { - nextSelectedId = currentSelectedId - } else if (pendingNavigation) { - nextSelectedId = pendingNavigation.automationId - } else { - nextSelectedId = nextAutomations[0]?.id ?? null - } - const nextSelectedRuns = nextSelectedId - ? await listAutomationRunsForTarget(automationHostTarget, nextSelectedId) - : [] + // Selection and run history are deliberately not written here: this call + // addressed one authority, and the selected row may belong to another. setAutomations(nextAutomations) - setRuns(nextRuns) setAutomationHostTargetKey(getAutomationHostTargetKey(automationHostTarget)) - setSelectedAutomationRuns({ - automationId: nextSelectedId, - runs: nextSelectedRuns - }) - setExternalManagers(nextExternalManagers) - if (!hasCurrentSelection && !pendingNavigation) { - selectAutomationId(nextAutomations[0]?.id ?? null) - if ( - shouldCloseDetailForLostSelection({ - isDetailOpen: isDetailOpenRef.current, - hasPendingNavigation: false, - isSelectedAutomationInNextList: false, - isSelectedExternalInNextList: buildExternalAutomationListEntries( - nextExternalManagers - ).some((entry) => entry.key === selectedExternalKeyRef.current) - }) - ) { - setIsDetailOpen(false) - setSelectedAutomationRunPageId(null) - setSelectedExternalRunPage(null) - setActivePaneTab('overview') - } - } + setFailedAuthorityKeys((current) => withoutKey(current, authorityKey)) + } catch { + // Why not a toast and not a rethrow: the list keeps whatever it had, and + // the host's own status row is where the failure and its Retry belong. + setFailedAuthorityKeys((current) => new Set(current).add(authorityKey)) } finally { setIsLoading(false) } - }, [selectAutomationId, settings]) + }, [reloadExternalManagers]) useEffect(() => { if (!pendingAutomationRunNavigation || isLoading) { @@ -899,29 +1096,14 @@ export default function AutomationsPage(): React.JSX.Element { }) }, []) - useEffect(() => { - const automationId = selected?.id ?? null - if (!automationId) { - setSelectedAutomationRuns({ automationId: null, runs: [] }) - return - } - let cancelled = false - const target = - pendingAutomationRunNavigation?.automationId === automationId && - pendingAutomationRunNavigation.hostId - ? getAutomationTargetFromHostId(pendingAutomationRunNavigation.hostId) - : selected - ? getAutomationOwnerTarget(selected, automationHostTarget) - : getAutomationListTarget(settings) - void listAutomationRunsForTarget(target, automationId).then((nextRuns) => { - if (!cancelled) { - setSelectedAutomationRuns({ automationId, runs: nextRuns }) - } - }) - return () => { - cancelled = true - } - }, [automationHostTarget, pendingAutomationRunNavigation, selected, selected?.id, runs, settings]) + useSelectedAutomationRunHistory({ + selected: selectedRow, + context: automationDispatchContext, + legacyTarget: automationHostTargetFor, + navigation: pendingAutomationRunNavigation, + reloadToken: runHistoryReloadToken, + onSettled: setSelectedAutomationRuns + }) useEffect(() => { const onAutomationsChanged = (): void => { @@ -945,56 +1127,6 @@ export default function AutomationsPage(): React.JSX.Element { } }, [refresh]) - useEffect(() => { - const inFlight = completionInFlightRef.current - const completedRuns = runs.filter((run) => { - if (run.status !== 'dispatched' || !run.terminalPaneKey) { - return false - } - if (inFlight.has(run.id)) { - return false - } - const dispatchedAt = run.dispatchedAt ?? null - if (dispatchedAt === null) { - return false - } - return hasAutomationRunCompletionEvidence({ - run, - dispatchedAt, - agentStatusByPaneKey, - retainedAgentsByPaneKey - }) - }) - if (completedRuns.length === 0) { - return - } - for (const run of completedRuns) { - inFlight.add(run.id) - } - void Promise.all( - completedRuns.map((run) => - window.api.automations.markDispatchResult({ - runId: run.id, - status: 'completed', - workspaceId: run.workspaceId, - terminalSessionId: run.terminalSessionId, - terminalPaneKey: run.terminalPaneKey, - terminalPtyId: run.terminalPtyId, - error: null - }) - ) - ) - .then(() => refresh()) - .catch((error) => { - console.error('[automations] failed to mark completed dispatch result:', error) - }) - .finally(() => { - for (const run of completedRuns) { - inFlight.delete(run.id) - } - }) - }, [agentStatusByPaneKey, retainedAgentsByPaneKey, refresh, runs]) - useEffect(() => { if (!draft.projectId) { const target = getDefaultTarget() @@ -1117,6 +1249,7 @@ export default function AutomationsPage(): React.JSX.Element { const target = getDefaultTarget() setEditingAutomationId(null) setEditingExternalTarget(null) + setEditingDestination(null) setCreateTarget('orca') const baseDraft: AutomationDraft = { name: '', @@ -1155,49 +1288,37 @@ export default function AutomationsPage(): React.JSX.Element { setCreateOpen(true) } - const openEditDialog = async (automation: Automation): Promise => { + const openEditDialog = async (row: AutomationListRow): Promise => { const requestId = (editRequestRef.current += 1) setEditingExternalTarget(null) setCreateTarget('orca') - let latest = automation - try { - latest = - (await window.api.automations.list()).find((entry) => entry.id === automation.id) ?? - automation - } catch { - latest = automation + const automationId = row.automation.id + // Why: hydrate inside the row's own captured scope, not from whatever the + // ambient authority happens to hold under the same automation ID. + const reread = await dispatchAutomationReread( + automationDispatchContext, + { rowKey: row.key, automationId }, + async () => + (await listAutomationsForTarget({ kind: 'local' })).find( + (entry) => entry.id === automationId + ) ?? null + ) + if (!reread.ok && reread.notice.severity === 'owner') { + reportOwnerAction(row.key, reread.notice) + return } + // A failed re-read still opens the form on the copy already on screen. + const latest = (reread.ok ? reread.value : null) ?? row.automation if (requestId !== editRequestRef.current) { return } - const schedule = tryParseAutomationRrule(latest.rrule) - const hasCustomSchedule = !schedule && isValidAutomationSchedule(latest.rrule) setEditingAutomationId(latest.id) - const nextDraft: AutomationDraft = { - name: latest.name, - prompt: latest.prompt, - agentId: latest.agentId, - projectId: getAutomationRunRepoId(latest), - workspaceMode: latest.workspaceMode, - workspaceId: latest.workspaceId ?? '', - baseBranch: latest.baseBranch ?? '', - setupDecision: getAutomationSetupDecisionDraftValue({ - workspaceMode: latest.workspaceMode, - persistedSetupDecision: latest.setupDecision - }), - reuseSession: latest.workspaceMode === 'existing' && latest.reuseSession, - precheckCommand: latest.precheck?.command ?? '', - precheckTimeoutSeconds: String(latest.precheck?.timeoutSeconds ?? 60), - preset: schedule?.preset ?? (hasCustomSchedule ? 'custom' : 'weekdays'), - time: schedule ? formatTimeInput(schedule.hour, schedule.minute) : AUTOMATION_DEFAULT_TIME, - dayOfWeek: String(schedule?.dayOfWeek ?? 1), - customSchedule: hasCustomSchedule ? latest.rrule : '', - missedRunGraceMinutes: String(latest.missedRunGraceMinutes), - scheduleWarning: - schedule || hasCustomSchedule - ? null - : 'This automation has an unsupported saved schedule. Pick a supported schedule before saving changes.' - } + setEditingRowKey(row.key) + const initialDestination = destinationForProject(latest.projectId) + setEditingDestination( + initialDestination ? { projectId: latest.projectId, destination: initialDestination } : null + ) + const nextDraft = buildAutomationEditDraft(latest) setDraft(nextDraft) setDraftAtOpen(nextDraft) setCreateOpen(true) @@ -1205,11 +1326,10 @@ export default function AutomationsPage(): React.JSX.Element { const openEditExternalDialog = ( manager: ExternalAutomationManager, - job: ExternalAutomationJob + job: ExternalAutomationJob, + scope: ExternalAutomationScope ): void => { editRequestRef.current += 1 - const rawSchedule = job.rawSchedule?.trim() ?? '' - const hasCustomSchedule = isValidAutomationCronSchedule(rawSchedule) const targetWorktree = Object.values(worktreesByRepo) .flat() @@ -1251,7 +1371,9 @@ export default function AutomationsPage(): React.JSX.Element { : 'This Hermes automation has an unsupported saved schedule. Pick a supported schedule before saving changes.' } setEditingAutomationId(null) - setEditingExternalTarget({ manager, job }) + setEditingRowKey(null) + setEditingDestination(null) + setEditingExternalTarget({ manager, job, scope }) setCreateTarget('hermes') setDraft(nextDraft) setDraftAtOpen(nextDraft) @@ -1262,6 +1384,10 @@ export default function AutomationsPage(): React.JSX.Element { (projectId: string): void => { const currentWorktrees = worktreesByRepo[projectId] ?? [] const currentDefaultWorktree = getDefaultWorktree(currentWorktrees) + if (editingAutomationId !== null) { + const destination = destinationForProject(projectId) + setEditingDestination(destination ? { projectId, destination } : null) + } setDraft((current) => ({ ...current, projectId, @@ -1284,10 +1410,28 @@ export default function AutomationsPage(): React.JSX.Element { ) }) }, - [fetchWorktrees, worktreesByRepo] + [destinationForProject, editingAutomationId, fetchWorktrees, worktreesByRepo] + ) + + const handleDraftChange = useCallback( + (updater: (current: AutomationDraft) => AutomationDraft): void => { + const current = draftRef.current + const next = updater(current) + draftRef.current = next + setDraft(next) + if ( + editingAutomationId !== null && + (next.projectId !== current.projectId || next.workspaceId !== current.workspaceId) + ) { + const destination = destinationForProject(next.projectId) + setEditingDestination(destination ? { projectId: next.projectId, destination } : null) + } + }, + [destinationForProject, editingAutomationId] ) const saveAutomation = async (): Promise => { + setEditorNotice(null) const { hour, minute } = parseDraftTime(draft.time) const isHermesSave = editingAutomationId === null && (createTarget === 'hermes' || editingExternalTarget !== null) @@ -1378,10 +1522,16 @@ export default function AutomationsPage(): React.JSX.Element { ) return } - const target = - editingExternalTarget?.manager.target ?? getExternalAutomationTargetForRepo(repo) - const repoTargetMatches = repoMatchesExternalAutomationTarget(repo, target) - if (!repoTargetMatches) { + // The scope the manager was listed under, never one re-derived from the + // repo at save time — that is how an edit lands on the wrong host. + const scope = editingExternalTarget + ? editingExternalTarget.scope + : scopedExternal.createScope(repo.connectionId ?? null) + const repoTargetMatches = + scope?.owner.selector.kind === 'ssh' + ? repo.connectionId === scope.owner.selector.targetId + : !repo.connectionId + if (!scope || !repoTargetMatches) { toast.error( translate( 'auto.components.automations.AutomationsPage.e431bb85d4', @@ -1390,34 +1540,27 @@ export default function AutomationsPage(): React.JSX.Element { ) return } - const schedule = buildHermesCronSchedule(draft) - const managerId = - editingExternalTarget?.manager.id ?? - (target.type === 'ssh' ? `hermes:ssh:${target.connectionId}` : 'hermes:local') - const input = { - managerId, - provider: 'hermes' as const, - target, - name: draft.name, - prompt: draft.prompt, - schedule, - workdir: selectedWorktree.path - } - await (editingExternalTarget - ? window.api.automations.updateExternal({ - ...input, - jobId: editingExternalTarget.job.id - }) - : window.api.automations.createExternal(input)) + await scopedExternal.saveExternalAutomation( + scope, + { + name: draft.name, + prompt: draft.prompt, + schedule: buildHermesCronSchedule(draft), + workdir: selectedWorktree.path + }, + editingExternalTarget?.job.id ?? null + ) if (!editingExternalTarget) { useAppStore.getState().recordFeatureInteraction('automation-created') } await refresh() setCreateOpen(false) setEditingExternalTarget(null) + // Same helper and same captured scope the row's key was built from, so the + // edited automation stays selected instead of falling back to the first row. selectExternalKey( editingExternalTarget - ? getExternalAutomationKey(editingExternalTarget.manager, editingExternalTarget.job) + ? externalAutomationJobKey(editingExternalTarget.scope, editingExternalTarget.job.id) : null ) toast.success( @@ -1498,14 +1641,67 @@ export default function AutomationsPage(): React.JSX.Element { ? (automations.find((automation) => automation.id === editingAutomationId) ?? null) : null if (editingAutomationId) { - try { - currentAutomation = - (await listAutomationsForTarget(getAutomationListTarget(settings))).find( + // Why: the conflict check reads the row's own scope, and the legacy read + // under it addresses the desktop rather than whichever host was ambient. + const reread = await dispatchAutomationReread( + automationDispatchContext, + { rowKey: editingRowKey ?? '', automationId: editingAutomationId }, + async () => + (await listAutomationsForTarget(automationHostTarget ?? { kind: 'local' })).find( (automation) => automation.id === editingAutomationId - ) ?? currentAutomation - } catch { - // Keep the in-memory automation as a fallback if the refresh fails. + ) ?? null + ) + if (!reread.ok && reread.notice.severity === 'owner') { + setEditorNotice(reread.notice) + return } + currentAutomation = (reread.ok ? reread.value : null) ?? currentAutomation + } + let editDestination: AutomationDestination | undefined + const destinationChanged = + currentAutomation && + (currentAutomation.projectId !== draft.projectId || + currentAutomation.workspaceId !== (draft.workspaceId || null) || + currentAutomation.workspaceMode !== draft.workspaceMode) + if (editingAutomationId && currentAutomation && destinationChanged) { + if (!editingDestination || editingDestination.projectId !== draft.projectId) { + setEditorNotice({ + message: translate( + 'auto.components.automations.createDestination.unavailable', + 'Choose an available project on this host before saving.' + ), + recovery: 'retry', + severity: 'owner' + }) + return + } + const revalidated = revalidateAutomationCreateDestination( + editingDestination.destination, + hostCatalog.entries + ) + if (revalidated.status === 'stale') { + setEditorNotice({ + message: translate( + 'auto.components.automations.createDestination.stale', + '{host} changed while this form was open. Choose the project again before saving.' + ).replace('{host}', revalidated.entry.label), + recovery: 'retry', + severity: 'owner' + }) + return + } + if (revalidated.status !== 'ready') { + setEditorNotice({ + message: translate( + 'auto.components.automations.createDestination.unavailable', + 'Choose an available project on this host before saving.' + ), + recovery: 'retry', + severity: 'owner' + }) + return + } + editDestination = revalidated.destination } const updates: AutomationUpdateInput = { name: draft.name, @@ -1527,14 +1723,26 @@ export default function AutomationsPage(): React.JSX.Element { updates.rrule = rrule updates.dtstart = now } - const automation = editingAutomationId - ? currentAutomation - ? await updateAutomationForTarget(currentAutomation, updates, automationHostTarget) - : await window.api.automations.update({ - id: editingAutomationId, - updates - }) - : await createAutomationForTarget({ + const editSource = currentAutomation + const saved = editingAutomationId + ? await dispatchAutomationUpdate( + automationDispatchContext, + { rowKey: editingRowKey ?? '', automationId: editingAutomationId }, + updates, + () => { + // Nothing names a host: no captured owner and no record to read one + // from, so the save is refused rather than sent unfenced. + if (!editSource) { + throw new Error( + automationOwnerConflictMessage(AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged) + ) + } + return updateAutomationForTarget(editSource, updates, automationHostTarget) + }, + 'save', + editDestination + ) + : await createDraftAutomation({ name: draft.name, prompt: draft.prompt, precheck, @@ -1551,7 +1759,16 @@ export default function AutomationsPage(): React.JSX.Element { dtstart: now, missedRunGraceMinutes }) - if (!editingAutomationId) { + if (!saved.ok) { + setEditorNotice(saved.notice) + return + } + const automation = saved.value + // Create invalidates its stated destination inside `createDraftAutomation`, + // which is the only place that still holds it. + if (editingAutomationId) { + invalidateRowHost(editingRowKey, 'definition') + } else { await hydratePersistedUIState() } setAutomations((current) => { @@ -1560,7 +1777,13 @@ export default function AutomationsPage(): React.JSX.Element { }) setDraft((current) => ({ ...current, name: '', prompt: '' })) await refresh() - selectAutomationId(automation.id) + if (editingAutomationId && editingRowKey) { + setSelectedAutomationRunPageId(null) + setSelectedRowKey(editingRowKey) + setSelectedId(automation.id) + } else { + selectAutomationId(automation.id) + } setCreateOpen(false) if (!editingAutomationId) { useAppStore.getState().recordFeatureInteraction('automation-created') @@ -1590,64 +1813,77 @@ export default function AutomationsPage(): React.JSX.Element { } } - const toggleAutomation = async (automation: Automation): Promise => { - await updateAutomationForTarget( - automation, - { enabled: !automation.enabled }, - automationHostTarget + /** Creation states its destination and re-checks it at submit; it never infers one. */ + const createDraftAutomation = async ( + input: AutomationCreateInput + ): Promise> => { + const checked = createDestination.check(input.projectId) + if (!checked.ok) { + return { ok: false, notice: checked.notice } + } + const result = toDispatchResult( + await createAutomationAtDestination( + checked.destination.authority, + input, + checked.destination.destination + ) ) - await refresh() + if (result.ok) { + invalidateWrittenHost(checked.destination.entry.stableRef, 'definition') + } + return result } - const deleteAutomation = async (automation: Automation): Promise => { - await deleteAutomationForTarget(automation, automationHostTarget) - if (useAppStore.getState().selectedAutomationId === automation.id) { - selectAutomationId(null) - setIsDetailOpen(false) - setSelectedAutomationRunPageId(null) - setActivePaneTab('overview') + const toggleAutomation = async (row: AutomationListRow): Promise => { + const automation = row.automation + const result = await dispatchAutomationUpdate( + automationDispatchContext, + { rowKey: row.key, automationId: automation.id }, + { enabled: !automation.enabled }, + () => + updateAutomationForTarget( + automation, + { enabled: !automation.enabled }, + automationHostTargetFor(row) + ) + ) + reportOwnerAction(row.key, result.ok ? null : result.notice) + if (result.ok) { + invalidateRowHost(row.key, 'definition') } await refresh() } - const persistDeleteAutomationPreference = (): void => { - void updateSettings({ skipDeleteAutomationConfirm: true }) - toast.success( - translate( - 'auto.components.automations.AutomationsPage.690b94da54', - "We'll skip this confirmation next time." - ), - { - description: translate( - 'auto.components.automations.AutomationsPage.d2a01b0b6f', - 'You can change this in Settings.' - ), - duration: 8000, - action: { - label: translate( - 'auto.components.automations.AutomationsPage.8a3226f172', - 'Open Settings' - ), - onClick: () => { - openSettingsPage() - openSettingsTarget({ - pane: 'general', - repoId: null, - sectionId: 'general-skip-delete-automation-confirm' - }) - } - } - } + const deleteAutomation = async (row: AutomationListRow): Promise => { + const automation = row.automation + const result = await dispatchAutomationDelete( + automationDispatchContext, + { rowKey: row.key, automationId: automation.id }, + () => deleteAutomationForTarget(automation, automationHostTargetFor(row)) ) + reportOwnerAction(row.key, result.ok ? null : result.notice) + if (result.ok) { + if (selectedRowKey === row.key) { + selectAutomationId(null) + setIsDetailOpen(false) + setSelectedAutomationRunPageId(null) + setActivePaneTab('overview') + } + invalidateRowHost(row.key, 'definition') + } + await refresh() } - const requestDeleteAutomation = (automation: Automation): void => { + const persistDeleteAutomationPreference = (): void => + persistSkipDeleteAutomationConfirm({ updateSettings, openSettingsPage, openSettingsTarget }) + + const requestDeleteAutomation = (row: AutomationListRow): void => { if (settings?.skipDeleteAutomationConfirm) { - void deleteAutomation(automation) + void deleteAutomation(row) return } setDontAskDeleteAgain(false) - setDeleteTarget(automation) + setDeleteTarget(row) } const confirmDeleteAutomation = async (): Promise => { @@ -1663,11 +1899,13 @@ export default function AutomationsPage(): React.JSX.Element { await deleteAutomation(target) } - const runNow = async (automation: Automation): Promise => { - const repo = repoMap.get(getAutomationRunRepoId(automation)) ?? null + const runNow = async (row: AutomationListRow): Promise => { + const automation = row.automation + const repo = repoForRow(row) ?? null const workspace = automation.workspaceId - ? (worktreeMap.get(automation.workspaceId) ?? null) + ? (worktreeForRow(row, repo ?? undefined) ?? null) : null + const rowHostTarget = automationHostTargetFor(row) const availability = getAutomationTargetAvailability({ automation, repo, @@ -1675,15 +1913,27 @@ export default function AutomationsPage(): React.JSX.Element { projectHostSetups, sshConnectionStates, runtimeStatusByEnvironmentId, - automationHostTarget, - sourceHostAvailability: automationSourceHostAvailabilityById.get(automation.id) + automationHostTarget: rowHostTarget, + sourceHostAvailability: automationSourceHostAvailabilityByRowKey.get(row.key) }) if (!availability.canRunNow) { toast.error(availability.message) return } - await runAutomationNowForTarget(automation, automationHostTarget) + const result = await dispatchAutomationRunNow( + automationDispatchContext, + { rowKey: row.key, automationId: automation.id }, + () => runAutomationNowForTarget(automation, rowHostTarget) + ) + reportOwnerAction(row.key, result.ok ? null : result.notice) + if (!result.ok) { + return + } useAppStore.getState().recordFeatureInteraction('automation-run') + // A run rewrites this host's run history and its next-run projection, and the + // list renders that host's cache — `refresh()` alone only feeds the pre-catalog + // `automations`, which is no longer what is on screen. + invalidateRowHost(row.key, 'run') await hydratePersistedUIState() await refresh() toast.message( @@ -1691,7 +1941,7 @@ export default function AutomationsPage(): React.JSX.Element { ) } - const rerunAutomationRun = async (automation: Automation, run: AutomationRun): Promise => { + const rerunAutomationRun = async (row: AutomationListRow, run: AutomationRun): Promise => { const runId = run.id if (rerunRunIdsInFlightRef.current.has(runId)) { return @@ -1700,7 +1950,17 @@ export default function AutomationsPage(): React.JSX.Element { rerunRunIdsInFlightRef.current.add(runId) setRerunRunIdsInFlight(new Set(rerunRunIdsInFlightRef.current)) try { - await runAutomationNowForTarget(automation, automationHostTarget) + const result = await dispatchAutomationRunNow( + automationDispatchContext, + { rowKey: row.key, automationId: row.automation.id }, + () => runAutomationNowForTarget(row.automation, automationHostTargetFor(row)) + ) + reportOwnerAction(row.key, result.ok ? null : result.notice) + if (!result.ok) { + await refresh() + return + } + invalidateRowHost(row.key, 'run') await hydratePersistedUIState() await refresh() toast.message( @@ -1728,20 +1988,16 @@ export default function AutomationsPage(): React.JSX.Element { } const runExternalAction = async ( - manager: ExternalAutomationManager, + scope: ExternalAutomationScope, job: ExternalAutomationJob, action: ExternalAutomationAction ): Promise => { - const key = `${manager.id}:${job.id}:${action}` - setExternalActionKey(key) + // Keyed by the scope the row was discovered under — the same one the call + // below routes to — so two hosts running the same provider can neither + // disable each other's buttons nor receive each other's delete. + setExternalActionKey(externalAutomationActionKey(scope, job.id, action)) try { - await window.api.automations.runExternalAction({ - managerId: manager.id, - provider: manager.provider, - target: manager.target, - jobId: job.id, - action - }) + await scopedExternal.runExternalAction(scope, job.id, action) if (action === 'run') { useAppStore.getState().recordFeatureInteraction('automation-run') } @@ -1749,7 +2005,7 @@ export default function AutomationsPage(): React.JSX.Element { // Why: full-page detail keeps selection when the deleted external was open; // without this, detail can fall through to an unrelated local automation. if (action === 'delete') { - const deletedKey = getExternalAutomationKey(manager, job) + const deletedKey = externalAutomationJobKey(scope, job.id) if (selectedExternalKeyRef.current === deletedKey) { selectExternalKey(null) setIsDetailOpen(false) @@ -1793,38 +2049,22 @@ export default function AutomationsPage(): React.JSX.Element { } const fetchExternalAutomationRuns = useCallback( - async ({ manager, job, page, pageSize }) => { - const fallbackRunsPage = { - runs: job.runs.slice(page * pageSize, page * pageSize + pageSize), - totalCount: job.runCount - } - const listExternalRuns = ( - window.api.automations as Partial> - ).listExternalRuns - if (typeof listExternalRuns !== 'function') { - return fallbackRunsPage - } + async ({ scope, job, page, pageSize }) => { try { - const result = await listExternalRuns({ - managerId: manager.id, - provider: manager.provider, - target: manager.target, - jobId: job.id, - page: page + 1, - pageSize - }) - return { - runs: result.runs, - totalCount: result.total - } + const result = await fetchScopedExternalRuns(scope, job, page, pageSize) + return { runs: [...result.runs], totalCount: result.totalCount } } catch (error) { if (isMissingExternalRunsApiError(error)) { - return fallbackRunsPage + // An old host with no runs endpoint still has the runs the job carried. + return { + runs: job.runs.slice(page * pageSize, page * pageSize + pageSize), + totalCount: job.runCount + } } throw error } }, - [] + [fetchScopedExternalRuns] ) const openExternalRunPage = ( @@ -1842,13 +2082,14 @@ export default function AutomationsPage(): React.JSX.Element { const requestExternalAction = ( manager: ExternalAutomationManager, job: ExternalAutomationJob, - action: ExternalAutomationAction + action: ExternalAutomationAction, + scope: ExternalAutomationScope ): void => { if (action === 'delete') { - setExternalDeleteTarget({ manager, job }) + setExternalDeleteTarget({ manager, job, scope }) return } - void runExternalAction(manager, job, action) + void runExternalAction(scope, job, action) } const confirmDeleteExternalAutomation = async (): Promise => { @@ -1857,11 +2098,16 @@ export default function AutomationsPage(): React.JSX.Element { } const target = externalDeleteTarget setExternalDeleteTarget(null) - await runExternalAction(target.manager, target.job, 'delete') + // The scope the confirmed row was listed under, not one re-derived after the + // dialog opened: a delete is the action least able to survive a wrong host. + await runExternalAction(target.scope, target.job, 'delete') } const openRunWorkspace = (run: AutomationRun): void => { - const runWorktree = run.workspaceId ? (worktreeMap.get(run.workspaceId) ?? null) : null + const runWorktree = + run.workspaceId && selectedRow + ? (worktreeForRow(selectedRow, repoForRow(selectedRow), run.workspaceId) ?? null) + : null const store = useAppStore.getState() const openTabId = getAutomationRunOpenTabId(run) const terminalTabExists = openTabId ? Boolean(store.getTab(openTabId)) : false @@ -1985,7 +2231,6 @@ export default function AutomationsPage(): React.JSX.Element {
+ { + const host = ownerAction?.host ?? null + setOwnerAction(null) + hostCatalog.recover(action, host) + if (action === 'retry') { + void refresh() + } + }} + onDismiss={() => setOwnerAction(null)} + /> + { + setEditorNotice(null) + hostCatalog.recover(action, editorRecoveryHost) + if (action === 'retry') { + void refresh() + } + }} + onNoticeDismiss={() => setEditorNotice(null)} onProjectChange={handleProjectChange} getRepoHostLabel={getAutomationRepoHostLabel} allowAddProject={!isOrcaForm || automationDialogTarget.kind === 'local'} onCreateTargetChange={handleCreateTargetChange} - onOpenChange={setCreateOpen} - onDraftChange={setDraft} + onOpenChange={(open) => { + setCreateOpen(open) + if (!open) { + setEditorNotice(null) + } + }} + onDraftChange={handleDraftChange} onSetupDecisionTouched={markSetupDecisionTouched} onApplyTemplate={applyTemplateToDraft} onSave={() => void saveAutomation()} /> { @@ -2064,6 +2338,14 @@ export default function AutomationsPage(): React.JSX.Element { selectedExternalRunPage={selectedExternalRunPage} selectedAutomationRunPage={selectedAutomationRunPage} selectedRuns={selectedRuns} + selectedRunsNotice={selectedRunsNotice} + recoverSelectedRuns={(action) => { + // Reconnect/Update server act on the selected row's own host; the + // re-ask is what brings this automation's history back either way. + hostCatalog.recover(action, rowRecoveryHost(selectedRowKey)) + setSelectedAutomationRuns((current) => ({ ...current, notice: null })) + setRunHistoryReloadToken((token) => token + 1) + }} activePaneTab={activePaneTab} relativeNow={relativeNow} externalActionKey={externalActionKey} @@ -2090,7 +2372,7 @@ export default function AutomationsPage(): React.JSX.Element { selectedAutomationRunPageViewState={selectedAutomationRunPageViewState} canRerunSelectedAutomationRunPage={canRerunSelectedAutomationRunPage} isSelectedAutomationRunPageRerunPending={isSelectedAutomationRunPageRerunPending} - worktreeMap={worktreeMap} + worktreeMap={selectedRunWorktreeMap} fetchExternalAutomationRuns={fetchExternalAutomationRuns} onActivePaneTabChange={setActivePaneTab} onClearExternalRunPage={() => setSelectedExternalRunPage(null)} @@ -2098,11 +2380,13 @@ export default function AutomationsPage(): React.JSX.Element { requestExternalAction={requestExternalAction} openExternalRunPage={openExternalRunPage} openEditExternalDialog={openEditExternalDialog} - runNow={(automation) => void runNow(automation)} - openEditDialog={(automation) => void openEditDialog(automation)} - toggleAutomation={(automation) => void toggleAutomation(automation)} - requestDeleteAutomation={requestDeleteAutomation} - rerunAutomationRun={(automation, run) => void rerunAutomationRun(automation, run)} + runNow={() => onSelectedRow(runNow)} + openEditDialog={() => onSelectedRow(openEditDialog)} + toggleAutomation={() => onSelectedRow(toggleAutomation)} + requestDeleteAutomation={() => onSelectedRow(requestDeleteAutomation)} + rerunAutomationRun={(_automation, run) => + onSelectedRow((row) => rerunAutomationRun(row, run)) + } openRunWorkspace={openRunWorkspace} openAutomationRunPage={openAutomationRunPage} onBackToList={() => { @@ -2116,42 +2400,69 @@ export default function AutomationsPage(): React.JSX.Element { setListSort((current) => nextAutomationListSort(current, field))} - selectedId={selectedId} + onListFilterChange={(next) => { + setListFilter(next) + // Host narrowing is row-side now; a leftover single-host query scope + // would hide the very rows the menu is asking for. + if ( + (next.hostStableKeys?.length ?? 0) > 0 && + hostCatalog.resolution.effective.kind !== 'all' + ) { + hostCatalog.selectHost({ kind: 'all' }) + } + }} + // Pre-filter count, so "no match" is distinguishable from an empty host. + searchCounts={{ + ...searchCounts, + hostRowCount: visibleRows.length + externalAutomationEntries.length + }} + hostCatalog={hostCatalog} + externalManagersUncheckedNotice={externalManagersUncheckedNotice} + onSelectHost={hostCatalog.selectHost} + onRecoverHost={(action, entry) => { + hostCatalog.recover(action, entry) + if (action === 'retry') { + void refresh() + } + }} + isActionEnabled={isAutomationRowActionEnabled} + filteredRows={filteredRows} + filteredExternalAutomationEntries={filteredExternalAutomationEntries} + selectedRowKey={selectedRowKey} selectedExternalKey={selectedExternalKey} - runs={runs} + selectedExternal={selectedExternal} relativeNow={relativeNow} repoMap={repoMap} worktreeMap={worktreeMap} + repoForRow={repoForRow} + worktreeForRow={worktreeForRow} projectHostSetups={projectHostSetups} sshConnectionStates={sshConnectionStates} runtimeStatusByEnvironmentId={runtimeStatusByEnvironmentId} - automationHostTarget={automationHostTarget} - automationSourceHostAvailabilityById={automationSourceHostAvailabilityById} + hostTargetFor={automationHostTargetFor} + automationSourceHostAvailabilityByRowKey={automationSourceHostAvailabilityByRowKey} hostLabelById={hostLabelById} externalActionKey={externalActionKey} - selectAutomationId={selectAutomationId} + selectAutomationRow={selectAutomationRow} selectExternalKey={selectExternalKey} setActivePaneTab={setActivePaneTab} - runNow={(automation) => void runNow(automation)} - openEditDialog={(automation) => void openEditDialog(automation)} - toggleAutomation={(automation) => void toggleAutomation(automation)} + runNow={(row) => void runNow(row)} + openEditDialog={(row) => void openEditDialog(row)} + toggleAutomation={(row) => void toggleAutomation(row)} requestDeleteAutomation={requestDeleteAutomation} requestExternalAction={requestExternalAction} openEditExternalDialog={openEditExternalDialog} openCreateDialog={openCreateDialog} canCreateAutomation={canCreateAutomation} onOpenDetail={() => setIsDetailOpen(true)} - onRefresh={() => void refresh()} + onRefresh={() => { + hostCatalog.refreshHosts() + void refresh() + }} isRefreshing={isLoading} /> )} diff --git a/src/renderer/src/components/automations/AutomationsPageSkeleton.tsx b/src/renderer/src/components/automations/AutomationsPageSkeleton.tsx index c2df447b04e..1b0ce301e4b 100644 --- a/src/renderer/src/components/automations/AutomationsPageSkeleton.tsx +++ b/src/renderer/src/components/automations/AutomationsPageSkeleton.tsx @@ -12,6 +12,7 @@ function TableRowSkeleton({ nameWidthClass, scheduleWidthClass, projectWidthClass, + hostWidthClass, nextWidthClass, lastRunWidthClass, statusWidthClass @@ -19,6 +20,7 @@ function TableRowSkeleton({ nameWidthClass: string scheduleWidthClass: string projectWidthClass: string + hostWidthClass: string nextWidthClass: string lastRunWidthClass: string statusWidthClass: string @@ -28,6 +30,7 @@ function TableRowSkeleton({ + @@ -43,6 +46,7 @@ const TABLE_ROW_SKELETONS = [ nameWidthClass: 'w-28', scheduleWidthClass: 'w-36', projectWidthClass: 'w-28', + hostWidthClass: 'w-20', nextWidthClass: 'w-24', lastRunWidthClass: 'w-20', statusWidthClass: 'w-16' @@ -52,6 +56,7 @@ const TABLE_ROW_SKELETONS = [ nameWidthClass: 'w-36', scheduleWidthClass: 'w-28', projectWidthClass: 'w-32', + hostWidthClass: 'w-24', nextWidthClass: 'w-20', lastRunWidthClass: 'w-24', statusWidthClass: 'w-14' @@ -61,6 +66,7 @@ const TABLE_ROW_SKELETONS = [ nameWidthClass: 'w-24', scheduleWidthClass: 'w-40', projectWidthClass: 'w-24', + hostWidthClass: 'w-16', nextWidthClass: 'w-28', lastRunWidthClass: 'w-16', statusWidthClass: 'w-16' @@ -70,6 +76,7 @@ const TABLE_ROW_SKELETONS = [ nameWidthClass: 'w-32', scheduleWidthClass: 'w-32', projectWidthClass: 'w-36', + hostWidthClass: 'w-20', nextWidthClass: 'w-24', lastRunWidthClass: 'w-20', statusWidthClass: 'w-14' @@ -79,6 +86,7 @@ const TABLE_ROW_SKELETONS = [ nameWidthClass: 'w-40', scheduleWidthClass: 'w-24', projectWidthClass: 'w-28', + hostWidthClass: 'w-24', nextWidthClass: 'w-20', lastRunWidthClass: 'w-24', statusWidthClass: 'w-16' @@ -88,6 +96,7 @@ const TABLE_ROW_SKELETONS = [ nameWidthClass: 'w-28', scheduleWidthClass: 'w-36', projectWidthClass: 'w-20', + hostWidthClass: 'w-20', nextWidthClass: 'w-24', lastRunWidthClass: 'w-16', statusWidthClass: 'w-14' @@ -120,6 +129,7 @@ export function AutomationsPageSkeleton(): React.JSX.Element { + diff --git a/src/renderer/src/components/automations/CreateFromPicker.tsx b/src/renderer/src/components/automations/CreateFromPicker.tsx index a392627ee18..d990be6b12b 100644 --- a/src/renderer/src/components/automations/CreateFromPicker.tsx +++ b/src/renderer/src/components/automations/CreateFromPicker.tsx @@ -43,7 +43,9 @@ export function CreateFromPicker({ triggerClassName?: string onValueChange: (baseBranch: string) => void }): React.JSX.Element { - const activeRuntimeEnvironmentId = useAppStore((state) => + // Per-repo evidence, not the ambient active-runtime setting; the base-ref helpers + // just take a settings-shaped object, so it is synthesized at each call below. + const repoRuntimeEnvironmentId = useAppStore((state) => getRuntimeEnvironmentIdForRepo(state, repoId) ) const repo = repoMap.get(repoId) @@ -116,7 +118,10 @@ export function CreateFromPicker({ } let stale = false setDefaultBaseRef(null) - void getRuntimeRepoBaseRefDefault({ activeRuntimeEnvironmentId }, repoId) + void getRuntimeRepoBaseRefDefault( + { activeRuntimeEnvironmentId: repoRuntimeEnvironmentId }, + repoId + ) .then((result) => { if (!stale) { setDefaultBaseRef(result.defaultBaseRef) @@ -130,7 +135,7 @@ export function CreateFromPicker({ return () => { stale = true } - }, [activeRuntimeEnvironmentId, repoId]) + }, [repoRuntimeEnvironmentId, repoId]) React.useEffect(() => { if (!isRuntimeRepoRefSearchQueryWithinLimit(query)) { @@ -148,7 +153,12 @@ export function CreateFromPicker({ let stale = false setIsSearching(true) const timer = window.setTimeout(() => { - void searchRuntimeRepoBaseRefs({ activeRuntimeEnvironmentId }, repoId, trimmedQuery, 30) + void searchRuntimeRepoBaseRefs( + { activeRuntimeEnvironmentId: repoRuntimeEnvironmentId }, + repoId, + trimmedQuery, + 30 + ) .then((results) => { if (!stale) { setSearchResults(results) @@ -170,7 +180,7 @@ export function CreateFromPicker({ stale = true window.clearTimeout(timer) } - }, [activeRuntimeEnvironmentId, open, query, repoId]) + }, [repoRuntimeEnvironmentId, open, query, repoId]) return (
diff --git a/src/renderer/src/components/automations/ExternalAutomationManagers.test.tsx b/src/renderer/src/components/automations/ExternalAutomationManagers.test.tsx index 8d21b62da6f..06c7c66f099 100644 --- a/src/renderer/src/components/automations/ExternalAutomationManagers.test.tsx +++ b/src/renderer/src/components/automations/ExternalAutomationManagers.test.tsx @@ -10,6 +10,11 @@ import type { ExternalAutomationProvider } from '../../../../shared/automations-types' import { ExternalAutomationManagers } from './ExternalAutomationManagers' +import type { + ExternalAutomationScope, + ScopedExternalAutomationManager +} from './external-automation-scope-client' +import { externalAutomationActionKey } from './external-automation-scope-keys' // Why: act(...) warnings are silenced by opting this module into the React act // environment, matching how the renderer mounts under test. @@ -73,17 +78,45 @@ function makeManager( } } +/** The desktop-self host every manager here is discovered on unless named otherwise. */ +function makeScope(environmentId: string | null = null): ExternalAutomationScope { + return { + owner: { + authority: + environmentId === null + ? { kind: 'desktop' } + : { kind: 'runtime', environmentId, pairingRevision: 1 }, + selector: { kind: 'self' } + }, + provider: 'hermes' + } +} + +function scoped( + overrides: Partial = {}, + scope: ExternalAutomationScope = makeScope() +): ScopedExternalAutomationManager { + return { scope, manager: makeManager(overrides) } +} + type OnActionMock = ReturnType< typeof vi.fn< ( manager: ExternalAutomationManager, job: ExternalAutomationJob, - action: ExternalAutomationAction + action: ExternalAutomationAction, + scope: ExternalAutomationScope ) => void > > type OnEditMock = ReturnType< - typeof vi.fn<(manager: ExternalAutomationManager, job: ExternalAutomationJob) => void> + typeof vi.fn< + ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + scope: ExternalAutomationScope + ) => void + > > type RenderOptions = { @@ -93,7 +126,7 @@ type RenderOptions = { } function renderManagers( - managers: ExternalAutomationManager[], + managers: ScopedExternalAutomationManager[], options: RenderOptions = {} ): { onAction: OnActionMock; onEdit: OnEditMock } { const onAction = options.onAction ?? vi.fn() @@ -112,10 +145,10 @@ function renderManagers( return { onAction, onEdit } } -function getSwitch(): HTMLButtonElement { - const node = container.querySelector('button[role="switch"]') +function getSwitch(index = 0): HTMLButtonElement { + const node = container.querySelectorAll('button[role="switch"]')[index] if (!(node instanceof HTMLButtonElement)) { - throw new Error('expected a role="switch" control') + throw new Error(`expected a role="switch" control at ${index}`) } return node } @@ -142,48 +175,109 @@ describe('ExternalAutomationManagers toggle', () => { }) it('renders a switch reflecting the enabled state via aria-checked', () => { - renderManagers([makeManager({ jobs: [makeJob({ enabled: true })] })]) + renderManagers([scoped({ jobs: [makeJob({ enabled: true })] })]) expect(getSwitch().getAttribute('aria-checked')).toBe('true') }) it('renders aria-checked=false for a paused job', () => { - renderManagers([makeManager({ jobs: [makeJob({ enabled: false })] })]) + renderManagers([scoped({ jobs: [makeJob({ enabled: false })] })]) expect(getSwitch().getAttribute('aria-checked')).toBe('false') }) it('dispatches pause when toggling an enabled job', () => { - const manager = makeManager({ jobs: [makeJob({ enabled: true })] }) - const { onAction } = renderManagers([manager]) + const entry = scoped({ jobs: [makeJob({ enabled: true })] }) + const { onAction } = renderManagers([entry]) act(() => { getSwitch().click() }) - expect(onAction).toHaveBeenCalledWith(manager, manager.jobs[0], 'pause') + expect(onAction).toHaveBeenCalledWith( + entry.manager, + entry.manager.jobs[0], + 'pause', + entry.scope + ) }) it('dispatches resume when toggling a paused job', () => { - const manager = makeManager({ jobs: [makeJob({ enabled: false })] }) - const { onAction } = renderManagers([manager]) + const entry = scoped({ jobs: [makeJob({ enabled: false })] }) + const { onAction } = renderManagers([entry]) act(() => { getSwitch().click() }) - expect(onAction).toHaveBeenCalledWith(manager, manager.jobs[0], 'resume') + expect(onAction).toHaveBeenCalledWith( + entry.manager, + entry.manager.jobs[0], + 'resume', + entry.scope + ) }) it('disables the switch when the manager cannot be managed', () => { - renderManagers([makeManager({ canManage: false })]) + renderManagers([scoped({ canManage: false })]) expect(getSwitch().disabled).toBe(true) }) it('shows the sibling spinner only while a pause/resume targets this row', () => { - const manager = makeManager({ jobs: [makeJob({ id: 'job-1', enabled: true })] }) + const entry = scoped({ jobs: [makeJob({ id: 'job-1', enabled: true })] }) // Keyed for the resume action even though the job is enabled — the spinner // must match either pause or resume so it does not vanish when enabled flips. - renderManagers([manager], { runningActionKey: 'manager-1:job-1:resume' }) + renderManagers([entry], { + runningActionKey: externalAutomationActionKey(entry.scope, 'job-1', 'resume') + }) expect(container.querySelector('.animate-spin')).not.toBeNull() }) + it('spins only the host whose action is in flight when two authorities share a manager ID', () => { + // Both hosts report `hermes:local` with the same job ID, because that ID + // names a provider and a target kind but no authority. The page keys the + // in-flight action by scope, so the component must too — matching on + // manager and job alone would spin the wrong host's row, and a user would + // watch a machine they never touched appear to run their automation. + const local = { id: 'hermes:local', jobs: [makeJob({ id: 'job-1', enabled: true })] } + const desktop = scoped(local, makeScope()) + const runtime = scoped(local, makeScope('env-7')) + + renderManagers([desktop, runtime], { + runningActionKey: externalAutomationActionKey(runtime.scope, 'job-1', 'pause') + }) + + // The spinner sits beside the switch it belongs to, so the switch it shares a + // parent with is the row the component considers in flight. + const spinners = container.querySelectorAll('.animate-spin') + expect(spinners).toHaveLength(1) + expect(spinners[0]?.parentElement?.contains(getSwitch(1))).toBe(true) + expect(spinners[0]?.parentElement?.contains(getSwitch(0))).toBe(false) + }) + + it('labels the two rows apart when two authorities share a manager and job ID', () => { + const local = { id: 'hermes:local', jobs: [makeJob({ id: 'job-1' })] } + renderManagers([scoped(local, makeScope()), scoped(local, makeScope('env-7'))]) + + // A name element ID built from manager and job alone would repeat, and the + // second switch's aria-labelledby would resolve to the first host's name. + expect(getSwitch(0).getAttribute('aria-labelledby')).not.toBe( + getSwitch(1).getAttribute('aria-labelledby') + ) + }) + + it('dispatches each row against its own authority when two share a manager ID', () => { + const local = { id: 'hermes:local', jobs: [makeJob({ id: 'job-1', enabled: true })] } + const desktop = scoped(local, makeScope()) + const runtime = scoped(local, makeScope('env-7')) + const { onAction } = renderManagers([desktop, runtime]) + + act(() => { + getSwitch(1).click() + }) + + // The scope reaches the handler from the row, so a pause on the runtime's + // row can never be replayed against the desktop's identically named cron job. + expect(onAction).toHaveBeenCalledTimes(1) + expect(onAction.mock.calls[0]?.[3]).toEqual(runtime.scope) + }) + it('keeps the Run/Edit/Delete actions and removes the pause/resume button on hermes', () => { - renderManagers([makeManager({ provider: 'hermes' })]) + renderManagers([scoped({ provider: 'hermes' })]) const labels = actionButtonLabels() expect(labels).toContain('Run external automation') expect(labels).toContain('Edit external automation') @@ -193,7 +287,7 @@ describe('ExternalAutomationManagers toggle', () => { }) it('keeps Run/Delete (no Edit) for openclaw and removes the pause/resume button', () => { - renderManagers([makeManager({ provider: 'openclaw' })]) + renderManagers([scoped({ provider: 'openclaw' })]) const labels = actionButtonLabels() expect(labels).toContain('Run external automation') expect(labels).toContain('Delete external automation') diff --git a/src/renderer/src/components/automations/ExternalAutomationManagers.tsx b/src/renderer/src/components/automations/ExternalAutomationManagers.tsx index f64684f8c29..815d3918215 100644 --- a/src/renderer/src/components/automations/ExternalAutomationManagers.tsx +++ b/src/renderer/src/components/automations/ExternalAutomationManagers.tsx @@ -10,6 +10,15 @@ import type { ExternalAutomationManager, ExternalAutomationRun } from '../../../../shared/automations-types' +import type { + ExternalAutomationScope, + ScopedExternalAutomationManager +} from './external-automation-scope-client' +import { + externalAutomationActionKey, + externalAutomationJobKey, + externalAutomationManagerKey +} from './external-automation-scope-keys' import { formatExternalDate, getExternalProviderLabel, @@ -24,13 +33,15 @@ import { getExternalAutomationActionDisabledMessage } from './external-automatio import { translate } from '@/i18n/i18n' type ExternalAutomationManagersProps = { - managers: ExternalAutomationManager[] + /** Each manager carries the host it was discovered on; its ID cannot name one. */ + managers: readonly ScopedExternalAutomationManager[] now: number runningActionKey: string | null onAction: ( manager: ExternalAutomationManager, job: ExternalAutomationJob, - action: ExternalAutomationAction + action: ExternalAutomationAction, + scope: ExternalAutomationScope ) => void onFetchRuns?: FetchExternalAutomationRuns onOpenRun?: ( @@ -38,15 +49,11 @@ type ExternalAutomationManagersProps = { job: ExternalAutomationJob, run: ExternalAutomationRun ) => void - onEdit?: (manager: ExternalAutomationManager, job: ExternalAutomationJob) => void -} - -function actionKey( - manager: ExternalAutomationManager, - job: ExternalAutomationJob, - action: ExternalAutomationAction -): string { - return `${manager.id}:${job.id}:${action}` + onEdit?: ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + scope: ExternalAutomationScope + ) => void } function ExternalActionButton({ @@ -92,7 +99,7 @@ export function ExternalAutomationManagers({ onOpenRun, onEdit }: ExternalAutomationManagersProps): React.JSX.Element { - const automationCount = managers.reduce((sum, manager) => sum + manager.jobs.length, 0) + const automationCount = managers.reduce((sum, { manager }) => sum + manager.jobs.length, 0) return (
@@ -119,8 +126,8 @@ export function ExternalAutomationManagers({
- {managers.map((manager) => ( -
+ {managers.map(({ scope, manager }) => ( +
{manager.targetLabel}
@@ -154,17 +161,20 @@ export function ExternalAutomationManagers({ manager, actionInProgress: runningActionKey !== null }) + // Scope-qualified so two hosts running the same provider cannot + // label each other's rows or spin each other's buttons. + const jobKey = externalAutomationJobKey(scope, job.id) + const nameId = `automation-name-${jobKey}` + const actionKey = (action: ExternalAutomationAction): string => + externalAutomationActionKey(scope, job.id, action) return (
- + {job.name} @@ -185,17 +195,17 @@ export function ExternalAutomationManagers({ {/* Match either key: job.enabled flips mid-flight, so a single-action match would drop the spinner a render early. */} - {runningActionKey === actionKey(manager, job, 'pause') || - runningActionKey === actionKey(manager, job, 'resume') ? ( + {runningActionKey === actionKey('pause') || + runningActionKey === actionKey('resume') ? ( ) : null} - onAction(manager, job, job.enabled ? 'pause' : 'resume') + onAction(manager, job, job.enabled ? 'pause' : 'resume', scope) } disabled={disabledMessage !== null} - ariaLabelledBy={`automation-name-${manager.id}-${job.id}`} + ariaLabelledBy={nameId} /> @@ -266,9 +276,9 @@ export function ExternalAutomationManagers({ ) } disabled={disabledMessage !== null} - onClick={() => onAction(manager, job, 'run')} + onClick={() => onAction(manager, job, 'run', scope)} > - {runningActionKey === actionKey(manager, job, 'run') ? ( + {runningActionKey === actionKey('run') ? ( ) : ( @@ -284,7 +294,7 @@ export function ExternalAutomationManagers({ ) } disabled={disabledMessage !== null} - onClick={() => onEdit?.(manager, job)} + onClick={() => onEdit?.(manager, job, scope)} > @@ -299,9 +309,9 @@ export function ExternalAutomationManagers({ } className="text-destructive hover:text-destructive" disabled={disabledMessage !== null} - onClick={() => onAction(manager, job, 'delete')} + onClick={() => onAction(manager, job, 'delete', scope)} > - {runningActionKey === actionKey(manager, job, 'delete') ? ( + {runningActionKey === actionKey('delete') ? ( ) : ( @@ -311,6 +321,7 @@ export function ExternalAutomationManagers({ {manager.provider === 'hermes' ? (
Promise type ExternalAutomationRunTableProps = { + scope: ExternalAutomationScope manager: ExternalAutomationManager job: ExternalAutomationJob now: number @@ -59,6 +64,7 @@ function normalizeRunPage( } export function ExternalAutomationRunTable({ + scope, manager, job, now, @@ -67,11 +73,16 @@ export function ExternalAutomationRunTable({ }: ExternalAutomationRunTableProps): React.JSX.Element { const [tableState, setTableState] = useState(() => createExternalAutomationRunTableState(job)) const [isLoading, setIsLoading] = useState(false) + const scopeRef = useRef(scope) const managerRef = useRef(manager) const jobRef = useRef(job) + scopeRef.current = scope managerRef.current = manager jobRef.current = job + // Refetch when the host changes, not just the job: the same manager and job ID + // can be a different machine's cron entry. + const scopeKey = externalAutomationScopeKey(scope) const resolvedTableState = resolveExternalAutomationRunTableState(tableState, job) if (resolvedTableState !== tableState) { @@ -92,6 +103,7 @@ export function ExternalAutomationRunTable({ fetchError: null })) void onFetchRuns({ + scope: scopeRef.current, manager: managerRef.current, job: jobRef.current, page, @@ -124,7 +136,7 @@ export function ExternalAutomationRunTable({ return () => { cancelled = true } - }, [job.id, manager.id, onFetchRuns, page]) + }, [job.id, manager.id, onFetchRuns, page, scopeKey]) const fallbackRuns = job.runs const visibleRuns = onFetchRuns diff --git a/src/renderer/src/components/automations/automation-authority-identity.test.ts b/src/renderer/src/components/automations/automation-authority-identity.test.ts new file mode 100644 index 00000000000..33ace9f748f --- /dev/null +++ b/src/renderer/src/components/automations/automation-authority-identity.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import { + automationAuthorityPartitionContext, + automationRuntimePairingRevision, + groupReposByAutomationAuthority +} from './automation-authority-identity' + +function repo(overrides: Partial & { id: string }): Repo { + return { + path: `/repos/${overrides.id}`, + displayName: overrides.id, + badgeColor: 'blue', + addedAt: 0, + ...overrides + } as Repo +} + +const DESKTOP = { kind: 'desktop' } as const +const RUNTIME = { kind: 'runtime', environmentId: 'env-1' } as const + +describe('automationAuthorityPartitionContext', () => { + it('answers for the desktop from desktop-owned repos only', () => { + const tables = groupReposByAutomationAuthority([ + repo({ id: 'p1' }), + repo({ id: 'p2', connectionId: 'devbox' }), + repo({ id: 'p3', executionHostId: 'runtime:env-1' }) + ]) + const context = automationAuthorityPartitionContext(tables, DESKTOP) + expect(context.repoConnectionId('p1')).toBeNull() + expect(context.repoConnectionId('p2')).toBe('devbox') + expect(context.repoConnectionId('p3')).toBeUndefined() + expect(context.projectsAuthoritative).toBe(true) + }) + + it('answers for a runtime from that runtime’s repos only, and never authoritatively', () => { + const tables = groupReposByAutomationAuthority([ + repo({ id: 'shared', executionHostId: 'runtime:env-1' }), + repo({ id: 'other', executionHostId: 'runtime:env-2' }), + // Same ID under the desktop, hosted over SSH: legal, and not this authority's answer. + repo({ id: 'shared', connectionId: 'devbox' }) + ]) + const context = automationAuthorityPartitionContext(tables, RUNTIME) + expect(context.repoConnectionId('shared')).toBeNull() + expect(context.repoConnectionId('other')).toBeUndefined() + expect(context.projectsAuthoritative).toBe(false) + }) + + it('reports an unmirrored authority as empty rather than borrowing another table', () => { + const tables = groupReposByAutomationAuthority([repo({ id: 'p1' })]) + const context = automationAuthorityPartitionContext(tables, { + kind: 'runtime', + environmentId: 'never-connected' + }) + expect(context.repoConnectionId('p1')).toBeUndefined() + expect(context.projectsAuthoritative).toBe(false) + }) +}) + +describe('automationRuntimePairingRevision', () => { + it('prefers the reported revision and rejects an unknown environment', () => { + const environments = [ + { id: 'env-1', createdAt: 10, pairingRevision: 4 }, + { id: 'env-2', createdAt: 7 } + ] + expect(automationRuntimePairingRevision(environments, 'env-1')).toBe(4) + expect(automationRuntimePairingRevision(environments, 'env-2')).toBe(7) + expect(automationRuntimePairingRevision(environments, 'env-3')).toBe(-1) + }) +}) diff --git a/src/renderer/src/components/automations/automation-authority-identity.ts b/src/renderer/src/components/automations/automation-authority-identity.ts new file mode 100644 index 00000000000..25b22f368d9 --- /dev/null +++ b/src/renderer/src/components/automations/automation-authority-identity.ts @@ -0,0 +1,89 @@ +/** + * The two live values an authority's fence is keyed by, read from mirrored store + * state rather than remembered. + * + * Both answer the same question — which incarnation is this authority right + * now — and both must be read at the moment of use: a cached pairing revision + * survives a re-pair, and a cached repo table survives a repo moving hosts, so + * either one held too long fences a request against an authority that is gone. + */ + +import type { Repo } from '../../../../shared/repo-types' +import type { LegacyAutomationPartitionContext } from '../../../../shared/automation-legacy-list-partition' +import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref' +import { automationAuthorityCatalogKey } from './automation-host-catalog-types' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' + +type RepoConnection = Pick + +export type AutomationAuthorityRepoTables = ReadonlyMap> + +/** Mirrors the authority's own rule: `undefined` = not in this table, `null` = local, string = SSH. */ +export function repoConnectionIdIn( + repoTable: ReadonlyMap +): (repoId: string) => string | null | undefined { + return (repoId) => { + const repo = repoTable.get(repoId) + return repo ? repo.connectionId?.trim() || null : undefined + } +} + +const EMPTY_REPO_TABLE: ReadonlyMap = new Map() + +function repoOwningAuthority(repo: Repo): StableAutomationAuthorityRef { + const host = parseExecutionHostId(getRepoExecutionHostId(repo)) + // A desktop SSH repo is still desktop-stored; only a runtime host owns its own registry. + return host?.kind === 'runtime' + ? { kind: 'runtime', environmentId: host.environmentId } + : { kind: 'desktop' } +} + +/** Splits the flat repo list into one table per owning authority. */ +export function groupReposByAutomationAuthority( + repos: readonly Repo[] +): AutomationAuthorityRepoTables { + const tables = new Map>() + for (const repo of repos) { + const key = automationAuthorityCatalogKey(repoOwningAuthority(repo)) + let table = tables.get(key) + if (!table) { + table = new Map() + tables.set(key, table) + } + table.set(repo.id, repo) + } + return tables +} + +/** + * The legacy partition context for the authority that answered. + * + * Only the desktop table is a verdict. A runtime's repos reach this client as a + * mirror that is populated on connect and can lag or be empty, so a miss there + * leaves the record unverified instead of declaring its project gone — and a + * desktop repo that happens to share an ID never gets to answer for it. + */ +export function automationAuthorityPartitionContext( + tables: AutomationAuthorityRepoTables, + authority: StableAutomationAuthorityRef +): LegacyAutomationPartitionContext { + return { + repoConnectionId: repoConnectionIdIn( + tables.get(automationAuthorityCatalogKey(authority)) ?? EMPTY_REPO_TABLE + ), + projectsAuthoritative: authority.kind === 'desktop' + } +} + +/** + * The revision the runtime transport guard compares, read from the saved + * environment list so a re-pair is observed rather than cached. An unknown + * environment answers -1, which never matches a real revision. + */ +export function automationRuntimePairingRevision( + environments: readonly { id: string; createdAt: number; pairingRevision?: number }[], + environmentId: string +): number { + const environment = environments.find((candidate) => candidate.id === environmentId) + return environment ? (environment.pairingRevision ?? environment.createdAt) : -1 +} diff --git a/src/renderer/src/components/automations/automation-captured-owner.test.ts b/src/renderer/src/components/automations/automation-captured-owner.test.ts new file mode 100644 index 00000000000..ffc998a5b36 --- /dev/null +++ b/src/renderer/src/components/automations/automation-captured-owner.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref' +import type { AutomationHostRow } from './automation-host-cache-types' +import { + automationActionAvailability, + captureAutomationOwners, + capturedAutomationOwner, + isAutomationActionEnabled, + UNCAPTURED_AUTOMATION_OWNER +} from './automation-captured-owner' + +const DESKTOP_SELF: AutomationOwnerRef = { + authority: { kind: 'desktop' }, + selector: { kind: 'self' } +} + +const RUNTIME_SELF: AutomationOwnerRef = { + authority: { kind: 'runtime', environmentId: 'gpu', pairingRevision: 1 }, + selector: { kind: 'self' } +} + +function automation(overrides: Partial = {}): Automation { + return { + id: 'a-1', + name: 'Nightly', + prompt: 'sweep', + precheck: null, + agentId: 'claude', + runContext: null, + projectId: 'repo-1', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'new_per_run', + workspaceId: null, + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: 1, + enabled: true, + nextRunAt: 2, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function row(overrides: Partial): AutomationHostRow { + return { + automation: automation(), + owner: DESKTOP_SELF, + selector: { kind: 'self' }, + usageSummary: null, + usageKnown: false, + ...overrides + } +} + +describe('captured owner availability', () => { + it('lets every action through on an owned row and repeats its precondition', () => { + const availability = automationActionAvailability( + { owner: DESKTOP_SELF, selector: { kind: 'self' } }, + 'run' + ) + expect(availability).toEqual({ + kind: 'owned', + owner: DESKTOP_SELF, + precondition: { selector: { kind: 'self' } } + }) + }) + + it('keeps delete and pause fenceable on an orphan while refusing to run it', () => { + const orphan = { owner: null, selector: { kind: 'orphan' as const, issue: 'gone' } } + expect(automationActionAvailability(orphan, 'delete')).toEqual({ + kind: 'orphan-fenced', + precondition: { selector: { kind: 'orphan' } } + }) + expect(automationActionAvailability(orphan, 'toggle').kind).toBe('orphan-fenced') + expect(automationActionAvailability(orphan, 'run').kind).toBe('blocked') + expect(automationActionAvailability(orphan, 'edit').kind).toBe('blocked') + expect(isAutomationActionEnabled(orphan, 'run')).toBe(false) + expect(isAutomationActionEnabled(orphan, 'delete')).toBe(true) + }) + + it('blocks every action on a legacy row and asks for a server update', () => { + // A qualified selector with no owner means the authority never sent a generation. + const legacy = { owner: null, selector: { kind: 'ssh' as const, targetId: 'box' } } + const availability = automationActionAvailability(legacy, 'delete') + expect(availability.kind).toBe('blocked') + expect(availability.kind === 'blocked' && availability.block.recovery).toBe('update-server') + }) + + it('reports a row with no metadata as uncaptured rather than blocked', () => { + expect(automationActionAvailability(UNCAPTURED_AUTOMATION_OWNER, 'run').kind).toBe('uncaptured') + expect(capturedAutomationOwner(new Map(), 'missing')).toBe(UNCAPTURED_AUTOMATION_OWNER) + expect(capturedAutomationOwner(null, 'missing')).toBe(UNCAPTURED_AUTOMATION_OWNER) + }) + + it('keys captured owners by row key', () => { + const captured = captureAutomationOwners([ + { rowKey: 'row|desktop|a-1', row: row({ automation: automation({ id: 'a-1' }) }) }, + { + rowKey: 'row|desktop|a-2', + row: row({ + automation: automation({ id: 'a-2' }), + owner: null, + selector: { kind: 'orphan', issue: 'gone' } + }) + } + ]) + expect(captured.get('row|desktop|a-1')?.owner).toEqual(DESKTOP_SELF) + expect(captured.get('row|desktop|a-2')?.owner).toBeNull() + }) + + it("keeps both authorities' owners when they hold the same automation ID", () => { + const captured = captureAutomationOwners([ + { rowKey: 'row|desktop|a-1', row: row({ automation: automation({ id: 'a-1' }) }) }, + { + rowKey: 'row|runtime|a-1', + row: row({ automation: automation({ id: 'a-1' }), owner: RUNTIME_SELF }) + } + ]) + expect(captured.get('row|desktop|a-1')?.owner).toEqual(DESKTOP_SELF) + expect(captured.get('row|runtime|a-1')?.owner).toEqual(RUNTIME_SELF) + }) +}) diff --git a/src/renderer/src/components/automations/automation-captured-owner.ts b/src/renderer/src/components/automations/automation-captured-owner.ts new file mode 100644 index 00000000000..761dd787e78 --- /dev/null +++ b/src/renderer/src/components/automations/automation-captured-owner.ts @@ -0,0 +1,156 @@ +/** + * What a row's captured owner permits. + * + * The owner is captured when the row is listed and travels with it; nothing + * here re-derives ownership from the active runtime, the desktop store, or the + * record's `runContext`. That is the whole point: an automation the user is + * looking at may have moved hosts since it was fetched, and the fence only + * works if the request names the incarnation the user actually saw. + * + * Three outcomes, kept distinct because they mean different things to the user: + * an owned row acts normally; an orphan row still deletes and pauses under the + * orphan precondition but can never execute; a legacy row proves nothing and + * stays view-only until its authority advertises fencing. + */ + +import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref' +import { ownerKey } from '../../../../shared/automation-owner-key' +import type { AutomationOwnerPrecondition } from '../../../../shared/automation-owner-precondition' +import { translate } from '@/i18n/i18n' +import type { AutomationHostRow, AutomationHostRowSelector } from './automation-host-cache-types' +import { ORPHAN_OWNER_PRECONDITION, ownerPrecondition } from './automation-scoped-list-client' +import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors' + +/** Every action the page can start against one stored automation. */ +export type AutomationRowAction = 'edit' | 'save' | 'toggle' | 'delete' | 'run' | 'history' + +export type AutomationActionBlockReason = 'orphan' | 'unfenced' + +export type AutomationActionBlock = { + reason: AutomationActionBlockReason + message: string + /** Null when nothing the UI can offer would fix it; the caller then shows no button. */ + recovery: AutomationHostRecoveryAction | null +} + +export type AutomationCapturedOwner = { + owner: AutomationOwnerRef | null + /** How the authority qualified the row; null when no list metadata was captured at all. */ + selector: AutomationHostRowSelector | null +} + +export type AutomationActionAvailability = + | { kind: 'owned'; owner: AutomationOwnerRef; precondition: AutomationOwnerPrecondition } + | { kind: 'orphan-fenced'; precondition: AutomationOwnerPrecondition } + | { kind: 'blocked'; block: AutomationActionBlock } + /** + * No owner metadata exists for this row yet, so the caller keeps its legacy + * unfenced path. Removed with the inference in Step 9, never in the same + * change — a mixed-version client still depends on it. + */ + | { kind: 'uncaptured' } + +export const UNCAPTURED_AUTOMATION_OWNER: AutomationCapturedOwner = { owner: null, selector: null } + +/** Delete and pause survive on an orphan; running it would need a host that no longer exists. */ +const ORPHAN_ACTIONS: ReadonlySet = new Set(['delete', 'toggle', 'history']) + +function orphanBlock(): AutomationActionBlock { + return { + reason: 'orphan', + message: translate( + 'auto.components.automations.capturedOwner.orphan', + 'This automation has no host to run on. Delete it, or re-add the host it belongs to.' + ), + recovery: null + } +} + +function unfencedBlock(): AutomationActionBlock { + return { + reason: 'unfenced', + message: translate( + 'auto.components.automations.capturedOwner.unfenced', + 'This host cannot confirm which automation it is changing, so this automation is read-only here.' + ), + recovery: 'update-server' + } +} + +export function automationActionAvailability( + captured: AutomationCapturedOwner, + action: AutomationRowAction +): AutomationActionAvailability { + if (captured.owner) { + return { + kind: 'owned', + owner: captured.owner, + precondition: ownerPrecondition(captured.owner) + } + } + if (!captured.selector) { + return { kind: 'uncaptured' } + } + if (captured.selector.kind === 'orphan') { + return ORPHAN_ACTIONS.has(action) + ? { kind: 'orphan-fenced', precondition: ORPHAN_OWNER_PRECONDITION } + : { kind: 'blocked', block: orphanBlock() } + } + // A qualified selector with no owner is a legacy row: no generation was ever captured. + return { kind: 'blocked', block: unfencedBlock() } +} + +/** True only for actions the user can start right now; drives the disabled state. */ +export function isAutomationActionEnabled( + captured: AutomationCapturedOwner, + action: AutomationRowAction +): boolean { + return automationActionAvailability(captured, action).kind !== 'blocked' +} + +/** A committed host row plus the list-row key its actions are looked up by. */ +export type AutomationCapturedRow = { + /** `automationListRowKey`: authority-qualified and incarnation-free. */ + rowKey: string + row: AutomationHostRow +} + +/** + * The owner map the page keys its actions by. Built from the rows a host query + * committed, so a row that never loaded simply has no entry and its actions + * fall back rather than guessing an owner. + * + * Keyed by the row key, not the automation ID: an ID is unique only inside one + * authority, so under All hosts a bare-ID map hands every colliding row the + * owner of whichever host was listed last. The key stays incarnation-free + * because it answers "which row is this" — the incarnation lives in the captured + * `owner` it returns, which is what the request fences on. + */ +export function captureAutomationOwners( + rows: Iterable +): Map { + const captured = new Map() + for (const { rowKey, row } of rows) { + captured.set(rowKey, { owner: row.owner, selector: row.selector }) + } + return captured +} + +/** + * Identifies the authority a row was listed under. Distinguishes "listed under + * this owner" from "listed with no owner at all", so a bare automation ID — + * unique only inside one authority — is never what a cached result is matched by. + */ +export function capturedAutomationOwnerKey(captured: AutomationCapturedOwner): string { + if (captured.owner) { + return ownerKey(captured.owner) + } + return captured.selector ? `selector:${captured.selector.kind}` : 'uncaptured' +} + +export function capturedAutomationOwner( + owners: ReadonlyMap | null | undefined, + rowKey: string | null +): AutomationCapturedOwner { + return (rowKey === null ? undefined : owners?.get(rowKey)) ?? UNCAPTURED_AUTOMATION_OWNER +} diff --git a/src/renderer/src/components/automations/automation-create-destination.test.ts b/src/renderer/src/components/automations/automation-create-destination.test.ts new file mode 100644 index 00000000000..bcf20e61c39 --- /dev/null +++ b/src/renderer/src/components/automations/automation-create-destination.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from 'vitest' +import type { + AutomationCatalogHydrationEvidence, + AutomationHostCatalogEntry +} from './automation-host-catalog-types' +import { + automationCreateHostStableKey, + automationCreateProjectMismatch, + preselectAutomationCreateHost, + resolveAutomationCreateDestination, + revalidateAutomationCreateDestination, + soleAutomationCreateHost +} from './automation-create-destination' +import { groupReposByAutomationAuthority } from './automation-authority-identity' +import type { Repo } from '../../../../shared/repo-types' + +function entry(overrides: Partial = {}): AutomationHostCatalogEntry { + return { + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'self' } }, + owner: { authority: { kind: 'desktop' }, selector: { kind: 'self' } }, + stableKey: 'desktop:self', + label: 'This computer', + authorityLabel: 'Desktop', + kind: 'self', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'connected', + querySupport: 'scoped', + ...overrides + } +} + +function sshEntry(targetGeneration: number): AutomationHostCatalogEntry { + return entry({ + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 'box' } }, + owner: { + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId: 'box', targetGeneration } + }, + stableKey: 'desktop:ssh:box', + kind: 'ssh' + }) +} + +describe('create destination resolution', () => { + it('states the destination from the chosen host', () => { + expect(resolveAutomationCreateDestination(entry())).toMatchObject({ + status: 'ready', + authority: { kind: 'desktop' }, + destination: { selector: { kind: 'self' } } + }) + }) + + it('asks for a choice instead of defaulting one', () => { + expect(resolveAutomationCreateDestination(null)).toEqual({ + status: 'choice-required', + reason: 'unselected' + }) + expect(resolveAutomationCreateDestination(entry({ kind: 'orphan' }))).toEqual({ + status: 'choice-required', + reason: 'orphan' + }) + expect(resolveAutomationCreateDestination(entry({ owner: null }))).toEqual({ + status: 'choice-required', + reason: 'unavailable' + }) + }) + + it('refuses a view-only host even when it still carries an owner', () => { + // A degraded query contract means the record could not be listed or edited + // back after landing; the badge and the create gate must agree. + for (const querySupport of ['legacy-unscoped', 'incompatible'] as const) { + expect(resolveAutomationCreateDestination(entry({ querySupport }))).toEqual({ + status: 'choice-required', + reason: 'unavailable' + }) + } + }) + + it('prefers the explicit selection and only then the active workspace', () => { + const entries = [entry(), sshEntry(3)] + expect( + preselectAutomationCreateHost(entries, 'desktop:ssh:box', 'desktop:self')?.stableKey + ).toBe('desktop:ssh:box') + expect(preselectAutomationCreateHost(entries, null, 'desktop:self')?.stableKey).toBe( + 'desktop:self' + ) + // An unresolvable workspace host leaves the choice open rather than picking one. + expect(preselectAutomationCreateHost(entries, null, 'desktop:ssh:gone')).toBeNull() + expect(preselectAutomationCreateHost(entries, null, null)).toBeNull() + }) +}) + +describe('create destination revalidation', () => { + const captured = { + authority: { kind: 'desktop' } as const, + destination: { selector: { kind: 'ssh' as const, targetId: 'box', targetGeneration: 3 } }, + entry: sshEntry(3) + } + + it('accepts a destination whose incarnation is unchanged', () => { + expect(revalidateAutomationCreateDestination(captured, [sshEntry(3)]).status).toBe('ready') + }) + + it('reports a re-registered target as stale rather than following it', () => { + expect(revalidateAutomationCreateDestination(captured, [sshEntry(4)])).toMatchObject({ + status: 'stale' + }) + }) + + it('reports a host that left the catalog as needing a choice', () => { + expect(revalidateAutomationCreateDestination(captured, [])).toEqual({ + status: 'choice-required', + reason: 'unselected' + }) + }) +}) + +describe('sole create host', () => { + const hydrated: AutomationCatalogHydrationEvidence = { + runtimeCatalogSettled: true, + desktopSshHydrated: true, + runtimeSshHydratedByEnvironmentId: new Map(), + savedRuntimeEnvironmentIds: new Set(), + orphanSettledAuthorityKeys: new Set(), + unavailableAuthorityKeys: new Set() + } + + it('states the only eligible host and never one of several', () => { + expect(soleAutomationCreateHost([entry()], hydrated)?.stableKey).toBe('desktop:self') + expect(soleAutomationCreateHost([entry(), sshEntry(3)], hydrated)).toBeNull() + // An orphan bucket, an unowned host, and a view-only host are not candidates at all. + expect( + soleAutomationCreateHost([entry(), entry({ stableKey: 'orphan', kind: 'orphan' })], hydrated) + ?.stableKey + ).toBe('desktop:self') + expect( + soleAutomationCreateHost( + [entry(), entry({ stableKey: 'legacy', querySupport: 'legacy-unscoped' })], + hydrated + )?.stableKey + ).toBe('desktop:self') + }) + + it('states nothing until the catalog has settled', () => { + expect( + soleAutomationCreateHost([entry()], { ...hydrated, runtimeCatalogSettled: false }) + ).toBeNull() + expect( + soleAutomationCreateHost([entry()], { ...hydrated, desktopSshHydrated: false }) + ).toBeNull() + }) +}) + +describe('create host stable key', () => { + it('maps a workspace host to the catalog host that would store its automations', () => { + expect(automationCreateHostStableKey('local')).toBe('host:desktop:self') + // A desktop SSH workspace is still desktop-stored; only the selector differs. + expect(automationCreateHostStableKey('ssh:box')).toBe('host:desktop:ssh:box') + expect(automationCreateHostStableKey('runtime:gpu')).toBe('host:runtime:gpu:self') + expect(automationCreateHostStableKey(null)).toBeNull() + }) +}) + +describe('create project mismatch', () => { + function repo(overrides: Partial): Repo { + return { + id: 'repo-1', + displayName: 'orca', + path: '/repos/orca', + badgeColor: '#000000', + addedAt: 1, + worktreeBaseRef: 'main', + ...overrides + } as Repo + } + const desktopSelf = { + authority: { kind: 'desktop' } as const, + destination: { selector: { kind: 'self' as const } }, + entry: entry() + } + + it('refuses a project the destination authority does not hold as local', () => { + const tables = groupReposByAutomationAuthority([ + repo({ id: 'runtime-repo', executionHostId: 'runtime:gpu' }), + repo({ id: 'ssh-repo', connectionId: 'box' }) + ]) + // No connection ID is not evidence of local: this repo is the runtime's. + expect(automationCreateProjectMismatch(tables, desktopSelf, 'runtime-repo')).toBe(true) + expect(automationCreateProjectMismatch(tables, desktopSelf, 'ssh-repo')).toBe(true) + expect(automationCreateProjectMismatch(tables, desktopSelf, 'repo-1')).toBe(true) + expect( + automationCreateProjectMismatch( + tables, + { + ...desktopSelf, + destination: { selector: { kind: 'ssh', targetId: 'box', targetGeneration: 3 } } + }, + 'ssh-repo' + ) + ).toBe(false) + }) + + it('refuses a repo id another authority owns for a runtime destination', () => { + // The exact repo_not_found bug: a desktop-local repo id sent to a remote + // host can never resolve there, so the miss is a verdict, not lag. + const tables = groupReposByAutomationAuthority([ + repo({ id: 'repo-1' }), + repo({ id: 'runtime-repo', executionHostId: 'runtime:gpu' }) + ]) + const runtimeSelf = { + authority: { kind: 'runtime' as const, environmentId: 'gpu', pairingRevision: 4 }, + destination: { selector: { kind: 'self' as const } }, + entry: entry() + } + expect(automationCreateProjectMismatch(tables, runtimeSelf, 'repo-1')).toBe(true) + expect(automationCreateProjectMismatch(tables, runtimeSelf, 'runtime-repo')).toBe(false) + // An id in no table at all fails closed for every destination. + expect(automationCreateProjectMismatch(tables, runtimeSelf, 'missing')).toBe(true) + expect(automationCreateProjectMismatch(tables, desktopSelf, 'missing')).toBe(true) + }) +}) diff --git a/src/renderer/src/components/automations/automation-create-destination.ts b/src/renderer/src/components/automations/automation-create-destination.ts new file mode 100644 index 00000000000..8eff3749059 --- /dev/null +++ b/src/renderer/src/components/automations/automation-create-destination.ts @@ -0,0 +1,195 @@ +/** + * Where a new automation is created, stated explicitly. + * + * Creation never infers a host. A concrete host filter constrains the + * destination; under All hosts the active workspace's host may pre-fill it, but + * only when that host resolves to a catalog entry with a real executable owner. + * Anything less — an orphan bucket, an unhydrated or ghost host, no selection + * at all — asks the user instead of picking for them, because a silent default + * here creates a scheduled job on a machine nobody chose. + * + * The destination is re-resolved immediately before submit so a host that + * changed incarnation while the form was open fails closed with the form + * intact, rather than landing the record on a re-registered target. + */ + +import type { + AutomationAuthorityRef, + AutomationOwnerRef +} from '../../../../shared/automation-owner-ref' +import type { AutomationDestination } from '../../../../shared/automation-owner-precondition' +import { hostStableKey, isSameAutomationOwner } from '../../../../shared/automation-owner-key' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import type { + AutomationCatalogHydrationEvidence, + AutomationHostCatalogEntry +} from './automation-host-catalog-types' +import { automationAuthorityCatalogKey } from './automation-host-catalog-types' +import { + repoConnectionIdIn, + type AutomationAuthorityRepoTables +} from './automation-authority-identity' + +export type AutomationCreateDestinationChoiceReason = + /** No host is selected and nothing may be assumed. */ + | 'unselected' + /** The selection is the authority's orphan bucket, which cannot own new records. */ + | 'orphan' + /** The host is known but has no executable owner yet: ghost, unhydrated, or legacy. */ + | 'unavailable' + +export type AutomationCreateDestination = { + authority: AutomationAuthorityRef + destination: AutomationDestination + /** The entry the user will see named on the form before submit. */ + entry: AutomationHostCatalogEntry +} + +export type AutomationCreateDestinationResolution = + | ({ status: 'ready' } & AutomationCreateDestination) + | { status: 'choice-required'; reason: AutomationCreateDestinationChoiceReason } + +/** + * The one create-eligibility predicate: an executable owner AND the scoped + * query contract. A view-only entry (degraded `querySupport`) must not accept + * a create it could not list or edit back afterwards, so every surface that + * offers, resolves, or defaults a create destination shares this check. + */ +export function automationCreateHostEligible( + entry: AutomationHostCatalogEntry +): entry is AutomationHostCatalogEntry & { owner: AutomationOwnerRef } { + return entry.kind !== 'orphan' && entry.owner !== null && entry.querySupport === 'scoped' +} + +export function resolveAutomationCreateDestination( + entry: AutomationHostCatalogEntry | null | undefined +): AutomationCreateDestinationResolution { + if (!entry) { + return { status: 'choice-required', reason: 'unselected' } + } + if (entry.kind === 'orphan') { + return { status: 'choice-required', reason: 'orphan' } + } + if (!automationCreateHostEligible(entry)) { + return { status: 'choice-required', reason: 'unavailable' } + } + return { + status: 'ready', + authority: entry.owner.authority, + destination: { selector: entry.owner.selector }, + entry + } +} + +/** + * Under All hosts, a pre-fill is a convenience, never a fallback: an + * unresolvable active workspace leaves the choice to the user. + */ +export function preselectAutomationCreateHost( + entries: readonly AutomationHostCatalogEntry[], + selectedStableKey: string | null, + activeWorkspaceStableKey: string | null +): AutomationHostCatalogEntry | null { + const key = selectedStableKey ?? activeWorkspaceStableKey + if (!key) { + return null + } + return entries.find((entry) => entry.stableKey === key) ?? null +} + +/** + * Re-resolves the captured destination against the live catalog. A changed + * incarnation is reported rather than followed, so the caller can keep the form + * and say which host moved. + */ +export function revalidateAutomationCreateDestination( + captured: AutomationCreateDestination, + entries: readonly AutomationHostCatalogEntry[] +): AutomationCreateDestinationResolution | { status: 'stale'; entry: AutomationHostCatalogEntry } { + const current = entries.find((entry) => entry.stableKey === captured.entry.stableKey) + const resolved = resolveAutomationCreateDestination(current) + if (resolved.status !== 'ready') { + return resolved + } + return isSameAutomationOwner(destinationOwner(resolved), destinationOwner(captured)) + ? resolved + : { status: 'stale', entry: resolved.entry } +} + +/** + * One eligible host is not a guess: with nothing else the user could choose, the + * destination is still stated. Gated on positive hydration evidence, because a + * catalog that has not settled can look single-host while a second one loads. + */ +export function soleAutomationCreateHost( + entries: readonly AutomationHostCatalogEntry[], + hydration: AutomationCatalogHydrationEvidence +): AutomationHostCatalogEntry | null { + if (!hydration.runtimeCatalogSettled || !hydration.desktopSshHydrated) { + return null + } + const eligible = entries.filter(automationCreateHostEligible) + return eligible.length === 1 ? (eligible[0] ?? null) : null +} + +/** The catalog host a workspace's execution host names, for the All-hosts pre-fill. */ +export function automationCreateHostStableKey(hostId: string | null | undefined): string | null { + const host = parseExecutionHostId(hostId) + if (!host) { + return null + } + if (host.kind === 'runtime') { + return hostStableKey({ + authority: { kind: 'runtime', environmentId: host.environmentId }, + selector: { kind: 'self' } + }) + } + // A desktop SSH workspace is still desktop-stored; only the selector differs. + return hostStableKey({ + authority: { kind: 'desktop' }, + selector: host.kind === 'ssh' ? { kind: 'ssh', targetId: host.targetId } : { kind: 'self' } + }) +} + +/** + * Whether the project can live on the destination at all, answered by the + * destination authority's own repo table. + * + * Fail closed on a miss: repo ids are host-local, and every candidate this + * dialog offers is a live row of this client's catalog, so a row the + * destination's table does not hold is provably another authority's — sending + * its id would only defer the refusal to the destination (`repo_not_found`). + * Contrast `automationAuthorityPartitionContext`, which stays fail-open for + * *stored* automation rows whose project may simply not be mirrored yet. + */ +export function automationCreateProjectMismatch( + tables: AutomationAuthorityRepoTables, + destination: AutomationCreateDestination, + projectId: string +): boolean { + const table = tables.get(automationAuthorityCatalogKey(destination.authority)) + const connectionId = table ? repoConnectionIdIn(table)(projectId) : undefined + if (connectionId === undefined) { + return true + } + const selector = destination.destination.selector + return selector.kind === 'ssh' ? connectionId !== selector.targetId : connectionId !== null +} + +/** + * The projects a destination can hold, filtered by the rule submit already + * enforces, so the form cannot offer a pairing its own check will refuse. + */ +export function automationCreateEligibleProjects( + tables: AutomationAuthorityRepoTables, + destination: AutomationCreateDestination, + projects: readonly TProject[] +): TProject[] { + return projects.filter( + (project) => !automationCreateProjectMismatch(tables, destination, project.id) + ) +} + +function destinationOwner(value: AutomationCreateDestination): AutomationOwnerRef { + return { authority: value.authority, selector: value.destination.selector } +} diff --git a/src/renderer/src/components/automations/automation-delete-confirm-preference.ts b/src/renderer/src/components/automations/automation-delete-confirm-preference.ts new file mode 100644 index 00000000000..f1a41d5f63b --- /dev/null +++ b/src/renderer/src/components/automations/automation-delete-confirm-preference.ts @@ -0,0 +1,44 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' + +/** + * Turning off the delete confirmation is a one-click, easily regretted change, + * so the acknowledgement carries the way back to it rather than just reporting + * success. + */ + +export type AutomationDeleteConfirmPreferenceActions = { + updateSettings: (update: { skipDeleteAutomationConfirm: boolean }) => unknown + openSettingsPage: () => void + openSettingsTarget: (target: { pane: 'general'; repoId: null; sectionId: string }) => void +} + +export function persistSkipDeleteAutomationConfirm( + actions: AutomationDeleteConfirmPreferenceActions +): void { + void actions.updateSettings({ skipDeleteAutomationConfirm: true }) + toast.success( + translate( + 'auto.components.automations.AutomationsPage.690b94da54', + "We'll skip this confirmation next time." + ), + { + description: translate( + 'auto.components.automations.AutomationsPage.d2a01b0b6f', + 'You can change this in Settings.' + ), + duration: 8000, + action: { + label: translate('auto.components.automations.AutomationsPage.8a3226f172', 'Open Settings'), + onClick: () => { + actions.openSettingsPage() + actions.openSettingsTarget({ + pane: 'general', + repoId: null, + sectionId: 'general-skip-delete-automation-confirm' + }) + } + } + } + ) +} diff --git a/src/renderer/src/components/automations/automation-detail-selection.test.ts b/src/renderer/src/components/automations/automation-detail-selection.test.ts deleted file mode 100644 index 8dea844ea15..00000000000 --- a/src/renderer/src/components/automations/automation-detail-selection.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { shouldCloseDetailForLostSelection } from './automation-detail-selection' - -const base = { - isDetailOpen: true, - hasPendingNavigation: false, - isSelectedAutomationInNextList: false, - isSelectedExternalInNextList: false -} - -describe('shouldCloseDetailForLostSelection', () => { - it('closes detail when the refreshed list no longer contains the selection', () => { - expect(shouldCloseDetailForLostSelection(base)).toBe(true) - }) - - it('keeps detail open when the selected automation survived the refresh', () => { - expect( - shouldCloseDetailForLostSelection({ ...base, isSelectedAutomationInNextList: true }) - ).toBe(false) - }) - - it('keeps detail open when the selected external automation survived the refresh', () => { - expect(shouldCloseDetailForLostSelection({ ...base, isSelectedExternalInNextList: true })).toBe( - false - ) - }) - - it('keeps detail open when a pending run navigation will re-open it', () => { - expect(shouldCloseDetailForLostSelection({ ...base, hasPendingNavigation: true })).toBe(false) - }) - - it('does nothing when detail is already closed', () => { - expect(shouldCloseDetailForLostSelection({ ...base, isDetailOpen: false })).toBe(false) - }) -}) diff --git a/src/renderer/src/components/automations/automation-detail-selection.ts b/src/renderer/src/components/automations/automation-detail-selection.ts deleted file mode 100644 index ce3487ee058..00000000000 --- a/src/renderer/src/components/automations/automation-detail-selection.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * True when a refresh dropped every selected automation while full-page detail is open — - * without closing, detail silently re-renders whichever automation the list now sorts first. - */ -export function shouldCloseDetailForLostSelection({ - isDetailOpen, - hasPendingNavigation, - isSelectedAutomationInNextList, - isSelectedExternalInNextList -}: { - isDetailOpen: boolean - hasPendingNavigation: boolean - isSelectedAutomationInNextList: boolean - isSelectedExternalInNextList: boolean -}): boolean { - if (!isDetailOpen || hasPendingNavigation) { - return false - } - return !isSelectedAutomationInNextList && !isSelectedExternalInNextList -} diff --git a/src/renderer/src/components/automations/automation-edit-draft.ts b/src/renderer/src/components/automations/automation-edit-draft.ts new file mode 100644 index 00000000000..f4f15bff013 --- /dev/null +++ b/src/renderer/src/components/automations/automation-edit-draft.ts @@ -0,0 +1,77 @@ +/** + * Turns a saved record back into the editor's draft shape. + * + * Both builders are total: a schedule the editor cannot represent produces a + * warning rather than a silently rewritten one, because saving would otherwise + * replace a working cron the user never chose to change. + */ + +import type { Automation, ExternalAutomationJob } from '../../../../shared/automations-types' +import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity' +import { + isValidAutomationCronSchedule, + isValidAutomationSchedule, + tryParseAutomationRrule +} from '../../../../shared/automation-schedules' +import type { AutomationDraft } from './AutomationEditorDialog' +import { AUTOMATION_DEFAULT_TIME, formatTimeInput } from './automation-draft-model' +import { getAutomationSetupDecisionDraftValue } from './automation-setup-decision' + +export function buildAutomationEditDraft(automation: Automation): AutomationDraft { + const schedule = tryParseAutomationRrule(automation.rrule) + const hasCustomSchedule = !schedule && isValidAutomationSchedule(automation.rrule) + return { + name: automation.name, + prompt: automation.prompt, + agentId: automation.agentId, + projectId: getAutomationRunRepoId(automation), + workspaceMode: automation.workspaceMode, + workspaceId: automation.workspaceId ?? '', + baseBranch: automation.baseBranch ?? '', + setupDecision: getAutomationSetupDecisionDraftValue({ + workspaceMode: automation.workspaceMode, + persistedSetupDecision: automation.setupDecision + }), + reuseSession: automation.workspaceMode === 'existing' && automation.reuseSession, + precheckCommand: automation.precheck?.command ?? '', + precheckTimeoutSeconds: String(automation.precheck?.timeoutSeconds ?? 60), + preset: schedule?.preset ?? (hasCustomSchedule ? 'custom' : 'weekdays'), + time: schedule ? formatTimeInput(schedule.hour, schedule.minute) : AUTOMATION_DEFAULT_TIME, + dayOfWeek: String(schedule?.dayOfWeek ?? 1), + customSchedule: hasCustomSchedule ? automation.rrule : '', + missedRunGraceMinutes: String(automation.missedRunGraceMinutes), + scheduleWarning: + schedule || hasCustomSchedule + ? null + : 'This automation has an unsupported saved schedule. Pick a supported schedule before saving changes.' + } +} + +export function buildExternalAutomationEditDraft( + job: ExternalAutomationJob, + placement: { projectId: string; workspaceId: string } +): AutomationDraft { + const rawSchedule = job.rawSchedule?.trim() ?? '' + const hasCustomSchedule = isValidAutomationCronSchedule(rawSchedule) + return { + name: job.name, + prompt: job.prompt ?? job.promptPreview, + agentId: 'hermes', + projectId: placement.projectId, + workspaceMode: 'existing', + workspaceId: placement.workspaceId, + baseBranch: '', + setupDecision: undefined, + reuseSession: false, + precheckCommand: '', + precheckTimeoutSeconds: '60', + preset: hasCustomSchedule ? 'custom' : 'weekdays', + time: AUTOMATION_DEFAULT_TIME, + dayOfWeek: '1', + customSchedule: hasCustomSchedule ? rawSchedule : '', + missedRunGraceMinutes: '720', + scheduleWarning: hasCustomSchedule + ? null + : 'This Hermes automation has an unsupported saved schedule. Pick a supported schedule before saving changes.' + } +} diff --git a/src/renderer/src/components/automations/automation-host-cache-controller.test.ts b/src/renderer/src/components/automations/automation-host-cache-controller.test.ts new file mode 100644 index 00000000000..8fd748f8dc8 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-cache-controller.test.ts @@ -0,0 +1,317 @@ +/** + * The controller is where a rebuilt catalog meets the cache: it must advance + * generations before fetching, forget hosts the catalog dropped, and leave an + * unreachable authority alone instead of dialling it. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import { hostStableKey } from '../../../../shared/automation-owner-key' +import type { + StableAutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import { createAutomationHostCache } from './automation-host-cache' +import { createAutomationHostQueryController } from './automation-host-cache-controller' +import { + getAuthorityCatalogGeneration, + resetAutomationHostCatalogGenerationsForTests +} from './automation-host-catalog-generation' +import type { + AutomationAuthorityHealth, + AutomationHostCatalog, + AutomationHostCatalogEntry, + AutomationHostCatalogState, + AutomationHostQuerySupport +} from './automation-host-catalog-types' +import type { AutomationHostSchedulerTransport } from './automation-host-scheduler' +import type { ScopedAutomationList } from './automation-scoped-list-client' + +const DESKTOP: StableAutomationAuthorityRef = { kind: 'desktop' } +const RUNTIME: StableAutomationAuthorityRef = { kind: 'runtime', environmentId: 'env-1' } +const DESKTOP_SELF: StableAutomationCatalogRef = { authority: DESKTOP, selector: { kind: 'self' } } +const RUNTIME_SELF: StableAutomationCatalogRef = { authority: RUNTIME, selector: { kind: 'self' } } +const RUNTIME_SSH: StableAutomationCatalogRef = { + authority: RUNTIME, + selector: { kind: 'ssh', targetId: 'target-1' } +} +const DESKTOP_SSH: StableAutomationCatalogRef = { + authority: DESKTOP, + selector: { kind: 'ssh', targetId: 'target-1' } +} + +function entry( + ref: StableAutomationCatalogRef, + overrides: { + authorityHealth?: AutomationAuthorityHealth + catalogState?: AutomationHostCatalogState + querySupport?: AutomationHostQuerySupport + targetGeneration?: number + } = {} +): AutomationHostCatalogEntry { + return { + stableRef: ref, + owner: { + authority: + ref.authority.kind === 'desktop' + ? { kind: 'desktop' } + : { kind: 'runtime', environmentId: ref.authority.environmentId, pairingRevision: 2 }, + selector: + ref.selector.kind === 'ssh' + ? { + kind: 'ssh', + targetId: ref.selector.targetId, + targetGeneration: overrides.targetGeneration ?? 5 + } + : { kind: 'self' } + }, + stableKey: hostStableKey(ref), + label: 'Host', + authorityLabel: 'Authority', + kind: ref.selector.kind === 'ssh' ? 'ssh' : 'self', + catalogState: overrides.catalogState ?? 'authoritative', + authorityHealth: overrides.authorityHealth ?? 'fresh', + executionHealth: 'connected', + querySupport: overrides.querySupport ?? 'scoped' + } +} + +function catalogOf(entries: AutomationHostCatalogEntry[]): AutomationHostCatalog { + return { + entries, + byStableKey: new Map(entries.map((item) => [item.stableKey, item])), + hydration: { + runtimeCatalogSettled: true, + desktopSshHydrated: true, + runtimeSshHydratedByEnvironmentId: new Map(), + savedRuntimeEnvironmentIds: new Set(['env-1']), + orphanSettledAuthorityKeys: new Set(), + unavailableAuthorityKeys: new Set() + } + } +} + +function emptyList(): ScopedAutomationList { + return { automations: [], items: [], orphanCount: 0, invalidRows: 0 } +} + +function listOf(ids: readonly string[]): ScopedAutomationList { + return { + automations: ids.map((id) => ({ id, name: id }) as Automation), + items: ids.map((id) => ({ automationId: id, selector: { kind: 'self' as const } })), + orphanCount: 0, + invalidRows: 0 + } +} + +function createController(listScoped: AutomationHostSchedulerTransport['listScoped']) { + const cache = createAutomationHostCache({ + catalogGeneration: () => 0, + connectionGeneration: () => 0 + }) + const controller = createAutomationHostQueryController({ + cache, + legacyPartitionContext: () => ({ repoConnectionId: () => null, projectsAuthoritative: true }), + isVisible: () => true, + schedule: (flush) => flush(), + transport: { listScoped } + }) + return { cache, controller } +} + +/** Reads the module-level generation registry, which is the fence the renderer really runs. */ +function createFencedController(listScoped: AutomationHostSchedulerTransport['listScoped']) { + const cache = createAutomationHostCache({ connectionGeneration: () => 0 }) + const controller = createAutomationHostQueryController({ + cache, + legacyPartitionContext: () => ({ repoConnectionId: () => null, projectsAuthoritative: true }), + isVisible: () => true, + schedule: (flush) => flush(), + transport: { listScoped } + }) + return { cache, controller } +} + +afterEach(() => { + resetAutomationHostCatalogGenerationsForTests() +}) + +describe('automation host query controller', () => { + it('advances catalog generations before fetching anything', async () => { + const listScoped = vi.fn(() => Promise.resolve(emptyList())) + const { controller } = createController(listScoped) + expect(getAuthorityCatalogGeneration(RUNTIME)).toBe(0) + + await controller.applyCatalog(catalogOf([entry(RUNTIME_SELF)])) + + expect(getAuthorityCatalogGeneration(RUNTIME)).toBe(1) + expect(listScoped).toHaveBeenCalledTimes(1) + controller.dispose() + }) + + it('skips removed entries and unreachable authorities but keeps the selected host', async () => { + const listScoped = vi.fn(() => Promise.resolve(emptyList())) + const { controller } = createController(listScoped) + + await controller.applyCatalog( + catalogOf([ + entry(DESKTOP_SELF), + entry(RUNTIME_SELF, { authorityHealth: 'unavailable' }), + entry(RUNTIME_SSH, { catalogState: 'removed' }) + ]) + ) + expect(listScoped).toHaveBeenCalledTimes(1) + + await controller.applyCatalog( + catalogOf([entry(DESKTOP_SELF), entry(RUNTIME_SELF, { authorityHealth: 'unavailable' })]), + { selectedStableKey: hostStableKey(RUNTIME_SELF), force: true } + ) + expect(listScoped).toHaveBeenCalledTimes(3) + controller.dispose() + }) + + // The ordinary startup shape: SSH hydration re-applies the catalog while the + // four-slot pool still holds a queue, and the hosts in that queue are the ones + // that would otherwise sit at `loading` with no request and no Retry. + it('re-fetches hosts whose queued work a second catalog apply cancelled', async () => { + const gates: ((value: ScopedAutomationList) => void)[] = [] + const queried: string[] = [] + let blocking = true + const listScoped = vi.fn((_authority, selector) => { + queried.push(selector.kind === 'ssh' ? selector.targetId : selector.kind) + if (!blocking) { + return Promise.resolve(emptyList()) + } + return new Promise((resolve) => gates.push(resolve)) + }) + const { controller } = createController(listScoped) + const catalog = catalogOf( + Array.from({ length: 10 }, (_, index) => + entry({ authority: RUNTIME, selector: { kind: 'ssh', targetId: `target-${index}` } }) + ) + ) + + const first = controller.applyCatalog(catalog) + await Promise.resolve() + expect(queried).toHaveLength(4) + + const second = controller.applyCatalog(catalog) + blocking = false + for (const resolve of gates) { + resolve(emptyList()) + } + await Promise.all([first, second]) + + expect(new Set(queried).size).toBe(10) + controller.dispose() + }) + + // Design doc: a manual All-host refresh bypasses TTL but leaves hosts already + // known to fail to their own Retry rather than dialling them again. + it('does not re-enqueue a permanently failed host on a manual refresh', async () => { + const listScoped = vi.fn(() => Promise.reject(new Error('permission_denied'))) + const { cache, controller } = createController(listScoped) + const catalog = catalogOf([entry(DESKTOP_SELF)]) + + await controller.applyCatalog(catalog) + expect(cache.get(DESKTOP_SELF)?.error).toMatchObject({ retryable: false }) + + await controller.applyCatalog(catalog, { force: true }) + expect(listScoped).toHaveBeenCalledTimes(1) + controller.dispose() + }) + + // doc:321 — a same-id re-adoption moves the registration generation and nothing + // else, so rows fetched under the old one must not survive the applied catalog. + it('re-fetches a host re-adopted under a new registration inside the TTL', async () => { + // One row per registration, so a cached row names the incarnation it came from. + const listScoped = vi.fn((_authority, selector) => + Promise.resolve( + listOf([ + `gen-${selector.kind === 'ssh' ? selector.expectedTargetGeneration : selector.kind}` + ]) + ) + ) + const { cache, controller } = createFencedController(listScoped) + + await controller.applyCatalog(catalogOf([entry(DESKTOP_SSH, { targetGeneration: 4 })])) + expect(listScoped).toHaveBeenCalledTimes(1) + + await controller.applyCatalog(catalogOf([entry(DESKTOP_SSH, { targetGeneration: 5 })])) + + expect(listScoped).toHaveBeenCalledTimes(2) + expect(listScoped.mock.calls[1]?.[1]).toMatchObject({ expectedTargetGeneration: 5 }) + expect(cache.get(DESKTOP_SSH)?.data.map((row) => row.automation.id)).toEqual(['gen-5']) + controller.dispose() + }) + + it('forgets entries the rebuilt catalog no longer lists', async () => { + const listScoped = vi.fn(() => Promise.resolve(listOf(['a']))) + const { cache, controller } = createController(listScoped) + + await controller.applyCatalog(catalogOf([entry(DESKTOP_SELF), entry(RUNTIME_SELF)])) + expect(cache.keys()).toHaveLength(2) + + await controller.applyCatalog(catalogOf([entry(DESKTOP_SELF)])) + expect(cache.keys()).toEqual([hostStableKey(DESKTOP_SELF)]) + controller.dispose() + }) + + // Why: a positive count is what tells the next catalog to show the authority's orphan entry. + it('reports the latest orphan count an authority returned', async () => { + const listScoped = vi.fn(() => + Promise.resolve({ automations: [], items: [], orphanCount: 3, invalidRows: 0 }) + ) + const { controller } = createController(listScoped) + expect(controller.authorityOrphanCount(RUNTIME)).toBeNull() + + await controller.applyCatalog(catalogOf([entry(RUNTIME_SELF)])) + + expect(controller.authorityOrphanCount(RUNTIME)).toBe(3) + expect(controller.authorityOrphanCount(DESKTOP)).toBeNull() + controller.dispose() + }) + + it('refetches only the entries an event invalidated, past the TTL', async () => { + const listScoped = vi.fn(() => Promise.resolve(listOf(['a']))) + const { controller } = createController(listScoped) + await controller.applyCatalog(catalogOf([entry(DESKTOP_SELF), entry(RUNTIME_SELF)])) + expect(listScoped).toHaveBeenCalledTimes(2) + + controller.handleAuthorityEvent({ authority: RUNTIME, selector: { kind: 'self' } }) + await Promise.resolve() + await Promise.resolve() + + expect(listScoped).toHaveBeenCalledTimes(3) + controller.dispose() + }) + + // Why: an event burst on an old runtime must still cost that host one unscoped call. + it('shares one legacy request across every entry a burst invalidated', async () => { + const cache = createAutomationHostCache({ + catalogGeneration: () => 0, + connectionGeneration: () => 0 + }) + const listLegacy = vi.fn(() => Promise.resolve([])) + const controller = createAutomationHostQueryController({ + cache, + legacyPartitionContext: () => ({ repoConnectionId: () => null, projectsAuthoritative: true }), + isVisible: () => true, + transport: { listLegacy } + }) + const legacy = { querySupport: 'legacy-unscoped' as const } + await controller.applyCatalog( + catalogOf([entry(RUNTIME_SELF, legacy), entry(RUNTIME_SSH, legacy)]) + ) + expect(listLegacy).toHaveBeenCalledTimes(1) + + controller.handleAuthorityEvent({ authority: RUNTIME, selector: { kind: 'self' } }) + controller.handleAuthorityEvent({ authority: RUNTIME, reason: 'definition' }) + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + + expect(listLegacy).toHaveBeenCalledTimes(2) + controller.dispose() + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-cache-controller.ts b/src/renderer/src/components/automations/automation-host-cache-controller.ts new file mode 100644 index 00000000000..5e50c41a317 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-cache-controller.ts @@ -0,0 +1,220 @@ +/** + * Binds a rebuilt host catalog to the cache, the scheduler, and the authority + * event stream. + * + * The catalog is the only thing that decides what may be fetched: applying one + * advances the affected authorities' generations first (so anything in flight + * under the old membership is discarded), evicts entries the catalog no longer + * lists, and only then asks for what is missing or stale. Authorities that are + * merely unreachable are left alone — an all-hosts refresh must never dial a + * connection the user did not ask for. + */ + +import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' +import type { LegacyAutomationPartitionContext } from '../../../../shared/automation-legacy-list-partition' +import type { + AutomationAuthorityRef, + StableAutomationAuthorityRef +} from '../../../../shared/automation-owner-ref' +import { syncAutomationHostCatalogGenerations } from './automation-host-catalog-generation' +import type { + AutomationHostCatalog, + AutomationHostCatalogEntry +} from './automation-host-catalog-types' +import { createAutomationHostCache, type AutomationHostCache } from './automation-host-cache' +import { + createAutomationHostInvalidation, + type AutomationAuthorityChangeEvent, + type AutomationHostInvalidation +} from './automation-host-invalidation' +import { subscribeAutomationHostInvalidation } from './automation-host-invalidation-window-events' +import { + createAutomationHostScheduler, + type AutomationHostFetchTarget, + type AutomationHostScheduler, + type AutomationHostSchedulerOptions +} from './automation-host-scheduler' + +export type AutomationHostQueryControllerOptions = { + legacyPartitionContext: ( + authority: StableAutomationAuthorityRef + ) => LegacyAutomationPartitionContext + cache?: AutomationHostCache + scheduler?: AutomationHostScheduler + transport?: AutomationHostSchedulerOptions['transport'] + now?: () => number + random?: () => number + isVisible?: () => boolean + schedule?: (flush: () => void) => void + scheduleRetry?: AutomationHostSchedulerOptions['scheduleRetry'] + /** Where `automationsChanged` arrives; `null` opts out for a caller that feeds events itself. */ + eventTarget?: EventTarget | null + /** Notified after each committed refresh cycle so a view can re-render. */ + onSettled?: () => void +} + +export type AutomationHostApplyCatalogOptions = { + /** The host the user is looking at; it and Desktop + Self jump the queue. */ + selectedStableKey?: string | null + /** Manual refresh: bypasses TTL for the authorities that are reachable. */ + force?: boolean +} + +export type AutomationHostQueryController = { + cache: AutomationHostCache + scheduler: AutomationHostScheduler + applyCatalog: ( + catalog: AutomationHostCatalog, + options?: AutomationHostApplyCatalogOptions + ) => Promise + /** Feeds one `automationsChanged` event in; bursts coalesce into one refresh. */ + handleAuthorityEvent: (event: AutomationAuthorityChangeEvent) => void + /** Latest orphan count the authority reported, so the next catalog can show its orphan entry. */ + authorityOrphanCount: (authority: StableAutomationAuthorityRef) => number | null + dispose: () => void +} + +function authorityRefFor(entry: AutomationHostCatalogEntry): AutomationAuthorityRef { + if (entry.owner) { + return entry.owner.authority + } + const authority = entry.stableRef.authority + return authority.kind === 'desktop' + ? { kind: 'desktop' } + : { + kind: 'runtime', + environmentId: authority.environmentId, + // Why: an unmirrored environment must fail the fence rather than borrow revision 0. + pairingRevision: getRuntimeEnvironmentRevision(authority.environmentId) ?? -1 + } +} + +function isFetchable(entry: AutomationHostCatalogEntry, selectedStableKey: string | null): boolean { + if (entry.catalogState === 'removed') { + return false + } + // A disconnected authority proves nothing about its rows, so it keeps the stale ones + // and a Reconnect action instead of being dialled by a background refresh. + return entry.authorityHealth !== 'unavailable' || entry.stableKey === selectedStableKey +} + +function toFetchTarget( + entry: AutomationHostCatalogEntry, + selectedStableKey: string | null +): AutomationHostFetchTarget { + return { + ref: entry.stableRef, + authority: authorityRefFor(entry), + owner: entry.owner, + querySupport: entry.querySupport, + priority: + entry.stableKey === selectedStableKey || + (entry.stableRef.authority.kind === 'desktop' && entry.stableRef.selector.kind === 'self') + } +} + +export function createAutomationHostQueryController( + options: AutomationHostQueryControllerOptions +): AutomationHostQueryController { + const cache = options.cache ?? createAutomationHostCache({ now: options.now }) + const scheduler = + options.scheduler ?? + createAutomationHostScheduler({ + cache, + legacyPartitionContext: options.legacyPartitionContext, + transport: options.transport, + now: options.now, + random: options.random, + isVisible: options.isVisible, + scheduleRetry: options.scheduleRetry + }) + let latest: AutomationHostCatalog | null = null + let selected: string | null = null + + const targetsFor = ( + catalog: AutomationHostCatalog, + include?: (entry: AutomationHostCatalogEntry) => boolean + ): AutomationHostFetchTarget[] => + catalog.entries + .filter((entry) => isFetchable(entry, selected) && (include?.(entry) ?? true)) + .map((entry) => toFetchTarget(entry, selected)) + + const refreshKeys = async (stableKeys: readonly string[]): Promise => { + const catalog = latest + if (!catalog) { + return + } + const wanted = new Set(stableKeys) + const targets = targetsFor(catalog, (entry) => wanted.has(entry.stableKey)) + if (targets.length > 0) { + // Forced: the event already proved these entries stale, so TTL must not swallow them. + await scheduler.refresh(targets, { force: true }) + options.onSettled?.() + } + } + + const invalidation: AutomationHostInvalidation = createAutomationHostInvalidation({ + cache, + schedule: options.schedule, + onInvalidated: (stableKeys) => { + void refreshKeys(stableKeys) + } + }) + + // Subscribed here rather than by the page: an unsubscribed controller silently + // stops refreshing on writes, which looks like stale data, not a missing wire. + const eventTarget = + options.eventTarget === undefined ? (globalThis.window ?? null) : options.eventTarget + const unsubscribe = eventTarget + ? subscribeAutomationHostInvalidation(invalidation.handle, eventTarget) + : null + + return { + cache, + scheduler, + applyCatalog: async (catalog, applyOptions = {}) => { + latest = catalog + selected = applyOptions.selectedStableKey ?? null + // Membership first: a request captured under the old membership must not commit. + const generations = syncAutomationHostCatalogGenerations(catalog) + // A re-adopted host keeps its stable key, so eviction below never sees it: + // only this drops rows the host's previous incarnation answered with. + for (const stableKey of generations.reincarnatedStableKeys) { + cache.discardIncarnation(stableKey) + } + for (const key of cache.keys()) { + if (!catalog.byStableKey.has(key)) { + cache.evict(key) + } + } + scheduler.cancelQueued() + const manual = applyOptions.force === true + // A manual refresh bypasses TTL, but a host already known to fail stays failed + // with its own Retry rather than being dialled again by the all-hosts button. + await scheduler.refresh(targetsFor(catalog), { + force: manual, + skipKnownFailures: manual + }) + options.onSettled?.() + }, + handleAuthorityEvent: invalidation.handle, + authorityOrphanCount: (authority) => { + let newest: { fetchedAt: number; orphanCount: number } | null = null + for (const key of cache.keysForAuthority(authority)) { + const entry = cache.getByKey(key) + if (!entry || entry.orphanCount === null || entry.fetchedAt === null) { + continue + } + if (!newest || entry.fetchedAt >= newest.fetchedAt) { + newest = { fetchedAt: entry.fetchedAt, orphanCount: entry.orphanCount } + } + } + return newest?.orphanCount ?? null + }, + dispose: () => { + unsubscribe?.() + invalidation.dispose() + scheduler.dispose() + } + } +} diff --git a/src/renderer/src/components/automations/automation-host-cache-error.ts b/src/renderer/src/components/automations/automation-host-cache-error.ts new file mode 100644 index 00000000000..41e7323e414 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-cache-error.ts @@ -0,0 +1,114 @@ +/** + * Classifies a failed host query by code rather than by copy, and decides + * whether it may retry. + * + * The distinction that matters: a host that *proved* it cannot serve the + * contract (old server, malformed payload, changed owner) must not burn the + * retry budget, while an unreachable or slow host must. + */ + +import { hasRuntimeRpcErrorCode } from '@/runtime/runtime-rpc-client' +import { + AUTOMATION_OWNER_CONFLICT_CODES, + stripAutomationOwnerConflictCode +} from '../../../../shared/automation-owner-conflict' +import { + AutomationHostScopeUnsupportedError, + AutomationListResponseError, + matchAutomationOwnerConflict +} from './automation-scoped-list-client' +import type { + AutomationHostQueryError, + AutomationHostQueryErrorCode +} from './automation-host-cache-types' + +export const AUTOMATION_HOST_RETRY_BASE_MS = 1_000 +export const AUTOMATION_HOST_RETRY_CAP_MS = 30_000 +export const AUTOMATION_HOST_MAX_ATTEMPTS = 3 + +const UNAVAILABLE_RPC_CODES = [ + 'runtime_unavailable', + 'remote_runtime_unavailable', + 'relay_quota_exceeded' +] +const TIMEOUT_RPC_CODES = ['timeout', 'runtime_timeout'] +const PERMISSION_RPC_CODES = ['permission_denied', 'unauthorized', 'forbidden'] +const INCOMPATIBLE_RPC_CODES = ['capability_unsupported', 'legacy_read_only'] +const INVALID_RPC_CODES = ['invalid_runtime_response', 'invalid_argument'] + +const RETRYABLE_CODES: ReadonlySet = new Set([ + 'authority_unavailable', + 'timeout', + 'unknown' +]) + +const CONFLICT_QUERY_CODES: Record = { + [AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged]: 'owner_changed', + [AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved]: 'target_removed', + [AUTOMATION_OWNER_CONFLICT_CODES.fencingRequired]: 'incompatible', + [AUTOMATION_OWNER_CONFLICT_CODES.invalidDestination]: 'invalid_response' +} + +function messageOf(error: unknown): string { + const raw = error instanceof Error ? error.message : String(error) + return stripAutomationOwnerConflictCode(raw) +} + +function codeOf(error: unknown): AutomationHostQueryErrorCode { + if (error instanceof AutomationHostScopeUnsupportedError) { + return 'incompatible' + } + if (error instanceof AutomationListResponseError) { + return 'invalid_response' + } + const conflict = matchAutomationOwnerConflict(error) + if (conflict) { + return CONFLICT_QUERY_CODES[conflict] ?? 'unknown' + } + const matches = (codes: readonly string[]): boolean => + codes.some((code) => hasRuntimeRpcErrorCode(error, code)) + if (matches(UNAVAILABLE_RPC_CODES)) { + return 'authority_unavailable' + } + if (matches(TIMEOUT_RPC_CODES)) { + return 'timeout' + } + if (matches(PERMISSION_RPC_CODES)) { + return 'permission_denied' + } + if (matches(INCOMPATIBLE_RPC_CODES)) { + return 'incompatible' + } + if (matches(INVALID_RPC_CODES)) { + return 'invalid_response' + } + return 'unknown' +} + +export function classifyAutomationHostQueryError( + error: unknown, + options: { attempt: number; now: number; random?: () => number } +): AutomationHostQueryError { + const code = codeOf(error) + const retryable = RETRYABLE_CODES.has(code) && options.attempt < AUTOMATION_HOST_MAX_ATTEMPTS + return { + code, + message: messageOf(error), + retryable, + retryAt: retryable + ? options.now + automationHostRetryDelayMs(options.attempt, options.random) + : null + } +} + +/** Full jitter: an authority that dropped every client must not get them back in lockstep. */ +export function automationHostRetryDelayMs( + attempt: number, + random: () => number = Math.random +): number { + const ceiling = Math.min( + AUTOMATION_HOST_RETRY_CAP_MS, + AUTOMATION_HOST_RETRY_BASE_MS * 2 ** Math.max(0, attempt - 1) + ) + return Math.round(random() * ceiling) +} diff --git a/src/renderer/src/components/automations/automation-host-cache-health.test.ts b/src/renderer/src/components/automations/automation-host-cache-health.test.ts new file mode 100644 index 00000000000..a30bba193fa --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-cache-health.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import type { + AutomationHostCacheEntry, + AutomationHostQueryErrorCode +} from './automation-host-cache-types' +import type { + AutomationHostCatalog, + AutomationHostCatalogEntry +} from './automation-host-catalog-types' +import { + automationHostEntryHealth, + automationHostLoadCounts, + withAutomationHostCacheHealth +} from './automation-host-cache-health' + +function entry( + overrides: Partial & Pick +): AutomationHostCatalogEntry { + return { + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'self' } }, + owner: null, + label: 'This computer', + authorityLabel: 'This computer', + kind: 'self', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'connected', + querySupport: 'scoped', + ...overrides + } +} + +function cached(overrides: Partial): AutomationHostCacheEntry { + return { + data: [], + fetchedAt: null, + attempt: 0, + requestGeneration: 0, + catalogGeneration: 0, + request: null, + error: null, + orphanCount: null, + ...overrides + } +} + +function failure(code: AutomationHostQueryErrorCode): Pick { + return { error: { code, message: 'nope', retryable: true, retryAt: null } } +} + +const SELF = entry({ stableKey: 'host:desktop:self' }) + +function catalogOf(entries: AutomationHostCatalogEntry[]): AutomationHostCatalog { + return { + entries, + byStableKey: new Map(entries.map((candidate) => [candidate.stableKey, candidate])), + hydration: { + runtimeCatalogSettled: true, + desktopSshHydrated: true, + runtimeSshHydratedByEnvironmentId: new Map(), + savedRuntimeEnvironmentIds: new Set(), + orphanSettledAuthorityKeys: new Set(), + unavailableAuthorityKeys: new Set() + } + } +} + +describe('automation host cache health', () => { + it('maps a transport failure to unreachable and an old server to incompatible', () => { + expect( + automationHostEntryHealth(SELF, { + entry: () => cached(failure('authority_unavailable')) + }) + ).toBe('unavailable') + expect(automationHostEntryHealth(SELF, { entry: () => cached(failure('incompatible')) })).toBe( + 'incompatible' + ) + expect(automationHostEntryHealth(SELF, { entry: () => cached(failure('timeout')) })).toBe( + 'stale-error' + ) + }) + + it('distinguishes a first load from a refresh over rows already on screen', () => { + expect( + automationHostEntryHealth(SELF, { + entry: () => cached({ request: Promise.resolve() }) + }) + ).toBe('loading') + expect( + automationHostEntryHealth(SELF, { + entry: () => cached({ request: Promise.resolve(), fetchedAt: 5 }) + }) + ).toBe('refreshing') + }) + + it('marks a host whose unscoped list failed, so it never reads as empty', () => { + expect( + automationHostEntryHealth(SELF, { + entry: () => null, + failedAuthorityKeys: new Set(['authority:desktop']) + }) + ).toBe('stale-error') + }) + + it('lets a successful host-scoped read outrank the unscoped failure', () => { + // The per-host read is better evidence about this host than the ambient one. + expect( + automationHostEntryHealth(SELF, { + entry: () => cached({ fetchedAt: 5 }), + failedAuthorityKeys: new Set(['authority:desktop']) + }) + ).toBe('fresh') + }) + + it('returns the same catalog when nothing moved', () => { + const catalog = catalogOf([SELF]) + expect(withAutomationHostCacheHealth(catalog, { entry: () => null })).toBe(catalog) + }) + + it('counts failed hosts and excludes hosts that were never expected to load', () => { + const catalog = catalogOf([ + SELF, + entry({ stableKey: 'a', authorityHealth: 'unavailable' }), + entry({ stableKey: 'b', authorityHealth: 'stale-error' }), + entry({ stableKey: 'gone', catalogState: 'removed', authorityHealth: 'unavailable' }) + ]) + + expect(automationHostLoadCounts(catalog)).toEqual({ failedHostCount: 2, totalHostCount: 3 }) + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-cache-health.ts b/src/renderer/src/components/automations/automation-host-cache-health.ts new file mode 100644 index 00000000000..b84454bb4db --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-cache-health.ts @@ -0,0 +1,132 @@ +/** + * Folds query results back onto the catalog for display. + * + * Kept separate from the catalog the controller is given: this one changes on + * every commit, and re-applying a catalog advances generations and cancels + * in-flight requests. The picker, notice, and empty state read this projection; + * the scheduler reads the store-derived one. + */ + +import type { AutomationHostCache } from './automation-host-cache' +import type { + AutomationHostCacheEntry, + AutomationHostQueryErrorCode +} from './automation-host-cache-types' +import type { + AutomationAuthorityHealth, + AutomationHostCatalog, + AutomationHostCatalogEntry +} from './automation-host-catalog-types' +import { automationAuthorityCatalogKey } from './automation-host-catalog-types' + +export type AutomationHostLoadCounts = { + failedHostCount: number + totalHostCount: number +} + +export type AutomationHostHealthInput = { + entry: (stableKey: string) => AutomationHostCacheEntry | null + /** + * Authorities whose most recent list attempt failed outside the per-host + * cache — today the page's own unscoped refresh. Without this a failed load + * would render as an empty host, which claims we looked and found nothing. + */ + failedAuthorityKeys?: ReadonlySet +} + +const ERROR_HEALTH: Partial> = { + authority_unavailable: 'unavailable', + incompatible: 'incompatible' +} + +function healthFromCache( + cached: AutomationHostCacheEntry | null +): AutomationAuthorityHealth | null { + if (!cached) { + return null + } + if (cached.error) { + return ERROR_HEALTH[cached.error.code] ?? 'stale-error' + } + if (cached.request) { + return cached.fetchedAt === null ? 'loading' : 'refreshing' + } + return cached.fetchedAt === null ? null : 'fresh' +} + +export function automationHostEntryHealth( + entry: AutomationHostCatalogEntry, + input: AutomationHostHealthInput +): AutomationAuthorityHealth { + // An offline authority already outranks anything its cache entry could say. + if (entry.authorityHealth === 'unavailable' || entry.authorityHealth === 'incompatible') { + return entry.authorityHealth + } + const fromCache = healthFromCache(input.entry(entry.stableKey)) + if (fromCache) { + return fromCache + } + const authorityKey = automationAuthorityCatalogKey(entry.stableRef.authority) + if (input.failedAuthorityKeys?.has(authorityKey)) { + return 'stale-error' + } + return entry.authorityHealth +} + +/** Returns the same catalog object when no entry's health moved, so memos hold. */ +export function withAutomationHostCacheHealth( + catalog: AutomationHostCatalog, + input: AutomationHostHealthInput +): AutomationHostCatalog { + let changed = false + const entries = catalog.entries.map((entry) => { + const authorityHealth = automationHostEntryHealth(entry, input) + if (authorityHealth === entry.authorityHealth) { + return entry + } + changed = true + return { ...entry, authorityHealth } + }) + if (!changed) { + return catalog + } + return { + entries, + byStableKey: new Map(entries.map((entry) => [entry.stableKey, entry])), + hydration: { + ...catalog.hydration, + unavailableAuthorityKeys: new Set([ + ...catalog.hydration.unavailableAuthorityKeys, + ...entries + .filter((entry) => entry.authorityHealth === 'unavailable') + .map((entry) => automationAuthorityCatalogKey(entry.stableRef.authority)) + ]) + } + } +} + +const FAILED_HEALTH: ReadonlySet = new Set([ + 'unavailable', + 'stale-error', + 'incompatible' +]) + +/** + * Counts hosts, not authorities: the summary tells the user how much of the + * list is missing, and one unreachable server can account for several hosts. + * Removed entries are excluded — a ghost was never expected to load. + */ +export function automationHostLoadCounts(catalog: AutomationHostCatalog): AutomationHostLoadCounts { + const counted = catalog.entries.filter((entry) => entry.catalogState !== 'removed') + return { + failedHostCount: counted.filter((entry) => FAILED_HEALTH.has(entry.authorityHealth)).length, + totalHostCount: counted.length + } +} + +/** Adapter so callers can pass a live cache where a plain reader is expected. */ +export function automationHostCacheReader( + cache: AutomationHostCache +): (stableKey: string) => AutomationHostCacheEntry | null { + return (stableKey) => cache.getByKey(stableKey) +} diff --git a/src/renderer/src/components/automations/automation-host-cache-rows.ts b/src/renderer/src/components/automations/automation-host-cache-rows.ts new file mode 100644 index 00000000000..9d8f2888efe --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-cache-rows.ts @@ -0,0 +1,111 @@ +/** + * Turns an authority's answer into cache rows. + * + * Scoped rows keep the owner they were fetched under so every later action can + * fence on it. Legacy rows keep no owner and no usage: the old response proves + * neither, and the design forbids compensating with run-history downloads. + */ + +import type { Automation } from '../../../../shared/automations-types' +import type { AutomationListItem } from '../../../../shared/automation-list-scope' +import { + partitionLegacyAutomationList, + type LegacyAutomationPartitionContext, + type LegacyAutomationSelector +} from '../../../../shared/automation-legacy-list-partition' +import type { + AutomationAuthorityRef, + AutomationOwnerRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import type { ScopedAutomationList } from './automation-scoped-list-client' +import type { AutomationHostRow } from './automation-host-cache-types' + +function ownerForItem( + authority: AutomationAuthorityRef, + item: AutomationListItem +): AutomationOwnerRef | null { + if (item.selector.kind === 'orphan') { + return null + } + return item.selector.kind === 'ssh' + ? { + authority, + selector: { + kind: 'ssh', + targetId: item.selector.targetId, + targetGeneration: item.selector.targetGeneration + } + } + : { authority, selector: { kind: 'self' } } +} + +export function toScopedAutomationHostRows( + authority: AutomationAuthorityRef, + result: ScopedAutomationList +): AutomationHostRow[] { + const itemsById = new Map(result.items.map((item) => [item.automationId, item])) + const rows: AutomationHostRow[] = [] + for (const automation of result.automations) { + const item = itemsById.get(automation.id) + if (!item) { + continue + } + const usageSummary = item.usageSummary ?? null + rows.push({ + automation, + owner: ownerForItem(authority, item), + selector: item.selector, + usageSummary, + // An absent or unreadable projection reads as unavailable rather than as zero usage. + usageKnown: usageSummary !== null + }) + } + return rows +} + +export type LegacyAutomationHostPartition = { + rowsByStableKey: Map + orphanCount: number +} + +function legacySelectorMatches( + selector: LegacyAutomationSelector, + ref: StableAutomationCatalogRef +): boolean { + if (selector.kind !== ref.selector.kind) { + return false + } + return selector.kind === 'ssh' && ref.selector.kind === 'ssh' + ? selector.targetId === ref.selector.targetId + : true +} + +/** + * Fans one unscoped response out to every entry that asked for it, so an old + * runtime is queried once per cycle rather than once per selector. + */ +export function partitionLegacyAutomationHostRows( + automations: readonly Automation[], + refs: readonly StableAutomationCatalogRef[], + context: LegacyAutomationPartitionContext, + keyOf: (ref: StableAutomationCatalogRef) => string +): LegacyAutomationHostPartition { + const partition = partitionLegacyAutomationList(automations, context) + const rowsByStableKey = new Map() + for (const ref of refs) { + rowsByStableKey.set( + keyOf(ref), + partition.rows + .filter((row) => legacySelectorMatches(row.selector, ref)) + .map((row) => ({ + automation: row.automation, + owner: null, + selector: row.selector, + usageSummary: null, + usageKnown: false + })) + ) + } + return { rowsByStableKey, orphanCount: partition.orphanCount } +} diff --git a/src/renderer/src/components/automations/automation-host-cache-types.ts b/src/renderer/src/components/automations/automation-host-cache-types.ts new file mode 100644 index 00000000000..29f0eab65f3 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-cache-types.ts @@ -0,0 +1,76 @@ +/** + * Shapes the per-host automation cache stores and fences. + * + * A cached row is only ever as trustworthy as the incarnation it was fetched + * under, so every row keeps the owner it came from (null when the authority + * could not qualify it) and every request keeps the three generations its + * commit must still match. + */ + +import type { Automation } from '../../../../shared/automations-types' +import type { AutomationListItemSelector } from '../../../../shared/automation-list-scope' +import type { LegacyAutomationSelector } from '../../../../shared/automation-legacy-list-partition' +import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref' +import type { AutomationUsageSummary } from '../../../../shared/automation-usage-summary' + +/** A legacy SSH row carries no registration generation, which is why it can never be fenced. */ +export type AutomationHostRowSelector = AutomationListItemSelector | LegacyAutomationSelector + +export type AutomationHostRow = { + automation: Automation + /** Null when nothing may be mutated through it: legacy unscoped rows and orphans. */ + owner: AutomationOwnerRef | null + selector: AutomationHostRowSelector + usageSummary: AutomationUsageSummary | null + /** False on the legacy path, where the authority sends no projection and none may be fetched. */ + usageKnown: boolean +} + +export type AutomationHostQueryErrorCode = + | 'authority_unavailable' + | 'timeout' + | 'permission_denied' + | 'incompatible' + | 'invalid_response' + | 'owner_changed' + | 'target_removed' + | 'unknown' + +export type AutomationHostQueryError = { + code: AutomationHostQueryErrorCode + message: string + retryable: boolean + /** Wall-clock time the next automatic attempt may start; null when nothing is scheduled. */ + retryAt: number | null +} + +export type AutomationHostCacheEntry = { + data: readonly AutomationHostRow[] + fetchedAt: number | null + attempt: number + requestGeneration: number + catalogGeneration: number + request: Promise | null + error: AutomationHostQueryError | null + /** Authoritative orphan count from the last scoped response; null when never reported. */ + orphanCount: number | null +} + +/** + * Everything a response must still match to commit. `catalogGeneration` covers + * removal tombstones too: a target leaving the catalog advances it. + */ +export type AutomationHostRequestFence = { + stableKey: string + authorityKey: string + requestGeneration: number + catalogGeneration: number + /** The authority's `pairingRevision`; a fixed constant for desktop. */ + connectionGeneration: number +} + +/** Desktop storage is local, so it has no pairing to revise. */ +export const DESKTOP_AUTHORITY_CONNECTION_GENERATION = 0 + +export const AUTOMATION_HOST_CACHE_TTL_MS = 30_000 +export const AUTOMATION_HOST_RETIRED_CACHE_LIMIT = 256 diff --git a/src/renderer/src/components/automations/automation-host-cache.test.ts b/src/renderer/src/components/automations/automation-host-cache.test.ts new file mode 100644 index 00000000000..b68f3144607 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-cache.test.ts @@ -0,0 +1,254 @@ +/** + * The commit fence is the whole point of this cache: an answer that was correct + * when it was asked for may be wrong by the time it lands, and rendering it + * would attribute one host's automations to another. + */ + +import { describe, expect, it, vi } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import { hostStableKey } from '../../../../shared/automation-owner-key' +import type { + StableAutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import { createAutomationHostCache } from './automation-host-cache' +import type { AutomationHostRow } from './automation-host-cache-types' +import { + createAutomationCatalogGenerationRegistry, + type AutomationCatalogGenerationRegistry +} from './automation-host-catalog-generation' +import type { + AutomationHostCatalog, + AutomationHostCatalogEntry, + AutomationHostQuerySupport +} from './automation-host-catalog-types' + +const DESKTOP: StableAutomationAuthorityRef = { kind: 'desktop' } +const RUNTIME: StableAutomationAuthorityRef = { kind: 'runtime', environmentId: 'env-1' } +const DESKTOP_SELF: StableAutomationCatalogRef = { authority: DESKTOP, selector: { kind: 'self' } } +const RUNTIME_SELF: StableAutomationCatalogRef = { authority: RUNTIME, selector: { kind: 'self' } } +const RUNTIME_SSH: StableAutomationCatalogRef = { + authority: RUNTIME, + selector: { kind: 'ssh', targetId: 'target-1' } +} + +function row(id: string): AutomationHostRow { + return { + automation: { id, name: id } as Automation, + owner: null, + selector: { kind: 'self' }, + usageSummary: null, + usageKnown: false + } +} + +function createCache(overrides: Parameters[0] = {}) { + return createAutomationHostCache({ + catalogGeneration: () => 0, + connectionGeneration: () => 0, + ...overrides + }) +} + +describe('automation host cache commit fence', () => { + it('commits a response whose generations all still match', () => { + const cache = createCache() + const fence = cache.beginRequest(DESKTOP_SELF) + expect(cache.commit(fence, { rows: [row('a')] })).toBe(true) + expect(cache.get(DESKTOP_SELF)?.data.map((entry) => entry.automation.id)).toEqual(['a']) + }) + + it('discards a response whose entry was invalidated while it was in flight', () => { + const cache = createCache() + const fence = cache.beginRequest(DESKTOP_SELF) + cache.invalidate(DESKTOP_SELF) + expect(cache.commit(fence, { rows: [row('a')] })).toBe(false) + expect(cache.get(DESKTOP_SELF)?.data).toEqual([]) + }) + + it('discards a response whose authority catalog generation advanced', () => { + let generation = 7 + const cache = createCache({ catalogGeneration: () => generation }) + const fence = cache.beginRequest(RUNTIME_SSH) + generation = 8 + expect(cache.commit(fence, { rows: [row('a')] })).toBe(false) + }) + + it('discards a response from a runtime that re-paired mid-flight', () => { + let revision = 4 + const cache = createCache({ connectionGeneration: () => revision }) + const fence = cache.beginRequest(RUNTIME_SELF) + revision = 5 + expect(cache.commit(fence, { rows: [row('a')] })).toBe(false) + expect( + cache.fail(fence, { code: 'timeout', message: 'x', retryable: true, retryAt: null }) + ).toBe(false) + }) + + // doc:321 — the stable key survives a re-adoption, so the rows have to go. + it('drops the rows and the in-flight marker of a superseded incarnation', () => { + const cache = createCache() + cache.commit(cache.beginRequest(RUNTIME_SSH), { rows: [row('a')], orphanCount: 2 }) + const stale = cache.beginRequest(RUNTIME_SSH) + cache.trackRequest(stale, Promise.resolve()) + + cache.discardIncarnation(hostStableKey(RUNTIME_SSH)) + + expect(cache.get(RUNTIME_SSH)?.data).toEqual([]) + expect(cache.freshness(RUNTIME_SSH)).toBe('missing') + expect(cache.get(RUNTIME_SSH)?.orphanCount).toBeNull() + expect(cache.pendingRequest(RUNTIME_SSH)).toBeNull() + expect(cache.commit(stale, { rows: [row('b')] })).toBe(false) + expect(cache.commit(cache.beginRequest(RUNTIME_SSH), { rows: [row('c')] })).toBe(true) + }) + + it('keeps the last good rows when a refresh fails and clears them on success', () => { + const cache = createCache() + cache.commit(cache.beginRequest(DESKTOP_SELF), { rows: [row('a')] }) + const failing = cache.beginRequest(DESKTOP_SELF) + cache.fail(failing, { code: 'timeout', message: 'slow', retryable: true, retryAt: 100 }) + expect(cache.get(DESKTOP_SELF)?.data).toHaveLength(1) + expect(cache.get(DESKTOP_SELF)?.attempt).toBe(1) + cache.commit(cache.beginRequest(DESKTOP_SELF), { rows: [row('a'), row('b')] }) + expect(cache.get(DESKTOP_SELF)?.error).toBeNull() + expect(cache.get(DESKTOP_SELF)?.attempt).toBe(0) + }) +}) + +describe('automation host cache freshness and sharing', () => { + it('reports fresh inside the TTL and stale past it', () => { + let clock = 1_000 + const cache = createCache({ now: () => clock, ttlMs: 30_000 }) + expect(cache.freshness(DESKTOP_SELF)).toBe('missing') + cache.commit(cache.beginRequest(DESKTOP_SELF), { rows: [] }) + expect(cache.freshness(DESKTOP_SELF)).toBe('fresh') + clock += 30_001 + expect(cache.freshness(DESKTOP_SELF)).toBe('stale') + }) + + it('publishes one in-flight request for concurrent callers of the same owner', async () => { + const cache = createCache() + const fence = cache.beginRequest(DESKTOP_SELF) + const request = Promise.resolve() + cache.trackRequest(fence, request) + expect(cache.pendingRequest(DESKTOP_SELF)).toBe(request) + cache.commit(fence, { rows: [] }) + expect(cache.pendingRequest(DESKTOP_SELF)).toBeNull() + }) + + it('refuses to publish an in-flight request against a superseded generation', () => { + const cache = createCache() + const fence = cache.beginRequest(DESKTOP_SELF) + cache.invalidate(DESKTOP_SELF) + cache.trackRequest(fence, Promise.resolve()) + expect(cache.pendingRequest(DESKTOP_SELF)).toBeNull() + }) +}) + +describe('automation host cache invalidation scope', () => { + it('invalidates only the entries of the named authority', () => { + const cache = createCache() + cache.commit(cache.beginRequest(DESKTOP_SELF), { rows: [row('a')] }) + const runtimeFence = cache.beginRequest(RUNTIME_SELF) + cache.commit(runtimeFence, { rows: [row('b')] }) + const desktopFence = cache.beginRequest(DESKTOP_SELF) + + expect(cache.invalidateAuthority(RUNTIME)).toEqual([hostStableKey(RUNTIME_SELF)]) + expect(cache.commit(desktopFence, { rows: [row('c')] })).toBe(true) + }) + + it('evicts a departed entry and caps the retired pool', () => { + const cache = createCache({ retiredLimit: 1 }) + cache.commit(cache.beginRequest(DESKTOP_SELF), { rows: [row('a')] }) + cache.commit(cache.beginRequest(RUNTIME_SELF), { rows: [row('b')] }) + cache.evict(hostStableKey(DESKTOP_SELF)) + cache.evict(hostStableKey(RUNTIME_SELF)) + expect(cache.keys()).toEqual([]) + // The revived entry keeps its advanced generation, so nothing captured before eviction commits. + const stale = cache.beginRequest(RUNTIME_SELF) + cache.invalidate(RUNTIME_SELF) + expect(cache.commit(stale, { rows: [] })).toBe(false) + }) + + it('notifies subscribers when entries change', () => { + const cache = createCache() + const listener = vi.fn() + const unsubscribe = cache.subscribe(listener) + cache.commit(cache.beginRequest(DESKTOP_SELF), { rows: [] }) + expect(listener).toHaveBeenCalledTimes(1) + unsubscribe() + cache.invalidate(DESKTOP_SELF) + expect(listener).toHaveBeenCalledTimes(1) + }) +}) + +function catalogEntry( + ref: StableAutomationCatalogRef, + querySupport: AutomationHostQuerySupport, + ownerRevision: number | null +): AutomationHostCatalogEntry { + return { + stableRef: ref, + owner: + ownerRevision === null + ? null + : { + authority: { kind: 'runtime', environmentId: 'env-1', pairingRevision: ownerRevision }, + selector: { kind: 'self' } + }, + stableKey: hostStableKey(ref), + label: 'Runtime', + authorityLabel: 'Runtime', + kind: 'self', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'unknown', + querySupport + } +} + +function catalogOf(entries: AutomationHostCatalogEntry[]): AutomationHostCatalog { + return { + entries, + byStableKey: new Map(entries.map((entry) => [entry.stableKey, entry])), + hydration: { + runtimeCatalogSettled: true, + desktopSshHydrated: true, + runtimeSshHydratedByEnvironmentId: new Map(), + savedRuntimeEnvironmentIds: new Set(['env-1']), + orphanSettledAuthorityKeys: new Set(), + unavailableAuthorityKeys: new Set() + } + } +} + +/** + * The fence is only as stable as the catalog generation feeding it, so this + * covers Step 2's warning from the consumer's side. + */ +describe('offline authority generation churn', () => { + const withRegistry = (registry: AutomationCatalogGenerationRegistry) => + createAutomationHostCache({ + catalogGeneration: (authority) => registry.get(authority), + connectionGeneration: () => 0 + }) + + it('does not churn while a disconnected authority keeps its last known query support', () => { + const registry = createAutomationCatalogGenerationRegistry() + const cache = withRegistry(registry) + registry.sync(catalogOf([catalogEntry(RUNTIME_SELF, 'scoped', 3)])) + const fence = cache.beginRequest(RUNTIME_SELF) + // The authority went offline; membership and incarnation are unchanged. + registry.sync(catalogOf([catalogEntry(RUNTIME_SELF, 'scoped', 3)])) + expect(cache.commit(fence, { rows: [row('a')] })).toBe(true) + }) + + it('churns if a disconnect is reported as legacy-unscoped with no owner', () => { + const registry = createAutomationCatalogGenerationRegistry() + const cache = withRegistry(registry) + registry.sync(catalogOf([catalogEntry(RUNTIME_SELF, 'scoped', 3)])) + const fence = cache.beginRequest(RUNTIME_SELF) + registry.sync(catalogOf([catalogEntry(RUNTIME_SELF, 'legacy-unscoped', null)])) + expect(cache.commit(fence, { rows: [row('a')] })).toBe(false) + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-cache.ts b/src/renderer/src/components/automations/automation-host-cache.ts new file mode 100644 index 00000000000..d8b3da1525b --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-cache.ts @@ -0,0 +1,303 @@ +/** + * Per-host automation cache with a three-part commit fence. + * + * A response may only land if the entry's request generation, the owning + * authority's catalog generation, and the authority's connection generation all + * still match what the request captured. That is what stops a slow answer from + * a re-paired runtime, a re-registered SSH target, or a host the user has since + * left from overwriting rows that now belong to a different incarnation. + * + * Removal tombstones need no separate check: a target leaving the catalog + * advances that authority's catalog generation, which the same fence rejects. + */ + +import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' +import { hostStableKey } from '../../../../shared/automation-owner-key' +import type { + StableAutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import { automationAuthorityCatalogKey } from './automation-host-catalog-types' +import { getAuthorityCatalogGeneration } from './automation-host-catalog-generation' +import { + AUTOMATION_HOST_CACHE_TTL_MS, + AUTOMATION_HOST_RETIRED_CACHE_LIMIT, + DESKTOP_AUTHORITY_CONNECTION_GENERATION, + type AutomationHostCacheEntry, + type AutomationHostQueryError, + type AutomationHostRequestFence, + type AutomationHostRow +} from './automation-host-cache-types' + +export type AutomationHostCacheFreshness = 'missing' | 'fresh' | 'stale' + +export type AutomationHostCommitInput = { + rows: readonly AutomationHostRow[] + /** Authoritative orphan count when the response carried one. */ + orphanCount?: number | null +} + +export type AutomationHostCacheOptions = { + now?: () => number + ttlMs?: number + retiredLimit?: number + catalogGeneration?: (authority: StableAutomationAuthorityRef) => number + connectionGeneration?: (authority: StableAutomationAuthorityRef) => number +} + +export type AutomationHostCache = { + get: (ref: StableAutomationCatalogRef) => AutomationHostCacheEntry | null + getByKey: (stableKey: string) => AutomationHostCacheEntry | null + freshness: (ref: StableAutomationCatalogRef) => AutomationHostCacheFreshness + /** Captures the generations a later commit must still match. */ + beginRequest: (ref: StableAutomationCatalogRef) => AutomationHostRequestFence + /** Publishes an in-flight promise so concurrent callers for the same owner share it. */ + trackRequest: (fence: AutomationHostRequestFence, request: Promise) => void + pendingRequest: (ref: StableAutomationCatalogRef) => Promise | null + /** Releases the published request for work that was dropped before it was sent. */ + abandonRequest: (fence: AutomationHostRequestFence) => void + commit: (fence: AutomationHostRequestFence, input: AutomationHostCommitInput) => boolean + fail: (fence: AutomationHostRequestFence, error: AutomationHostQueryError) => boolean + /** Advances the request generation so anything already in flight is discarded. */ + invalidate: (ref: StableAutomationCatalogRef) => void + /** Same fence bump for a replacement request, but it keeps the retry budget spent so far. */ + supersede: (ref: StableAutomationCatalogRef) => void + invalidateKey: (stableKey: string) => void + /** + * Drops what a host answered under a previous incarnation of itself, keeping + * its display slot. The stable key survives a re-adoption, so nothing else + * distinguishes those rows — and the TTL would otherwise report them fresh. + */ + discardIncarnation: (stableKey: string) => void + invalidateAuthority: (authority: StableAutomationAuthorityRef) => readonly string[] + keysForAuthority: (authority: StableAutomationAuthorityRef) => readonly string[] + evict: (stableKey: string) => void + keys: () => readonly string[] + subscribe: (listener: () => void) => () => void + reset: () => void +} + +type CacheRecord = AutomationHostCacheEntry & { + ref: StableAutomationCatalogRef + authorityKey: string +} + +function defaultConnectionGeneration(authority: StableAutomationAuthorityRef): number { + // Why: an environment the renderer has not mirrored yet must never look like a match. + return authority.kind === 'desktop' + ? DESKTOP_AUTHORITY_CONNECTION_GENERATION + : (getRuntimeEnvironmentRevision(authority.environmentId) ?? -1) +} + +function emptyRecord(ref: StableAutomationCatalogRef, catalogGeneration: number): CacheRecord { + return { + ref, + authorityKey: automationAuthorityCatalogKey(ref.authority), + data: [], + fetchedAt: null, + attempt: 0, + requestGeneration: 0, + catalogGeneration, + request: null, + error: null, + orphanCount: null + } +} + +export function createAutomationHostCache( + options: AutomationHostCacheOptions = {} +): AutomationHostCache { + const now = options.now ?? Date.now + const ttlMs = options.ttlMs ?? AUTOMATION_HOST_CACHE_TTL_MS + const retiredLimit = options.retiredLimit ?? AUTOMATION_HOST_RETIRED_CACHE_LIMIT + const catalogGenerationOf = options.catalogGeneration ?? getAuthorityCatalogGeneration + const connectionGenerationOf = options.connectionGeneration ?? defaultConnectionGeneration + const records = new Map() + const retired = new Map() + const listeners = new Set<() => void>() + + const notify = (): void => { + for (const listener of listeners) { + listener() + } + } + + const ensure = (ref: StableAutomationCatalogRef): CacheRecord => { + const key = hostStableKey(ref) + const existing = records.get(key) + if (existing) { + return existing + } + // A retired entry keeps its generations, so a host coming back cannot commit stale work. + const revived = retired.get(key) + if (revived) { + retired.delete(key) + records.set(key, revived) + return revived + } + const created = emptyRecord(ref, catalogGenerationOf(ref.authority)) + records.set(key, created) + return created + } + + const matchesFence = (record: CacheRecord, fence: AutomationHostRequestFence): boolean => + record.requestGeneration === fence.requestGeneration && + catalogGenerationOf(record.ref.authority) === fence.catalogGeneration && + connectionGenerationOf(record.ref.authority) === fence.connectionGeneration + + // Returns false for an evicted entry too: either way the answer arrived too + // late to land, and the caller counts that as a discard. + const settle = ( + fence: AutomationHostRequestFence, + apply: (record: CacheRecord) => void + ): boolean => { + const record = records.get(fence.stableKey) + if (!record || !matchesFence(record, fence)) { + return false + } + record.request = null + apply(record) + notify() + return true + } + + const bump = (record: CacheRecord, keepAttempts = false): void => { + record.requestGeneration += 1 + record.request = null + if (!keepAttempts) { + record.attempt = 0 + } + } + + return { + get: (ref) => records.get(hostStableKey(ref)) ?? null, + getByKey: (stableKey) => records.get(stableKey) ?? null, + freshness: (ref) => { + const record = records.get(hostStableKey(ref)) + if (!record || record.fetchedAt === null) { + return 'missing' + } + return now() - record.fetchedAt < ttlMs ? 'fresh' : 'stale' + }, + beginRequest: (ref) => { + const record = ensure(ref) + record.catalogGeneration = catalogGenerationOf(ref.authority) + return { + stableKey: hostStableKey(ref), + authorityKey: record.authorityKey, + requestGeneration: record.requestGeneration, + catalogGeneration: record.catalogGeneration, + connectionGeneration: connectionGenerationOf(ref.authority) + } + }, + trackRequest: (fence, request) => { + const record = records.get(fence.stableKey) + if (record && record.requestGeneration === fence.requestGeneration) { + record.request = request + } + }, + pendingRequest: (ref) => records.get(hostStableKey(ref))?.request ?? null, + abandonRequest: (fence) => { + const record = records.get(fence.stableKey) + if (!record || record.requestGeneration !== fence.requestGeneration) { + return + } + // Why bump rather than only clear the marker: the marker may not be published + // yet when the job is abandoned, and only a stale generation refuses it later. + // A left-behind marker reads as in flight and skips the host on every plan. + bump(record, true) + notify() + }, + commit: (fence, input) => + settle(fence, (record) => { + record.data = input.rows + record.fetchedAt = now() + record.attempt = 0 + record.error = null + if (input.orphanCount !== undefined && input.orphanCount !== null) { + record.orphanCount = input.orphanCount + } + }), + // Why: a failed refresh keeps the last good rows readable and records the error beside them. + fail: (fence, error) => + settle(fence, (record) => { + record.attempt += 1 + record.error = error + }), + invalidate: (ref) => { + bump(ensure(ref)) + notify() + }, + supersede: (ref) => { + bump(ensure(ref), true) + notify() + }, + invalidateKey: (stableKey) => { + const record = records.get(stableKey) + if (record) { + bump(record) + notify() + } + }, + discardIncarnation: (stableKey) => { + const record = records.get(stableKey) + if (!record) { + return + } + // Bumped as well as emptied: work captured under the old incarnation is + // still in flight, and its marker would otherwise skip the host forever. + bump(record) + record.data = [] + record.fetchedAt = null + record.error = null + record.orphanCount = null + notify() + }, + invalidateAuthority: (authority) => { + const authorityKey = automationAuthorityCatalogKey(authority) + const invalidated: string[] = [] + for (const [key, record] of records) { + if (record.authorityKey === authorityKey) { + bump(record) + invalidated.push(key) + } + } + if (invalidated.length > 0) { + notify() + } + return invalidated + }, + keysForAuthority: (authority) => { + const authorityKey = automationAuthorityCatalogKey(authority) + return [...records].filter(([, r]) => r.authorityKey === authorityKey).map(([key]) => key) + }, + evict: (stableKey) => { + const record = records.get(stableKey) + if (!record) { + return + } + bump(record) + records.delete(stableKey) + retired.set(stableKey, record) + // Insertion order is LRU order here: reviving re-inserts at the end. + while (retired.size > retiredLimit) { + const oldest = retired.keys().next() + if (oldest.done) { + break + } + retired.delete(oldest.value) + } + notify() + }, + keys: () => [...records.keys()], + subscribe: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + reset: () => { + records.clear() + retired.clear() + notify() + } + } +} diff --git a/src/renderer/src/components/automations/automation-host-catalog-generation.test.ts b/src/renderer/src/components/automations/automation-host-catalog-generation.test.ts new file mode 100644 index 00000000000..f085311fda2 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-catalog-generation.test.ts @@ -0,0 +1,220 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref' +import { buildAutomationHostCatalog } from './automation-host-catalog' +import { + createAutomationCatalogGenerationRegistry, + getAuthorityCatalogGeneration, + resetAutomationHostCatalogGenerationsForTests, + syncAutomationHostCatalogGenerations +} from './automation-host-catalog-generation' +import type { + AutomationCatalogSshMirrorInput, + AutomationHostCatalogInput, + AutomationRuntimeAuthorityInput +} from './automation-host-catalog-types' + +const DESKTOP: StableAutomationAuthorityRef = { kind: 'desktop' } +const ENV_A: StableAutomationAuthorityRef = { kind: 'runtime', environmentId: 'env-a' } +const ENV_B: StableAutomationAuthorityRef = { kind: 'runtime', environmentId: 'env-b' } + +function mirror( + overrides: Partial = {} +): AutomationCatalogSshMirrorInput { + return { + targetsHydrated: true, + targets: [], + removedTargetLabels: new Map(), + connectionStatusByTargetId: new Map(), + ...overrides + } +} + +function runtime( + environmentId: string, + overrides: Partial = {} +): AutomationRuntimeAuthorityInput { + return { + environmentId, + label: environmentId, + pairingRevision: 1, + authorityHealth: 'fresh', + querySupport: 'scoped', + ssh: mirror(), + ...overrides + } +} + +function input(overrides: Partial = {}): AutomationHostCatalogInput { + return { + desktop: { label: 'Local Mac', ssh: mirror() }, + runtimes: [runtime('env-a'), runtime('env-b')], + runtimeCatalogSettled: true, + ...overrides + } +} + +describe('automation catalog generation', () => { + let registry = createAutomationCatalogGenerationRegistry() + + beforeEach(() => { + registry = createAutomationCatalogGenerationRegistry() + }) + + it('advances every authority on the first sync', () => { + registry.sync(buildAutomationHostCatalog(input())) + expect(registry.get(DESKTOP)).toBe(1) + expect(registry.get(ENV_A)).toBe(1) + expect(registry.get(ENV_B)).toBe(1) + }) + + it('advances only the authority whose target bucket hydrated', () => { + registry.sync( + buildAutomationHostCatalog( + input({ + runtimes: [ + runtime('env-a', { ssh: mirror({ targetsHydrated: false }) }), + runtime('env-b') + ] + }) + ) + ) + const before = { + desktop: registry.get(DESKTOP), + a: registry.get(ENV_A), + b: registry.get(ENV_B) + } + const advanced = registry.sync( + buildAutomationHostCatalog( + input({ + runtimes: [ + runtime('env-a', { + ssh: mirror({ targets: [{ targetId: 't', label: 'T', generation: 2 }] }) + }), + runtime('env-b') + ] + }) + ) + ) + expect(advanced.advancedAuthorityKeys).toEqual(['authority:runtime:env-a']) + expect(registry.get(ENV_A)).toBe(before.a + 1) + expect(registry.get(ENV_B)).toBe(before.b) + expect(registry.get(DESKTOP)).toBe(before.desktop) + }) + + it('does not advance on runtime or SSH connection status changes', () => { + const targets = [{ targetId: 't', label: 'T', generation: 2 }] + registry.sync( + buildAutomationHostCatalog( + input({ + runtimes: [ + runtime('env-a', { + ssh: mirror({ targets, connectionStatusByTargetId: new Map([['t', 'connected']]) }) + }), + runtime('env-b') + ] + }) + ) + ) + const before = registry.get(ENV_A) + const advanced = registry.sync( + buildAutomationHostCatalog( + input({ + runtimes: [ + runtime('env-a', { + authorityHealth: 'stale-error', + ssh: mirror({ targets, connectionStatusByTargetId: new Map([['t', 'reconnecting']]) }) + }), + runtime('env-b') + ] + }) + ) + ) + expect(advanced.advancedAuthorityKeys).toEqual([]) + expect(registry.get(ENV_A)).toBe(before) + }) + + it('does not advance on a rename', () => { + registry.sync(buildAutomationHostCatalog(input())) + const before = registry.get(ENV_A) + registry.sync( + buildAutomationHostCatalog( + input({ runtimes: [runtime('env-a', { label: 'Renamed' }), runtime('env-b')] }) + ) + ) + expect(registry.get(ENV_A)).toBe(before) + }) + + it('advances on a same-id re-pair', () => { + registry.sync(buildAutomationHostCatalog(input())) + const before = registry.get(ENV_A) + const advanced = registry.sync( + buildAutomationHostCatalog( + input({ runtimes: [runtime('env-a', { pairingRevision: 2 }), runtime('env-b')] }) + ) + ) + expect(registry.get(ENV_A)).toBe(before + 1) + expect(registry.get(ENV_B)).toBe(1) + expect(advanced.reincarnatedStableKeys).toEqual(['host:runtime:env-a:self']) + }) + + // doc:321 — the entry a same-id re-adoption produces is byte-identical apart + // from this, so the caller has no other way to know its rows are obsolete. + it('reports a same-id SSH re-adoption as a reincarnated host', () => { + const desktopAt = (generation: number, label = 'Box'): AutomationHostCatalogInput => + input({ + desktop: { + label: 'Local Mac', + ssh: mirror({ targets: [{ targetId: 'ssh-1', label, generation }] }) + } + }) + const first = registry.sync(buildAutomationHostCatalog(desktopAt(4))) + expect(first.reincarnatedStableKeys).toEqual([]) + + const renamed = registry.sync(buildAutomationHostCatalog(desktopAt(4, 'Renamed'))) + expect(renamed.reincarnatedStableKeys).toEqual([]) + + const readopted = registry.sync(buildAutomationHostCatalog(desktopAt(5))) + expect(readopted.reincarnatedStableKeys).toEqual(['host:desktop:ssh:ssh-1']) + expect(readopted.advancedAuthorityKeys).toEqual(['authority:desktop']) + }) + + // The generation file's own warning: an offline authority reported as + // legacy-unscoped loses its owner refs, which must not read as a re-adoption. + it('does not report a host whose owner merely went absent', () => { + const withSupport = (querySupport: 'scoped' | 'legacy-unscoped'): AutomationHostCatalogInput => + input({ + runtimes: [ + runtime('env-a', { + querySupport, + // An SSH entry is where the owner ref actually disappears with the support level. + ssh: mirror({ targets: [{ targetId: 't', label: 'T', generation: 3 }] }) + }) + ] + }) + registry.sync(buildAutomationHostCatalog(withSupport('scoped'))) + + const degraded = registry.sync(buildAutomationHostCatalog(withSupport('legacy-unscoped'))) + expect(degraded.reincarnatedStableKeys).toEqual([]) + + const restored = registry.sync(buildAutomationHostCatalog(withSupport('scoped'))) + expect(restored.reincarnatedStableKeys).toEqual([]) + }) + + it('advances once when an authority leaves the catalog and stays put afterwards', () => { + registry.sync(buildAutomationHostCatalog(input())) + registry.sync(buildAutomationHostCatalog(input({ runtimes: [runtime('env-a')] }))) + const afterRemoval = registry.get(ENV_B) + expect(afterRemoval).toBe(2) + registry.sync(buildAutomationHostCatalog(input({ runtimes: [runtime('env-a')] }))) + expect(registry.get(ENV_B)).toBe(afterRemoval) + }) + + it('exposes a module-level registry for the cache fence', () => { + resetAutomationHostCatalogGenerationsForTests() + expect(getAuthorityCatalogGeneration(DESKTOP)).toBe(0) + syncAutomationHostCatalogGenerations(buildAutomationHostCatalog(input())) + expect(getAuthorityCatalogGeneration(DESKTOP)).toBe(1) + resetAutomationHostCatalogGenerationsForTests() + expect(getAuthorityCatalogGeneration(DESKTOP)).toBe(0) + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-catalog-generation.ts b/src/renderer/src/components/automations/automation-host-catalog-generation.ts new file mode 100644 index 00000000000..8db2c85c16c --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-catalog-generation.ts @@ -0,0 +1,143 @@ +import { ownerKey } from '../../../../shared/automation-owner-key' +import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref' +import { + automationAuthorityCatalogKey, + type AutomationHostCatalog, + type AutomationHostCatalogEntry +} from './automation-host-catalog-types' + +/** + * Catalog generation, tracked *per authority*. + * + * It advances only when that authority's authoritative membership or + * incarnation changes. Runtime connection status and SSH connection status feed + * health, not this counter — otherwise one host's target bucket hydrating would + * discard every other host's in-flight response and burn the retry cap. + * + * Callers must report the last known query support for an offline authority: + * synthesising `legacy-unscoped` on a mere disconnect would drop the entries' + * owner refs and read as an incarnation change. + */ + +export type AutomationCatalogGenerationSync = { + /** Authority keys whose catalog generation advanced. */ + advancedAuthorityKeys: readonly string[] + /** + * Hosts that stayed listed while their owner incarnation changed underneath — + * a same-id SSH re-adoption or re-pair. Nothing else in the entry moves, so + * this is the only signal that the rows already held answer for a host that no + * longer exists. + */ + reincarnatedStableKeys: readonly string[] +} + +export type AutomationCatalogGenerationRegistry = { + get: (authority: StableAutomationAuthorityRef) => number + /** Applies a rebuilt catalog and reports what the caller has to invalidate. */ + sync: (catalog: AutomationHostCatalog) => AutomationCatalogGenerationSync + reset: () => void +} + +// Health is deliberately absent: only membership and incarnation belong here. +export function automationHostCatalogEntryFingerprint(entry: AutomationHostCatalogEntry): string { + return `${entry.stableKey}|${entry.catalogState}|${entry.owner ? ownerKey(entry.owner) : '-'}` +} + +function fingerprintByAuthority(catalog: AutomationHostCatalog): Map { + const parts = new Map() + for (const entry of catalog.entries) { + const key = automationAuthorityCatalogKey(entry.stableRef.authority) + const bucket = parts.get(key) + if (bucket) { + bucket.push(automationHostCatalogEntryFingerprint(entry)) + } else { + parts.set(key, [automationHostCatalogEntryFingerprint(entry)]) + } + } + // Entries arrive in the deterministic catalog order, so the join is stable. + return new Map([...parts].map(([key, values]) => [key, values.join('\n')])) +} + +export function createAutomationCatalogGenerationRegistry(): AutomationCatalogGenerationRegistry { + const generationByAuthorityKey = new Map() + const fingerprintByAuthorityKey = new Map() + const ownerKeyByStableKey = new Map() + + /** Compared only where an owner exists: a disconnect can strip owner refs, and that is not a new incarnation. */ + const reincarnations = (catalog: AutomationHostCatalog): string[] => { + const reincarnated: string[] = [] + const listed = new Set() + for (const entry of catalog.entries) { + listed.add(entry.stableKey) + if (!entry.owner) { + continue + } + const next = ownerKey(entry.owner) + const previous = ownerKeyByStableKey.get(entry.stableKey) + if (previous !== undefined && previous !== next) { + reincarnated.push(entry.stableKey) + } + ownerKeyByStableKey.set(entry.stableKey, next) + } + // Deleting during iteration is defined for Map; a visited key is never revisited. + for (const stableKey of ownerKeyByStableKey.keys()) { + if (!listed.has(stableKey)) { + ownerKeyByStableKey.delete(stableKey) + } + } + return reincarnated + } + + const advance = (authorityKey: string, fingerprint: string): void => { + fingerprintByAuthorityKey.set(authorityKey, fingerprint) + generationByAuthorityKey.set( + authorityKey, + (generationByAuthorityKey.get(authorityKey) ?? 0) + 1 + ) + } + + return { + get: (authority) => generationByAuthorityKey.get(automationAuthorityCatalogKey(authority)) ?? 0, + sync: (catalog) => { + const reincarnatedStableKeys = reincarnations(catalog) + const next = fingerprintByAuthority(catalog) + const advanced: string[] = [] + for (const [authorityKey, fingerprint] of next) { + if (fingerprintByAuthorityKey.get(authorityKey) !== fingerprint) { + advance(authorityKey, fingerprint) + advanced.push(authorityKey) + } + } + // Why: an authority that left the catalog must invalidate its captured requests too. + for (const authorityKey of fingerprintByAuthorityKey.keys()) { + if (!next.has(authorityKey) && fingerprintByAuthorityKey.get(authorityKey) !== '') { + advance(authorityKey, '') + advanced.push(authorityKey) + } + } + return { advancedAuthorityKeys: advanced, reincarnatedStableKeys } + }, + reset: () => { + generationByAuthorityKey.clear() + fingerprintByAuthorityKey.clear() + ownerKeyByStableKey.clear() + } + } +} + +const registry = createAutomationCatalogGenerationRegistry() + +/** Generation Step 4's commit fence compares for the authority that owns the entry. */ +export function getAuthorityCatalogGeneration(authority: StableAutomationAuthorityRef): number { + return registry.get(authority) +} + +export function syncAutomationHostCatalogGenerations( + catalog: AutomationHostCatalog +): AutomationCatalogGenerationSync { + return registry.sync(catalog) +} + +export function resetAutomationHostCatalogGenerationsForTests(): void { + registry.reset() +} diff --git a/src/renderer/src/components/automations/automation-host-catalog-order.test.ts b/src/renderer/src/components/automations/automation-host-catalog-order.test.ts new file mode 100644 index 00000000000..347b55d2d06 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-catalog-order.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it, vi } from 'vitest' +import { buildAutomationHostCatalog } from './automation-host-catalog' +import { orderAutomationHostCatalogEntries } from './automation-host-catalog-order' +import type { + AutomationCatalogSshMirrorInput, + AutomationHostCatalogEntry +} from './automation-host-catalog-types' + +function mirror( + targets: { targetId: string; label: string }[], + overrides: Partial = {} +): AutomationCatalogSshMirrorInput { + return { + targetsHydrated: true, + targets: targets.map((target) => ({ ...target, generation: 1 })), + removedTargetLabels: new Map(), + connectionStatusByTargetId: new Map(), + ...overrides + } +} + +function describeEntry(entry: AutomationHostCatalogEntry): string { + const authority = entry.stableRef.authority + const prefix = authority.kind === 'desktop' ? 'desktop' : authority.environmentId + return `${prefix}/${entry.kind}:${entry.label}` +} + +describe('orderAutomationHostCatalogEntries', () => { + it('orders desktop first, then runtimes, with orphans last inside each authority', () => { + const catalog = buildAutomationHostCatalog({ + desktop: { + label: 'Local Mac', + ssh: mirror([ + { targetId: 'z', label: 'zeta' }, + { targetId: 'a', label: 'alpha' } + ]), + orphanCount: 1 + }, + runtimes: [ + { + environmentId: 'env-z', + label: 'Zulu', + pairingRevision: 1, + authorityHealth: 'fresh', + querySupport: 'scoped', + ssh: mirror([{ targetId: 'zt', label: 'zulu target' }]), + orphanCount: 4 + }, + { + environmentId: 'env-a', + label: 'Alpha', + pairingRevision: 1, + authorityHealth: 'fresh', + querySupport: 'scoped', + ssh: mirror([ + { targetId: 'b2', label: 'beta' }, + { targetId: 'a2', label: 'Ampere' } + ]) + } + ], + runtimeCatalogSettled: true + }) + expect(catalog.entries.map(describeEntry)).toEqual([ + 'desktop/self:Local Mac', + 'desktop/ssh:alpha', + 'desktop/ssh:zeta', + 'desktop/orphan:Unassigned legacy automations', + 'env-a/self:Alpha', + 'env-a/ssh:Ampere', + 'env-a/ssh:beta', + 'env-z/self:Zulu', + 'env-z/ssh:zulu target', + 'env-z/orphan:Unassigned legacy automations' + ]) + }) + + it('breaks equal labels by target id and equal authority labels by environment id', () => { + const catalog = buildAutomationHostCatalog({ + desktop: { + label: 'Local Mac', + ssh: mirror([ + { targetId: 'zzz', label: 'same' }, + { targetId: 'aaa', label: 'same' } + ]) + }, + runtimes: ['env-b', 'env-a'].map((environmentId) => ({ + environmentId, + label: 'Tied', + pairingRevision: 1, + authorityHealth: 'fresh' as const, + querySupport: 'scoped' as const, + ssh: mirror([]) + })), + runtimeCatalogSettled: true + }) + expect(catalog.entries.map(describeEntry)).toEqual([ + 'desktop/self:Local Mac', + 'desktop/ssh:same', + 'desktop/ssh:same', + 'env-a/self:Tied', + 'env-b/self:Tied' + ]) + const desktopSsh = catalog.entries.filter((entry) => entry.kind === 'ssh') + expect(desktopSsh.map((entry) => entry.stableRef.selector)).toEqual([ + { kind: 'ssh', targetId: 'aaa' }, + { kind: 'ssh', targetId: 'zzz' } + ]) + }) + + it('sorts labels with a locale-aware collator', () => { + const catalog = buildAutomationHostCatalog({ + desktop: { + label: 'Local Mac', + ssh: mirror([ + { targetId: '1', label: 'box-10' }, + { targetId: '2', label: 'box-2' }, + { targetId: '3', label: 'Ähnlich' } + ]) + }, + runtimes: [], + runtimeCatalogSettled: true + }) + expect( + catalog.entries.filter((entry) => entry.kind === 'ssh').map((entry) => entry.label) + ).toEqual(['Ähnlich', 'box-2', 'box-10']) + }) + + it('constructs exactly one collator per rebuild', () => { + const catalog = buildAutomationHostCatalog({ + desktop: { + label: 'Local Mac', + ssh: mirror( + Array.from({ length: 40 }, (_unused, index) => ({ + targetId: `t${index}`, + label: `host ${index}` + })) + ) + }, + runtimes: [], + runtimeCatalogSettled: true + }) + const createCollator = vi.fn(() => new Intl.Collator(undefined, { numeric: true })) + orderAutomationHostCatalogEntries(catalog.entries, { createCollator }) + expect(createCollator).toHaveBeenCalledTimes(1) + }) + + it('is a pure reordering', () => { + const catalog = buildAutomationHostCatalog({ + desktop: { label: 'Local Mac', ssh: mirror([{ targetId: 'a', label: 'A' }]) }, + runtimes: [], + runtimeCatalogSettled: true + }) + const input = catalog.entries.toReversed() + const ordered = orderAutomationHostCatalogEntries(input) + expect(input).toEqual(catalog.entries.toReversed()) + expect(new Set(ordered)).toEqual(new Set(catalog.entries)) + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-catalog-order.ts b/src/renderer/src/components/automations/automation-host-catalog-order.ts new file mode 100644 index 00000000000..b07223e6e75 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-catalog-order.ts @@ -0,0 +1,76 @@ +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' + +/** + * The one deterministic catalog order, shared by the picker and the All-hosts + * grouped list so responses arriving out of order never reflow the page. + * + * Sort fields are precomputed once per rebuild and a single `Intl.Collator` is + * constructed per call — never one per comparison. + */ + +export type AutomationHostCatalogOrderOptions = { + /** Injectable so tests can prove exactly one collator is built per rebuild. */ + createCollator?: () => Intl.Collator +} + +type SortFields = { + entry: AutomationHostCatalogEntry + authorityRank: number + authorityLabel: string + authorityId: string + kindRank: number + label: string + targetId: string +} + +const KIND_RANK: Record = { + self: 0, + ssh: 1, + orphan: 2 +} + +function defaultCollator(): Intl.Collator { + return new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }) +} + +function toSortFields(entry: AutomationHostCatalogEntry): SortFields { + const { authority, selector } = entry.stableRef + return { + entry, + authorityRank: authority.kind === 'desktop' ? 0 : 1, + authorityLabel: entry.authorityLabel, + authorityId: authority.kind === 'desktop' ? '' : authority.environmentId, + kindRank: KIND_RANK[entry.kind], + label: entry.label, + targetId: selector.kind === 'ssh' ? selector.targetId : '' + } +} + +// Why: ids are identifiers, not prose — compare them byte-wise so the tiebreak is locale-independent. +function compareIds(a: string, b: string): number { + return a === b ? 0 : a < b ? -1 : 1 +} + +export function orderAutomationHostCatalogEntries( + entries: readonly AutomationHostCatalogEntry[], + options?: AutomationHostCatalogOrderOptions +): AutomationHostCatalogEntry[] { + const collator = (options?.createCollator ?? defaultCollator)() + const decorated = entries.map(toSortFields) + decorated.sort((a, b) => { + if (a.authorityRank !== b.authorityRank) { + return a.authorityRank - b.authorityRank + } + if (a.authorityId !== b.authorityId) { + return ( + collator.compare(a.authorityLabel, b.authorityLabel) || + compareIds(a.authorityId, b.authorityId) + ) + } + if (a.kindRank !== b.kindRank) { + return a.kindRank - b.kindRank + } + return collator.compare(a.label, b.label) || compareIds(a.targetId, b.targetId) + }) + return decorated.map((fields) => fields.entry) +} diff --git a/src/renderer/src/components/automations/automation-host-catalog-source.test.ts b/src/renderer/src/components/automations/automation-host-catalog-source.test.ts new file mode 100644 index 00000000000..7104026fe6a --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-catalog-source.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import { AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + buildAutomationHostCatalogSource, + runtimeAuthorityHealth, + runtimeQuerySupport, + type AutomationCatalogRuntimeSource, + type AutomationCatalogSshSource +} from './automation-host-catalog-source' + +function runtime( + overrides: Partial = {} +): AutomationCatalogRuntimeSource { + return { + environmentId: 'gpu', + label: 'GPU box', + pairingRevision: 7, + status: undefined, + ssh: undefined, + ...overrides + } +} + +const EMPTY_SSH: AutomationCatalogSshSource = { + targetsHydrated: true, + targetLabels: new Map(), + removedTargetLabels: new Map(), + connectionStates: new Map() +} + +describe('automation host catalog source', () => { + it('treats a never-probed environment as loading, not as down', () => { + expect(runtimeAuthorityHealth(runtime())).toBe('loading') + expect(runtimeAuthorityHealth(runtime({ status: { status: null } }))).toBe('unavailable') + expect(runtimeAuthorityHealth(runtime({ status: { status: {} as never } }))).toBe('fresh') + }) + + it('assumes scoped support until the capability set is actually known', () => { + // The scoped client re-checks the capability and fails closed itself, so + // guessing legacy here would only discard owners on a modern host. + expect(runtimeQuerySupport(runtime())).toBe('scoped') + expect(runtimeQuerySupport(runtime({ status: { status: null } }))).toBe('scoped') + expect( + runtimeQuerySupport(runtime({ status: { status: { capabilities: [] } as never } })) + ).toBe('legacy-unscoped') + expect( + runtimeQuerySupport( + runtime({ + status: { + status: { capabilities: [AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY] } as never + } + }) + ) + ).toBe('scoped') + }) + + it('assumes a hydrated desktop target with no recorded state is disconnected', () => { + const input = buildAutomationHostCatalogSource({ + desktopSsh: { + ...EMPTY_SSH, + targetLabels: new Map([['t1', 'web-01']]), + targetGenerations: new Map([['t1', 3]]) + }, + runtimes: [], + runtimeCatalogSettled: true + }) + + expect(input.desktop.ssh.missingConnectionStatus).toBe('disconnected') + expect(input.desktop.ssh.targets).toEqual([{ targetId: 't1', label: 'web-01', generation: 3 }]) + }) + + it('omits a generation the authority never reported rather than inventing one', () => { + const input = buildAutomationHostCatalogSource({ + desktopSsh: { ...EMPTY_SSH, targetLabels: new Map([['t1', 'web-01']]) }, + runtimes: [], + runtimeCatalogSettled: true + }) + + expect(input.desktop.ssh.targets[0]).toEqual({ targetId: 't1', label: 'web-01' }) + }) + + it('omits an unsettled orphan count instead of passing zero', () => { + const settled = buildAutomationHostCatalogSource({ + desktopSsh: EMPTY_SSH, + runtimes: [], + runtimeCatalogSettled: true, + orphanCount: () => 0 + }) + const unsettled = buildAutomationHostCatalogSource({ + desktopSsh: EMPTY_SSH, + runtimes: [], + runtimeCatalogSettled: true, + orphanCount: () => null + }) + + expect(settled.desktop.orphanCount).toBe(0) + expect('orphanCount' in unsettled.desktop).toBe(false) + }) + + it('leaves a runtime with no mirrored SSH state unhydrated', () => { + const input = buildAutomationHostCatalogSource({ + desktopSsh: EMPTY_SSH, + runtimes: [runtime()], + runtimeCatalogSettled: true + }) + + // Absence of a bucket is not evidence the environment has no targets. + expect(input.runtimes[0].ssh.targetsHydrated).toBe(false) + expect(input.runtimes[0].pairingRevision).toBe(7) + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-catalog-source.ts b/src/renderer/src/components/automations/automation-host-catalog-source.ts new file mode 100644 index 00000000000..670fe4b175c --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-catalog-source.ts @@ -0,0 +1,154 @@ +/** + * Projects mirrored renderer state into the catalog builder's input. + * + * Deliberately reads store state only. Query results are folded in later by + * `automation-host-cache-health.ts`, because a catalog derived from the cache + * would change every time the cache it drives commits, and re-applying a catalog + * is what advances generations and cancels in-flight work. + */ + +import { getLocalExecutionHostLabel } from '../../../../shared/execution-host' +import { AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { SshConnectionStatus } from '../../../../shared/ssh-types' +import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref' +import type { + AutomationAuthorityHealth, + AutomationCatalogSshMirrorInput, + AutomationCatalogSshTargetInput, + AutomationHostCatalogInput, + AutomationHostQuerySupport +} from './automation-host-catalog-types' + +/** The mirrored SSH state one authority exposes; desktop and runtime shapes converge here. */ +export type AutomationCatalogSshSource = { + targetsHydrated: boolean + targetLabels: ReadonlyMap + /** Absent for a target whose authority never reported a registration generation. */ + targetGenerations?: ReadonlyMap + removedTargetLabels: ReadonlyMap + connectionStates: ReadonlyMap +} + +export type AutomationCatalogRuntimeSource = { + environmentId: string + label: string + pairingRevision: number + /** Undefined until this client has probed the environment at all. */ + status: { status: RuntimeStatus | null } | undefined + ssh: AutomationCatalogSshSource | undefined +} + +export type AutomationHostCatalogSource = { + desktopLabel?: string + desktopSsh: AutomationCatalogSshSource + runtimes: readonly AutomationCatalogRuntimeSource[] + runtimeCatalogSettled: boolean + referencedStableKeys?: Iterable + /** Authoritative orphan counts the cache has seen, keyed by authority catalog key. */ + orphanCount?: (authority: StableAutomationAuthorityRef) => number | null +} + +const EMPTY_SSH: AutomationCatalogSshSource = { + targetsHydrated: false, + targetLabels: new Map(), + removedTargetLabels: new Map(), + connectionStates: new Map() +} + +function toSshTargets(source: AutomationCatalogSshSource): AutomationCatalogSshTargetInput[] { + const targets: AutomationCatalogSshTargetInput[] = [] + for (const [targetId, label] of source.targetLabels) { + const generation = source.targetGenerations?.get(targetId) + targets.push({ targetId, label, ...(generation === undefined ? {} : { generation }) }) + } + return targets +} + +function toSshMirror( + source: AutomationCatalogSshSource, + missingConnectionStatus?: SshConnectionStatus +): AutomationCatalogSshMirrorInput { + const connectionStatusByTargetId = new Map() + for (const [targetId, state] of source.connectionStates) { + connectionStatusByTargetId.set(targetId, state.status) + } + return { + targetsHydrated: source.targetsHydrated, + targets: toSshTargets(source), + removedTargetLabels: source.removedTargetLabels, + connectionStatusByTargetId, + ...(missingConnectionStatus ? { missingConnectionStatus } : {}) + } +} + +/** A never-probed environment is loading, not down; only a failed probe proves unreachable. */ +export function runtimeAuthorityHealth( + runtime: AutomationCatalogRuntimeSource +): AutomationAuthorityHealth { + if (!runtime.status) { + return 'loading' + } + return runtime.status.status === null ? 'unavailable' : 'fresh' +} + +/** + * Optimistic on an unknown capability set, fail-closed on a known one: the + * scoped client re-checks the capability before every list and raises the + * update-required error itself, so guessing `legacy-unscoped` here would only + * throw away owners on a modern host we have not probed yet. + */ +export function runtimeQuerySupport( + runtime: AutomationCatalogRuntimeSource +): AutomationHostQuerySupport { + const capabilities = runtime.status?.status?.capabilities + if (!capabilities) { + return 'scoped' + } + return capabilities.includes(AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY) + ? 'scoped' + : 'legacy-unscoped' +} + +function orphanCountFor( + source: AutomationHostCatalogSource, + authority: StableAutomationAuthorityRef +): number | undefined { + return source.orphanCount?.(authority) ?? undefined +} + +export function buildAutomationHostCatalogSource( + source: AutomationHostCatalogSource +): AutomationHostCatalogInput { + return { + desktop: { + label: source.desktopLabel ?? getLocalExecutionHostLabel(), + // Why 'disconnected' for a hydrated target with no state: the desktop + // records a state the moment it dials, so silence means it never did. + ssh: toSshMirror(source.desktopSsh, 'disconnected'), + ...withOrphanCount(orphanCountFor(source, { kind: 'desktop' })) + }, + runtimes: source.runtimes.map((runtime) => { + const authority: StableAutomationAuthorityRef = { + kind: 'runtime', + environmentId: runtime.environmentId + } + return { + environmentId: runtime.environmentId, + label: runtime.label, + pairingRevision: runtime.pairingRevision, + authorityHealth: runtimeAuthorityHealth(runtime), + querySupport: runtimeQuerySupport(runtime), + ssh: toSshMirror(runtime.ssh ?? EMPTY_SSH), + ...withOrphanCount(orphanCountFor(source, authority)) + } + }), + runtimeCatalogSettled: source.runtimeCatalogSettled, + ...(source.referencedStableKeys ? { referencedStableKeys: source.referencedStableKeys } : {}) + } +} + +/** Omitted rather than passed as undefined: absence is what marks the count unsettled. */ +function withOrphanCount(orphanCount: number | undefined): { orphanCount?: number } { + return orphanCount === undefined ? {} : { orphanCount } +} diff --git a/src/renderer/src/components/automations/automation-host-catalog-types.ts b/src/renderer/src/components/automations/automation-host-catalog-types.ts new file mode 100644 index 00000000000..4aa37d0617c --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-catalog-types.ts @@ -0,0 +1,141 @@ +import type { + AutomationOwnerRef, + StableAutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import type { SshConnectionStatus } from '../../../../shared/ssh-types' + +/** + * Health of an authority's *stored-automation query path*. Never describes its + * execution targets: a reachable authority keeps listing and editing records + * whose SSH target is down (required invariant 6). + */ +export type AutomationAuthorityHealth = + | 'loading' + | 'fresh' + | 'refreshing' + | 'stale-error' + | 'unavailable' + | 'incompatible' + +/** Health of the *execution target*, tracked independently of authority health. */ +export type AutomationExecutionHealth = + | 'connected' + | 'connecting' + | 'disconnected' + | 'unavailable' + | 'unknown' + +/** What list/fencing contract this entry can use. `legacy-unscoped` entries are view-only. */ +export type AutomationHostQuerySupport = 'scoped' | 'legacy-unscoped' | 'incompatible' + +/** + * Why an entry cannot use host-scoped, fenced queries. Absent when it can. + * + * Only `authority-unscoped` is positive evidence about the authority's list + * contract — a probe that actually came back without the capability. The target + * reasons say nothing about the server's age and none of them are repaired by + * updating it, so they must never share its badge copy or its recovery verb. + */ +export type AutomationHostScopeGap = + /** The owning authority proved the target is gone (tombstone or hydrated absence). */ + | 'target-removed' + /** No live registration has been seen yet; a disconnect clears generations too. */ + | 'target-unverified' + /** The target is live and hydrated, but its authority issued it no registration generation. */ + | 'target-unregistered' + /** The authority advertises no host-scoped list capability. */ + | 'authority-unscoped' + +/** Whether the entry's membership is proven, merely remembered, or positively gone. */ +export type AutomationHostCatalogState = 'authoritative' | 'unhydrated' | 'removed' + +export type AutomationHostCatalogEntry = { + stableRef: StableAutomationCatalogRef + /** Null whenever no executable, fully fenced owner exists (ghost, unhydrated, orphan, legacy). */ + owner: AutomationOwnerRef | null + stableKey: string + label: string + authorityLabel: string + kind: 'self' | 'ssh' | 'orphan' + catalogState: AutomationHostCatalogState + authorityHealth: AutomationAuthorityHealth + executionHealth: AutomationExecutionHealth + querySupport: AutomationHostQuerySupport + /** Present only while `querySupport` is degraded; explains which repair, if any, applies. */ + scopeGap?: AutomationHostScopeGap +} + +export type AutomationCatalogSshTargetInput = { + targetId: string + label: string + /** Durable registration generation; absent on legacy targets and old servers. */ + generation?: number +} + +/** One authority's mirrored SSH state. Desktop and runtime supply the same shape. */ +export type AutomationCatalogSshMirrorInput = { + /** Absence only counts as removal once a target list actually loaded. */ + targetsHydrated: boolean + targets: readonly AutomationCatalogSshTargetInput[] + removedTargetLabels: ReadonlyMap + connectionStatusByTargetId: ReadonlyMap + /** Status assumed for a hydrated target with no recorded state; desktop passes `disconnected`. */ + missingConnectionStatus?: SshConnectionStatus +} + +export type AutomationDesktopAuthorityInput = { + label: string + /** Desktop storage is local IPC, so this reflects query/cache state only. */ + authorityHealth?: AutomationAuthorityHealth + ssh: AutomationCatalogSshMirrorInput + /** Authoritative orphan count from the last list; absent means not yet known. */ + orphanCount?: number +} + +export type AutomationRuntimeAuthorityInput = { + environmentId: string + label: string + pairingRevision: number + authorityHealth: AutomationAuthorityHealth + /** Supplied by the caller from advertised capabilities; the projection never probes. */ + querySupport: AutomationHostQuerySupport + ssh: AutomationCatalogSshMirrorInput + orphanCount?: number +} + +export type AutomationHostCatalogInput = { + desktop: AutomationDesktopAuthorityInput + /** Saved runtime environments — projected even while offline. */ + runtimes: readonly AutomationRuntimeAuthorityInput[] + /** Gates Runtime+Self absence; a failed catalog read settles without hydrating. */ + runtimeCatalogSettled: boolean + /** Stable keys still referenced by a stored automation, cached row, persisted filter, or tombstone. */ + referencedStableKeys?: Iterable +} + +/** Positive-evidence gates for absence. Missing data is never removal (invariant 4). */ +export type AutomationCatalogHydrationEvidence = { + runtimeCatalogSettled: boolean + desktopSshHydrated: boolean + runtimeSshHydratedByEnvironmentId: ReadonlyMap + savedRuntimeEnvironmentIds: ReadonlySet + /** Authority keys that returned an authoritative orphan count. */ + orphanSettledAuthorityKeys: ReadonlySet + /** Authority keys currently unreachable; their absences prove nothing. */ + unavailableAuthorityKeys: ReadonlySet +} + +export type AutomationHostCatalog = { + /** Deterministically ordered; see `orderAutomationHostCatalogEntries`. */ + entries: readonly AutomationHostCatalogEntry[] + byStableKey: ReadonlyMap + hydration: AutomationCatalogHydrationEvidence +} + +/** Namespaced so an authority key can never be confused with one of its host keys. */ +export function automationAuthorityCatalogKey(authority: StableAutomationAuthorityRef): string { + return authority.kind === 'desktop' + ? 'authority:desktop' + : `authority:runtime:${encodeURIComponent(authority.environmentId)}` +} diff --git a/src/renderer/src/components/automations/automation-host-catalog.test.ts b/src/renderer/src/components/automations/automation-host-catalog.test.ts new file mode 100644 index 00000000000..2eae8530b02 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-catalog.test.ts @@ -0,0 +1,459 @@ +import { describe, expect, it, vi } from 'vitest' +import { hostStableKey, ownerKey } from '../../../../shared/automation-owner-key' +import { RUNTIME_OWNED_SSH_TARGET_ID_PREFIX } from '../../../../shared/execution-host' +import { + AUTOMATION_ORPHAN_ENTRY_LABEL, + buildAutomationHostCatalog +} from './automation-host-catalog' +import type { + AutomationCatalogSshMirrorInput, + AutomationHostCatalog, + AutomationHostCatalogEntry, + AutomationHostCatalogInput, + AutomationRuntimeAuthorityInput +} from './automation-host-catalog-types' + +function mirror( + overrides: Partial = {} +): AutomationCatalogSshMirrorInput { + return { + targetsHydrated: true, + targets: [], + removedTargetLabels: new Map(), + connectionStatusByTargetId: new Map(), + ...overrides + } +} + +function runtime( + overrides: Partial = {} +): AutomationRuntimeAuthorityInput { + return { + environmentId: 'env-1', + label: 'Build server', + pairingRevision: 7, + authorityHealth: 'fresh', + querySupport: 'scoped', + ssh: mirror(), + ...overrides + } +} + +function buildInput( + overrides: Partial = {} +): AutomationHostCatalogInput { + return { + desktop: { label: 'Local Mac', ssh: mirror({ missingConnectionStatus: 'disconnected' }) }, + runtimes: [], + runtimeCatalogSettled: true, + ...overrides + } +} + +function entryAt(catalog: AutomationHostCatalog, key: string): AutomationHostCatalogEntry { + const entry = catalog.byStableKey.get(key) + if (!entry) { + throw new Error(`missing catalog entry: ${key}`) + } + return entry +} + +const DESKTOP_SELF_KEY = hostStableKey({ + authority: { kind: 'desktop' }, + selector: { kind: 'self' } +}) + +function desktopSshKey(targetId: string): string { + return hostStableKey({ authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId } }) +} + +function runtimeSelfKey(environmentId: string): string { + return hostStableKey({ + authority: { kind: 'runtime', environmentId }, + selector: { kind: 'self' } + }) +} + +function runtimeSshKey(environmentId: string, targetId: string): string { + return hostStableKey({ + authority: { kind: 'runtime', environmentId }, + selector: { kind: 'ssh', targetId } + }) +} + +describe('buildAutomationHostCatalog', () => { + it('projects Desktop + Self as immediately authoritative', () => { + const catalog = buildAutomationHostCatalog(buildInput()) + const self = entryAt(catalog, DESKTOP_SELF_KEY) + expect(self).toMatchObject({ + kind: 'self', + label: 'Local Mac', + catalogState: 'authoritative', + executionHealth: 'connected', + querySupport: 'scoped' + }) + expect(self.owner).toEqual({ authority: { kind: 'desktop' }, selector: { kind: 'self' } }) + }) + + it('keeps equal SSH target ids under Desktop and a runtime distinct', () => { + const catalog = buildAutomationHostCatalog( + buildInput({ + desktop: { + label: 'Local Mac', + ssh: mirror({ + targets: [{ targetId: 'shared', label: 'Desktop box', generation: 3 }], + connectionStatusByTargetId: new Map([['shared', 'connected']]) + }) + }, + runtimes: [ + runtime({ + ssh: mirror({ + targets: [{ targetId: 'shared', label: 'Runtime box', generation: 11 }], + connectionStatusByTargetId: new Map([['shared', 'disconnected']]) + }) + }) + ] + }) + ) + const desktop = entryAt(catalog, desktopSshKey('shared')) + const nested = entryAt(catalog, runtimeSshKey('env-1', 'shared')) + expect(desktop.stableKey).not.toBe(nested.stableKey) + expect(desktop.label).toBe('Desktop box') + expect(nested.label).toBe('Runtime box') + expect(desktop.executionHealth).toBe('connected') + expect(nested.executionHealth).toBe('disconnected') + expect(ownerKey(desktop.owner!)).not.toBe(ownerKey(nested.owner!)) + expect(nested.owner).toEqual({ + authority: { kind: 'runtime', environmentId: 'env-1', pairingRevision: 7 }, + selector: { kind: 'ssh', targetId: 'shared', targetGeneration: 11 } + }) + }) + + it('keeps equal SSH target ids under two runtimes distinct', () => { + const catalog = buildAutomationHostCatalog( + buildInput({ + runtimes: [ + runtime({ + environmentId: 'env-a', + label: 'A', + ssh: mirror({ targets: [{ targetId: 'shared', label: 'A box', generation: 1 }] }) + }), + runtime({ + environmentId: 'env-b', + label: 'B', + ssh: mirror({ targets: [{ targetId: 'shared', label: 'B box', generation: 1 }] }) + }) + ] + }) + ) + expect(entryAt(catalog, runtimeSshKey('env-a', 'shared')).label).toBe('A box') + expect(entryAt(catalog, runtimeSshKey('env-b', 'shared')).label).toBe('B box') + expect(catalog.entries.filter((entry) => entry.kind === 'ssh')).toHaveLength(2) + }) + + it('projects Runtime + Self while the runtime is offline', () => { + const catalog = buildAutomationHostCatalog( + buildInput({ runtimes: [runtime({ authorityHealth: 'unavailable' })] }) + ) + const self = entryAt(catalog, runtimeSelfKey('env-1')) + expect(self).toMatchObject({ + catalogState: 'authoritative', + authorityHealth: 'unavailable', + executionHealth: 'disconnected', + label: 'Build server' + }) + expect(self.owner).not.toBeNull() + }) + + it('preserves nested labels through a parent outage without claiming removal', () => { + const catalog = buildAutomationHostCatalog( + buildInput({ + runtimes: [ + runtime({ + authorityHealth: 'unavailable', + ssh: mirror({ + targetsHydrated: false, + targets: [{ targetId: 'nested', label: 'Nested box', generation: 4 }] + }) + }) + ] + }) + ) + const nested = entryAt(catalog, runtimeSshKey('env-1', 'nested')) + expect(nested.label).toBe('Nested box') + expect(nested.catalogState).toBe('unhydrated') + expect(nested.executionHealth).toBe('unknown') + // Unverified state must never carry a fencing owner. + expect(nested.owner).toBeNull() + }) + + it('never copies nested runtime targets into the desktop authority', () => { + const catalog = buildAutomationHostCatalog( + buildInput({ + runtimes: [ + runtime({ + ssh: mirror({ targets: [{ targetId: 'only-nested', label: 'N', generation: 2 }] }) + }) + ] + }) + ) + expect(catalog.byStableKey.has(desktopSshKey('only-nested'))).toBe(false) + expect( + catalog.entries.filter((entry) => entry.stableRef.authority.kind === 'desktop') + ).toHaveLength(1) + }) + + it('hides runtime-owned ephemeral SSH targets from both authorities', () => { + const ephemeral = `${RUNTIME_OWNED_SSH_TARGET_ID_PREFIX}vm-1` + const catalog = buildAutomationHostCatalog( + buildInput({ + desktop: { + label: 'Local Mac', + ssh: mirror({ + targets: [{ targetId: ephemeral, label: 'VM', generation: 1 }], + removedTargetLabels: new Map([[ephemeral, 'VM']]) + }) + }, + runtimes: [ + runtime({ + ssh: mirror({ targets: [{ targetId: ephemeral, label: 'VM', generation: 1 }] }) + }) + ], + referencedStableKeys: [desktopSshKey(ephemeral), runtimeSshKey('env-1', ephemeral)] + }) + ) + expect(catalog.entries.some((entry) => entry.kind === 'ssh')).toBe(false) + }) + + it('marks a tombstoned target removed and keeps its last known label', () => { + const catalog = buildAutomationHostCatalog( + buildInput({ + desktop: { + label: 'Local Mac', + ssh: mirror({ removedTargetLabels: new Map([['gone', 'Old box']]) }) + } + }) + ) + const ghost = entryAt(catalog, desktopSshKey('gone')) + expect(ghost).toMatchObject({ + label: 'Old box', + catalogState: 'removed', + executionHealth: 'unavailable', + owner: null + }) + }) + + it('treats absence as removal only once the target list hydrated', () => { + const referenced = [desktopSshKey('referenced')] + const hydrated = buildAutomationHostCatalog(buildInput({ referencedStableKeys: referenced })) + expect(entryAt(hydrated, desktopSshKey('referenced')).catalogState).toBe('removed') + + const unhydrated = buildAutomationHostCatalog( + buildInput({ + desktop: { label: 'Local Mac', ssh: mirror({ targetsHydrated: false }) }, + referencedStableKeys: referenced + }) + ) + const entry = entryAt(unhydrated, desktopSshKey('referenced')) + expect(entry.catalogState).toBe('unhydrated') + expect(entry.executionHealth).toBe('unknown') + }) + + it('keeps a legacy runtime view-only: no generations, no owners', () => { + const catalog = buildAutomationHostCatalog( + buildInput({ + runtimes: [ + runtime({ + querySupport: 'legacy-unscoped', + ssh: mirror({ targets: [{ targetId: 'legacy', label: 'Legacy box' }] }) + }) + ] + }) + ) + const nested = entryAt(catalog, runtimeSshKey('env-1', 'legacy')) + expect(nested.querySupport).toBe('legacy-unscoped') + expect(nested.owner).toBeNull() + // Self needs no target generation, so it stays owned even on an old server. + expect(entryAt(catalog, runtimeSelfKey('env-1')).owner).not.toBeNull() + }) + + it('downgrades a scoped entry whose target carries no generation', () => { + const catalog = buildAutomationHostCatalog( + buildInput({ + runtimes: [runtime({ ssh: mirror({ targets: [{ targetId: 'ungenerated', label: 'U' }] }) })] + }) + ) + const nested = entryAt(catalog, runtimeSshKey('env-1', 'ungenerated')) + expect(nested.querySupport).toBe('legacy-unscoped') + expect(nested.owner).toBeNull() + }) + + it('blames the removed target, not a server, for a desktop ghost entry', () => { + const catalog = buildAutomationHostCatalog( + buildInput({ + desktop: { + label: 'Local Mac', + ssh: mirror({ removedTargetLabels: new Map([['gone', 'Old box']]) }) + }, + referencedStableKeys: [desktopSshKey('gone')] + }) + ) + const ghost = entryAt(catalog, desktopSshKey('gone')) + expect({ catalogState: ghost.catalogState, scopeGap: ghost.scopeGap }).toEqual({ + catalogState: 'removed', + scopeGap: 'target-removed' + }) + }) + + it('marks a modern runtime unverified, not unscoped, while its SSH bucket is stale', () => { + // markEnvironmentSshStateStale keeps labels but drops generations and hydration. + const catalog = buildAutomationHostCatalog( + buildInput({ + runtimes: [ + runtime({ + querySupport: 'scoped', + ssh: mirror({ targetsHydrated: false, targets: [{ targetId: 'box', label: 'Box' }] }) + }) + ] + }) + ) + const nested = entryAt(catalog, runtimeSshKey('env-1', 'box')) + expect(nested.scopeGap).toBe('target-unverified') + expect(nested.owner).toBeNull() + }) + + it('keeps target execution health independent of authority query health', () => { + const catalog = buildAutomationHostCatalog( + buildInput({ + runtimes: [ + runtime({ + ssh: mirror({ + targets: [{ targetId: 'down', label: 'Down box', generation: 5 }], + connectionStatusByTargetId: new Map([['down', 'auth-failed']]) + }) + }) + ] + }) + ) + const nested = entryAt(catalog, runtimeSshKey('env-1', 'down')) + expect(nested.executionHealth).toBe('disconnected') + expect(nested.authorityHealth).toBe('fresh') + expect(nested.owner).not.toBeNull() + }) + + it('adds an orphan entry for a positive count and omits an empty one', () => { + expect( + buildAutomationHostCatalog(buildInput()).entries.some((entry) => entry.kind === 'orphan') + ).toBe(false) + expect( + buildAutomationHostCatalog( + buildInput({ desktop: { label: 'Local Mac', ssh: mirror(), orphanCount: 0 } }) + ).entries.some((entry) => entry.kind === 'orphan') + ).toBe(false) + + const catalog = buildAutomationHostCatalog( + buildInput({ desktop: { label: 'Local Mac', ssh: mirror(), orphanCount: 2 } }) + ) + const orphan = catalog.entries.find((entry) => entry.kind === 'orphan') + expect(orphan).toMatchObject({ + label: AUTOMATION_ORPHAN_ENTRY_LABEL, + catalogState: 'authoritative', + executionHealth: 'unavailable', + owner: null + }) + }) + + it('keeps a referenced orphan entry unhydrated until its authority answers', () => { + const key = hostStableKey({ authority: { kind: 'desktop' }, selector: { kind: 'orphan' } }) + const catalog = buildAutomationHostCatalog(buildInput({ referencedStableKeys: [key] })) + expect(entryAt(catalog, key).catalogState).toBe('unhydrated') + }) + + it('preserves the stable key and owner slot across a rename', () => { + const before = buildAutomationHostCatalog( + buildInput({ + runtimes: [ + runtime({ + ssh: mirror({ targets: [{ targetId: 't1', label: 'Old name', generation: 9 }] }) + }) + ] + }) + ) + const after = buildAutomationHostCatalog( + buildInput({ + runtimes: [ + runtime({ + label: 'Renamed server', + ssh: mirror({ targets: [{ targetId: 't1', label: 'New name', generation: 9 }] }) + }) + ] + }) + ) + const key = runtimeSshKey('env-1', 't1') + expect(entryAt(after, key).stableKey).toBe(entryAt(before, key).stableKey) + expect(ownerKey(entryAt(after, key).owner!)).toBe(ownerKey(entryAt(before, key).owner!)) + expect(entryAt(after, key).label).toBe('New name') + }) + + it('reports hydration evidence for every absence gate', () => { + const catalog = buildAutomationHostCatalog( + buildInput({ + runtimeCatalogSettled: false, + desktop: { label: 'Local Mac', ssh: mirror({ targetsHydrated: false }), orphanCount: 1 }, + runtimes: [ + runtime({ authorityHealth: 'unavailable', ssh: mirror({ targetsHydrated: false }) }), + runtime({ environmentId: 'env-2', label: 'Second' }) + ] + }) + ) + expect(catalog.hydration.runtimeCatalogSettled).toBe(false) + expect(catalog.hydration.desktopSshHydrated).toBe(false) + expect(catalog.hydration.savedRuntimeEnvironmentIds).toEqual(new Set(['env-1', 'env-2'])) + expect(catalog.hydration.runtimeSshHydratedByEnvironmentId.get('env-1')).toBe(false) + expect(catalog.hydration.runtimeSshHydratedByEnvironmentId.get('env-2')).toBe(true) + expect([...catalog.hydration.orphanSettledAuthorityKeys]).toEqual(['authority:desktop']) + expect([...catalog.hydration.unavailableAuthorityKeys]).toEqual(['authority:runtime:env-1']) + }) + + it('ignores unparseable referenced keys instead of throwing', () => { + expect(() => + buildAutomationHostCatalog(buildInput({ referencedStableKeys: ['nonsense', ''] })) + ).not.toThrow() + }) + + it('performs no network, runtime, or SSH side effects', () => { + const fetchSpy = vi.fn() + const socketSpy = vi.fn() + const globals = globalThis as Record + const previous = { fetch: globals.fetch, WebSocket: globals.WebSocket, window: globals.window } + globals.fetch = fetchSpy + globals.WebSocket = socketSpy + globals.window = new Proxy( + {}, + { + get: (_target, property) => { + throw new Error(`projection touched window.${String(property)}`) + } + } + ) + try { + const catalog = buildAutomationHostCatalog( + buildInput({ + runtimes: [ + runtime({ + ssh: mirror({ targets: [{ targetId: 't', label: 'T', generation: 1 }] }), + orphanCount: 3 + }) + ] + }) + ) + expect(catalog.entries.length).toBeGreaterThan(0) + } finally { + globals.fetch = previous.fetch + globals.WebSocket = previous.WebSocket + globals.window = previous.window + } + expect(fetchSpy).not.toHaveBeenCalled() + expect(socketSpy).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-catalog.ts b/src/renderer/src/components/automations/automation-host-catalog.ts new file mode 100644 index 00000000000..71e445e546c --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-catalog.ts @@ -0,0 +1,303 @@ +import { hostStableKey, parseHostStableKey } from '../../../../shared/automation-owner-key' +import type { + AutomationOwnerRef, + StableAutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host' +import { + automationAuthorityCatalogKey, + type AutomationCatalogSshMirrorInput, + type AutomationHostCatalog, + type AutomationHostCatalogEntry, + type AutomationHostCatalogInput, + type AutomationHostCatalogState, + type AutomationHostQuerySupport, + type AutomationHostScopeGap, + type AutomationAuthorityHealth +} from './automation-host-catalog-types' +import { resolveSelfExecutionHealth, resolveSshExecutionHealth } from './automation-host-health' +import { orderAutomationHostCatalogEntries } from './automation-host-catalog-order' + +/** + * Purely projective host catalog for the Automations page. + * + * It reads state that is already mirrored on this client and returns entries. + * It never connects to a runtime, lists targets, or opens an SSH session — + * rendering the picker must cost nothing. Capability support and authority + * health are *inputs*, not probes. + */ + +export const AUTOMATION_ORPHAN_ENTRY_LABEL = 'Unassigned legacy automations' + +type ReferencedAuthorityScope = { targetIds: Set; orphan: boolean } + +type AuthorityProjectionContext = { + authority: StableAutomationAuthorityRef + authorityLabel: string + authorityHealth: AutomationAuthorityHealth + querySupport: AutomationHostQuerySupport + /** Null for the desktop authority, which has no pairing incarnation. */ + pairingRevision: number | null + ssh: AutomationCatalogSshMirrorInput + referenced: ReferencedAuthorityScope + orphanCount: number | undefined +} + +const EMPTY_SCOPE: ReferencedAuthorityScope = { targetIds: new Set(), orphan: false } + +function groupReferencedKeys( + keys: Iterable | undefined +): ReadonlyMap { + const grouped = new Map() + for (const key of keys ?? []) { + const ref = parseHostStableKey(key) + if (!ref) { + continue + } + const authorityKey = automationAuthorityCatalogKey(ref.authority) + let scope = grouped.get(authorityKey) + if (!scope) { + scope = { targetIds: new Set(), orphan: false } + grouped.set(authorityKey, scope) + } + if (ref.selector.kind === 'ssh') { + scope.targetIds.add(ref.selector.targetId) + } else if (ref.selector.kind === 'orphan') { + scope.orphan = true + } + } + return grouped +} + +function toOwner( + ctx: AuthorityProjectionContext, + selector: AutomationOwnerRef['selector'] +): AutomationOwnerRef { + return { + authority: + ctx.pairingRevision === null || ctx.authority.kind === 'desktop' + ? { kind: 'desktop' } + : { + kind: 'runtime', + environmentId: ctx.authority.environmentId, + pairingRevision: ctx.pairingRevision + }, + selector + } +} + +function makeEntry( + stableRef: StableAutomationCatalogRef, + entry: Omit +): AutomationHostCatalogEntry { + return { ...entry, stableRef, stableKey: hostStableKey(stableRef) } +} + +/** Omitted rather than passed as undefined: absence is what marks the entry fully scoped. */ +function withScopeGap(scopeGap: AutomationHostScopeGap | undefined): { + scopeGap?: AutomationHostScopeGap +} { + return scopeGap === undefined ? {} : { scopeGap } +} + +/** The authority's own contract; the only gap an entry with no target can have. */ +function authorityScopeGap( + querySupport: AutomationHostQuerySupport +): AutomationHostScopeGap | undefined { + return querySupport === 'legacy-unscoped' ? 'authority-unscoped' : undefined +} + +function projectSelfEntry(ctx: AuthorityProjectionContext): AutomationHostCatalogEntry { + return makeEntry( + { authority: ctx.authority, selector: { kind: 'self' } }, + { + owner: toOwner(ctx, { kind: 'self' }), + label: ctx.authorityLabel, + authorityLabel: ctx.authorityLabel, + kind: 'self', + catalogState: 'authoritative', + authorityHealth: ctx.authorityHealth, + executionHealth: resolveSelfExecutionHealth(ctx.authorityHealth), + querySupport: ctx.querySupport, + ...withScopeGap(authorityScopeGap(ctx.querySupport)) + } + ) +} + +function resolveSshCatalogState( + isLive: boolean, + isTombstoned: boolean, + targetsHydrated: boolean +): AutomationHostCatalogState { + if (isLive) { + return targetsHydrated ? 'authoritative' : 'unhydrated' + } + // Why: a tombstone is positive evidence recorded by the owning authority; a + // bare absence only counts once that authority's target list actually loaded. + return isTombstoned || targetsHydrated ? 'removed' : 'unhydrated' +} + +/** + * A missing generation has four unrelated causes and only one of them is the + * server's contract. Reading them all as "old server" is exactly what + * `automation-host-catalog-generation.ts` warns callers against. + */ +function resolveSshScopeGap( + querySupport: AutomationHostQuerySupport, + catalogState: AutomationHostCatalogState, + generation: number | undefined +): AutomationHostScopeGap | undefined { + if (querySupport !== 'scoped') { + return authorityScopeGap(querySupport) + } + if (catalogState === 'removed') { + return 'target-removed' + } + // Checked before the generation: a stale bucket drops generations on a mere disconnect. + if (catalogState === 'unhydrated') { + return 'target-unverified' + } + return generation === undefined ? 'target-unregistered' : undefined +} + +function projectSshEntries(ctx: AuthorityProjectionContext): AutomationHostCatalogEntry[] { + const liveTargets = new Map( + ctx.ssh.targets + .filter((target) => !isRuntimeOwnedSshTargetId(target.targetId)) + .map((target) => [target.targetId, target]) + ) + const candidateIds = new Set(liveTargets.keys()) + for (const targetId of ctx.ssh.removedTargetLabels.keys()) { + candidateIds.add(targetId) + } + for (const targetId of ctx.referenced.targetIds) { + candidateIds.add(targetId) + } + const entries: AutomationHostCatalogEntry[] = [] + for (const targetId of candidateIds) { + if (isRuntimeOwnedSshTargetId(targetId)) { + continue + } + const live = liveTargets.get(targetId) + const catalogState = resolveSshCatalogState( + live !== undefined, + ctx.ssh.removedTargetLabels.has(targetId), + ctx.ssh.targetsHydrated + ) + const generation = live?.generation + const scoped = ctx.querySupport === 'scoped' && generation !== undefined + entries.push( + makeEntry( + { authority: ctx.authority, selector: { kind: 'ssh', targetId } }, + { + owner: + scoped && catalogState === 'authoritative' && generation !== undefined + ? toOwner(ctx, { kind: 'ssh', targetId, targetGeneration: generation }) + : null, + label: live?.label ?? ctx.ssh.removedTargetLabels.get(targetId) ?? targetId, + authorityLabel: ctx.authorityLabel, + kind: 'ssh', + catalogState, + authorityHealth: ctx.authorityHealth, + executionHealth: resolveSshExecutionHealth( + catalogState, + ctx.ssh.connectionStatusByTargetId.get(targetId), + ctx.ssh.missingConnectionStatus + ), + // Why: without a registration generation the entry keys on target id alone, so it stays view-only. + querySupport: + ctx.querySupport === 'scoped' && !scoped ? 'legacy-unscoped' : ctx.querySupport, + ...withScopeGap(resolveSshScopeGap(ctx.querySupport, catalogState, generation)) + } + ) + ) + } + return entries +} + +function projectOrphanEntry(ctx: AuthorityProjectionContext): AutomationHostCatalogEntry | null { + const orphanCount = ctx.orphanCount + const settled = orphanCount !== undefined + if (!ctx.referenced.orphan && !(orphanCount !== undefined && orphanCount > 0)) { + return null + } + return makeEntry( + { authority: ctx.authority, selector: { kind: 'orphan' } }, + { + owner: null, + label: AUTOMATION_ORPHAN_ENTRY_LABEL, + authorityLabel: ctx.authorityLabel, + kind: 'orphan', + catalogState: settled ? 'authoritative' : 'unhydrated', + authorityHealth: ctx.authorityHealth, + executionHealth: 'unavailable', + querySupport: ctx.querySupport, + ...withScopeGap(authorityScopeGap(ctx.querySupport)) + } + ) +} + +function projectAuthority(ctx: AuthorityProjectionContext): AutomationHostCatalogEntry[] { + const orphan = projectOrphanEntry(ctx) + return [projectSelfEntry(ctx), ...projectSshEntries(ctx), ...(orphan ? [orphan] : [])] +} + +export function buildAutomationHostCatalog( + input: AutomationHostCatalogInput +): AutomationHostCatalog { + const referenced = groupReferencedKeys(input.referencedStableKeys) + const desktopAuthority: StableAutomationAuthorityRef = { kind: 'desktop' } + const contexts: AuthorityProjectionContext[] = [ + { + authority: desktopAuthority, + authorityLabel: input.desktop.label, + authorityHealth: input.desktop.authorityHealth ?? 'fresh', + querySupport: 'scoped', + pairingRevision: null, + ssh: input.desktop.ssh, + referenced: referenced.get(automationAuthorityCatalogKey(desktopAuthority)) ?? EMPTY_SCOPE, + orphanCount: input.desktop.orphanCount + }, + ...input.runtimes.map((runtime): AuthorityProjectionContext => { + const authority: StableAutomationAuthorityRef = { + kind: 'runtime', + environmentId: runtime.environmentId + } + return { + authority, + authorityLabel: runtime.label, + authorityHealth: runtime.authorityHealth, + querySupport: runtime.querySupport, + pairingRevision: runtime.pairingRevision, + ssh: runtime.ssh, + referenced: referenced.get(automationAuthorityCatalogKey(authority)) ?? EMPTY_SCOPE, + orphanCount: runtime.orphanCount + } + }) + ] + const entries = orderAutomationHostCatalogEntries(contexts.flatMap(projectAuthority)) + const byStableKey = new Map(entries.map((entry) => [entry.stableKey, entry])) + return { + entries, + byStableKey, + hydration: { + runtimeCatalogSettled: input.runtimeCatalogSettled, + desktopSshHydrated: input.desktop.ssh.targetsHydrated, + runtimeSshHydratedByEnvironmentId: new Map( + input.runtimes.map((runtime) => [runtime.environmentId, runtime.ssh.targetsHydrated]) + ), + savedRuntimeEnvironmentIds: new Set(input.runtimes.map((runtime) => runtime.environmentId)), + orphanSettledAuthorityKeys: new Set( + contexts + .filter((ctx) => ctx.orphanCount !== undefined) + .map((ctx) => automationAuthorityCatalogKey(ctx.authority)) + ), + unavailableAuthorityKeys: new Set( + contexts + .filter((ctx) => ctx.authorityHealth === 'unavailable') + .map((ctx) => automationAuthorityCatalogKey(ctx.authority)) + ) + } + } +} diff --git a/src/renderer/src/components/automations/automation-host-client.test.ts b/src/renderer/src/components/automations/automation-host-client.test.ts index 9f501a1ff97..266fbe62f92 100644 --- a/src/renderer/src/components/automations/automation-host-client.test.ts +++ b/src/renderer/src/components/automations/automation-host-client.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import type { Automation, AutomationCreateInput } from '../../../../shared/automations-types' import { createAutomationForTarget, - getAutomationListTarget, + listAutomationRunsForTarget, listAutomationsForTarget, runAutomationNowForTarget, updateAutomationForTarget @@ -68,11 +68,13 @@ describe('automation host client', () => { vi.clearAllMocks() }) - it('lists automations from the active remote server when one is selected', async () => { + it('lists automations from the host it was handed, which the caller must state', async () => { vi.mocked(callRuntimeRpc).mockResolvedValueOnce({ automations: [makeAutomation()] }) - const target = getAutomationListTarget({ activeRuntimeEnvironmentId: 'gpu' }) - const automations = await listAutomationsForTarget(target) + const automations = await listAutomationsForTarget({ + kind: 'environment', + environmentId: 'gpu' + }) expect(automations).toHaveLength(1) expect(mockApi.automations.list).not.toHaveBeenCalled() @@ -84,45 +86,40 @@ describe('automation host client', () => { ) }) - it('creates and manually runs runtime-host automations through that server', async () => { - const automation = makeAutomation() - const input: AutomationCreateInput = { - name: automation.name, - prompt: automation.prompt, - precheck: null, - agentId: automation.agentId, - runContext: automation.runContext, - projectId: automation.projectId, - workspaceMode: automation.workspaceMode, - workspaceId: null, - setupDecision: 'run', - timezone: automation.timezone, - rrule: automation.rrule, - dtstart: automation.dtstart - } + // The broad form is gone in both directions: usage totals come from the + // authority's list projection, so nothing may ask a host for all of its runs. + it('fetches one automation history at a time, on both the desktop and a runtime', async () => { vi.mocked(callRuntimeRpc) - .mockResolvedValueOnce({ automation }) - .mockResolvedValueOnce({ run: { id: 'run-1', automationId: automation.id } }) + .mockResolvedValueOnce({ runs: [] }) + .mockResolvedValueOnce({ runs: [] }) - await createAutomationForTarget(input) - await runAutomationNowForTarget(automation) + await listAutomationRunsForTarget({ kind: 'environment', environmentId: 'gpu' }, 'auto-1') + await listAutomationRunsForTarget({ kind: 'local' }, 'auto-1') - expect(mockApi.automations.create).not.toHaveBeenCalled() - expect(mockApi.automations.runNow).not.toHaveBeenCalled() - expect(callRuntimeRpc).toHaveBeenNthCalledWith( - 1, + expect(callRuntimeRpc).toHaveBeenCalledWith( { kind: 'environment', environmentId: 'gpu' }, - 'automation.create', - expect.objectContaining({ - repo: 'id:repo-1', - workspace: undefined, - setupDecision: 'run', - runContext: automation.runContext - }), + 'automation.runs', + { automationId: 'auto-1' }, { timeoutMs: 15_000 } ) - expect(callRuntimeRpc).toHaveBeenNthCalledWith( - 2, + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'local' }, + 'automation.runs', + { automationId: 'auto-1' }, + { timeoutMs: 15_000 } + ) + }) + + it('manually runs runtime-host automations through that server', async () => { + const automation = makeAutomation() + vi.mocked(callRuntimeRpc).mockResolvedValueOnce({ + run: { id: 'run-1', automationId: automation.id } + }) + + await runAutomationNowForTarget(automation) + + expect(mockApi.automations.runNow).not.toHaveBeenCalled() + expect(callRuntimeRpc).toHaveBeenCalledWith( { kind: 'environment', environmentId: 'gpu' }, 'automation.runNow', { id: automation.id }, diff --git a/src/renderer/src/components/automations/automation-host-client.ts b/src/renderer/src/components/automations/automation-host-client.ts index 6f3c967ba28..79ef3c65436 100644 --- a/src/renderer/src/components/automations/automation-host-client.ts +++ b/src/renderer/src/components/automations/automation-host-client.ts @@ -81,8 +81,8 @@ function toRuntimeAutomationCreateInput( workspace: input.workspaceMode === 'existing' && workspaceId ? `id:${workspaceId}` : undefined } } - -function toRuntimeAutomationUpdateInput( +/** Renames the desktop input's target fields to the wire contract every authority speaks. */ +export function toRuntimeAutomationUpdateInput( input: AutomationUpdateInput ): RuntimeAutomationUpdateInput { const { projectId, workspaceId, ...rest } = input @@ -98,9 +98,6 @@ function toRuntimeAutomationUpdateInput( export async function listAutomationsForTarget( target: AutomationHostTarget ): Promise { - if (target.kind === 'local') { - return await window.api.automations.list() - } const result = await callRuntimeRpc<{ automations: Automation[] }>( target, 'automation.list', @@ -110,17 +107,19 @@ export async function listAutomationsForTarget( return result.automations } +/** + * One automation's history, never a host's. Usage totals for the list come from + * the authority's own list projection; fetching every run to compute them made + * the page's cost scale with retained history rather than with what is on screen. + */ export async function listAutomationRunsForTarget( target: AutomationHostTarget, - automationId?: string + automationId: string ): Promise { - if (target.kind === 'local') { - return await window.api.automations.listRuns(automationId ? { automationId } : undefined) - } const result = await callRuntimeRpc<{ runs: AutomationRun[] }>( target, 'automation.runs', - automationId ? { automationId } : {}, + { automationId }, { timeoutMs: 15_000 } ) return result.runs @@ -146,9 +145,6 @@ export async function updateAutomationForTarget( sourceTarget?: AutomationHostTarget | null ): Promise { const target = getAutomationOwnerTarget(automation, sourceTarget) - if (target.kind === 'local') { - return await window.api.automations.update({ id: automation.id, updates }) - } const result = await callRuntimeRpc<{ automation: Automation }>( target, 'automation.update', @@ -163,10 +159,6 @@ export async function deleteAutomationForTarget( sourceTarget?: AutomationHostTarget | null ): Promise { const target = getAutomationOwnerTarget(automation, sourceTarget) - if (target.kind === 'local') { - await window.api.automations.delete({ id: automation.id }) - return - } await callRuntimeRpc(target, 'automation.delete', { id: automation.id }, { timeoutMs: 15_000 }) } @@ -175,9 +167,6 @@ export async function runAutomationNowForTarget( sourceTarget?: AutomationHostTarget | null ): Promise { const target = getAutomationOwnerTarget(automation, sourceTarget) - if (target.kind === 'local') { - return await window.api.automations.runNow({ id: automation.id }) - } const result = await callRuntimeRpc<{ run: AutomationRun }>( target, 'automation.runNow', diff --git a/src/renderer/src/components/automations/automation-host-diagnostics.test.ts b/src/renderer/src/components/automations/automation-host-diagnostics.test.ts new file mode 100644 index 00000000000..cc49fee491c --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-diagnostics.test.ts @@ -0,0 +1,617 @@ +/** + * The counters are only worth having if they are driven by the real cache and + * scheduler, so these run the instrumentation through both. The design doc's + * release gate is stated over exactly these numbers: calls per authority, one + * legacy call per authority per cycle, and stale responses that never landed. + * + * The distinction these tests exist to hold down is request versus entry. A + * legacy answer is one request serving many hosts, so a counter that increments + * per entry would fail the "one call per authority" gate for a system behaving + * exactly as designed, and would inflate one stale answer into a dozen. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import type { AutomationListScopeSelector } from '../../../../shared/automation-list-scope' +import { automationAuthorityCatalogKey } from './automation-host-catalog-types' +import { hostStableKey } from '../../../../shared/automation-owner-key' +import { AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { + AutomationAuthorityRef, + StableAutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import { createAutomationHostCache, type AutomationHostCache } from './automation-host-cache' +import type { AutomationHostQuerySupport } from './automation-host-catalog-types' +import { + automationHostDiagnostics, + createAutomationHostDiagnostics, + type AutomationHostDiagnostics +} from './automation-host-diagnostics' +import { + createAutomationHostScheduler, + type AutomationHostFetchTarget, + type AutomationHostSchedulerOptions +} from './automation-host-scheduler' +import type { ScopedAutomationList } from './automation-scoped-list-client' + +const callRuntimeRpc = vi.fn() +const getRuntimeEnvironmentStatus = vi.fn() + +// Only the capability-probe suite reaches the real list client; every other test +// injects a transport, so this mock stands in for a paired host that answers. +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: (...args: unknown[]) => callRuntimeRpc(...args), + getRuntimeEnvironmentStatus: (...args: unknown[]) => getRuntimeEnvironmentStatus(...args), + hasRuntimeRpcErrorCode: () => false +})) + +const RUNTIME_AUTHORITY: AutomationAuthorityRef = { + kind: 'runtime', + environmentId: 'env-1', + pairingRevision: 4 +} +const OTHER_AUTHORITY: AutomationAuthorityRef = { + kind: 'runtime', + environmentId: 'env-2', + pairingRevision: 1 +} +const LEGACY = { querySupport: 'legacy-unscoped' as const } + +function stableAuthority(authority: AutomationAuthorityRef): StableAutomationAuthorityRef { + return authority.kind === 'desktop' + ? { kind: 'desktop' } + : { kind: 'runtime', environmentId: authority.environmentId } +} + +function selfRef(authority: AutomationAuthorityRef): StableAutomationCatalogRef { + return { authority: stableAuthority(authority), selector: { kind: 'self' } } +} + +function sshRef(authority: AutomationAuthorityRef, targetId: string): StableAutomationCatalogRef { + return { + authority: stableAuthority(authority), + selector: { kind: 'ssh', targetId } + } +} + +function orphanRef(authority: AutomationAuthorityRef): StableAutomationCatalogRef { + return { authority: stableAuthority(authority), selector: { kind: 'orphan' } } +} + +function authorityKeyOf(authority: AutomationAuthorityRef): string { + return automationAuthorityCatalogKey(stableAuthority(authority)) +} + +function target( + ref: StableAutomationCatalogRef, + options: { + authority?: AutomationAuthorityRef + querySupport?: AutomationHostQuerySupport + } = {} +): AutomationHostFetchTarget { + const authority = options.authority ?? RUNTIME_AUTHORITY + return { + ref, + authority, + owner: + ref.selector.kind === 'ssh' + ? { + authority, + selector: { + kind: 'ssh', + targetId: ref.selector.targetId, + targetGeneration: 2 + } + } + : { authority, selector: { kind: 'self' } }, + querySupport: options.querySupport ?? 'scoped' + } +} + +function automation(id: string, overrides: Partial = {}): Automation { + return { + id, + name: id, + projectId: 'repo-1', + executionTargetType: 'local', + ...overrides + } as Automation +} + +function scopedResult(ids: readonly string[]): ScopedAutomationList { + return { + automations: ids.map((id) => automation(id)), + items: ids.map((id) => ({ + automationId: id, + selector: { kind: 'self' as const } + })), + orphanCount: 0, + invalidRows: 0 + } +} + +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +const LOCAL_REPO = { repoConnectionId: () => null, projectsAuthoritative: true } + +function harness(): { + cache: AutomationHostCache + diagnostics: AutomationHostDiagnostics + schedule: ( + overrides: Partial + ) => ReturnType +} { + const diagnostics = createAutomationHostDiagnostics() + const cache = createAutomationHostCache({ + catalogGeneration: () => 0, + connectionGeneration: () => 0 + }) + return { + cache, + diagnostics, + schedule: (overrides) => + createAutomationHostScheduler({ + cache, + diagnostics, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + // A retry that never runs: these assert what one cycle cost, and a live + // timer would re-enter the counters after the test read them. + scheduleRetry: () => () => {}, + ...overrides + }) + } +} + +describe('automation host request counters', () => { + it('attributes each scoped call to both its authority and its stable key', async () => { + const { diagnostics, schedule } = harness() + const scheduler = schedule({ + transport: { listScoped: vi.fn(() => Promise.resolve(scopedResult(['a', 'b']))) } + }) + + await scheduler.refresh([ + target(selfRef(RUNTIME_AUTHORITY)), + target(sshRef(RUNTIME_AUTHORITY, 'target-1')), + target(selfRef(OTHER_AUTHORITY), { authority: OTHER_AUTHORITY }) + ]) + + const snapshot = diagnostics.snapshot() + expect(snapshot.totals.requests).toBe(3) + expect(snapshot.totals.scopedRequests).toBe(3) + expect(snapshot.byAuthority[authorityKeyOf(RUNTIME_AUTHORITY)].requests).toBe(2) + expect(snapshot.byAuthority[authorityKeyOf(OTHER_AUTHORITY)].requests).toBe(1) + expect(snapshot.byStableKey[hostStableKey(selfRef(RUNTIME_AUTHORITY))].requests).toBe(1) + }) + + // The doc's old-server budget in one number: three entries on one authority + // must still cost one call, and it must be attributable to that authority. + it('counts one legacy call per authority per cycle, not one per entry', async () => { + const { diagnostics, schedule } = harness() + const listLegacy = vi.fn(() => Promise.resolve([automation('self-1')])) + const scheduler = schedule({ transport: { listLegacy } }) + + const refs = [ + selfRef(RUNTIME_AUTHORITY), + sshRef(RUNTIME_AUTHORITY, 'target-1'), + orphanRef(RUNTIME_AUTHORITY) + ] + await scheduler.refresh(refs.map((ref) => target(ref, LEGACY))) + + const snapshot = diagnostics.snapshot() + expect(listLegacy).toHaveBeenCalledTimes(1) + expect(snapshot.byAuthority[authorityKeyOf(RUNTIME_AUTHORITY)].legacyRequests).toBe(1) + // A legacy answer belongs to no single entry, so no key may claim the call + // even though each key is credited with the rows it took from that answer. + for (const ref of refs) { + expect(snapshot.byStableKey[hostStableKey(ref)]).toMatchObject({ + requests: 0, + legacyRequests: 0, + responses: 0 + }) + } + }) + + it('records a dedupe hit, not a request, when a caller joins one in flight', async () => { + const { diagnostics, schedule } = harness() + const gate = deferred() + const listScoped = vi.fn(() => gate.promise) + const scheduler = schedule({ transport: { listScoped } }) + + const first = scheduler.refresh([target(selfRef(RUNTIME_AUTHORITY))]) + await Promise.resolve() + const second = scheduler.refresh([target(selfRef(RUNTIME_AUTHORITY))]) + gate.resolve(scopedResult(['a'])) + await Promise.all([first, second]) + + const key = hostStableKey(selfRef(RUNTIME_AUTHORITY)) + expect(listScoped).toHaveBeenCalledTimes(1) + expect(diagnostics.snapshot().byStableKey[key]).toMatchObject({ + requests: 1, + dedupeHits: 1 + }) + }) + + // A legacy request is unkeyed, so its dedupe hits must be too — otherwise a + // legacy host reads 0 requests against N hits and no per-key invariant holds. + it('keys a legacy dedupe hit the same way it keys the legacy request', async () => { + const { diagnostics, schedule } = harness() + const gate = deferred() + const scheduler = schedule({ transport: { listLegacy: vi.fn(() => gate.promise) } }) + + const first = scheduler.refresh([ + target(selfRef(RUNTIME_AUTHORITY), LEGACY), + target(sshRef(RUNTIME_AUTHORITY, 'target-1'), LEGACY) + ]) + await Promise.resolve() + const second = scheduler.refresh([target(selfRef(RUNTIME_AUTHORITY), LEGACY)]) + gate.resolve([automation('a')]) + await Promise.all([first, second]) + + const snapshot = diagnostics.snapshot() + const authority = snapshot.byAuthority[authorityKeyOf(RUNTIME_AUTHORITY)] + expect(authority).toMatchObject({ legacyRequests: 1, dedupeHits: 1 }) + expect(snapshot.byStableKey[hostStableKey(selfRef(RUNTIME_AUTHORITY))].dedupeHits).toBe(0) + }) + + it('counts one hit when the same host appears twice in one refresh', async () => { + const { diagnostics, schedule } = harness() + const gate = deferred() + const scheduler = schedule({ transport: { listScoped: vi.fn(() => gate.promise) } }) + + const ref = selfRef(RUNTIME_AUTHORITY) + const first = scheduler.refresh([target(ref)]) + await Promise.resolve() + // plan() collapses the duplicate into one entry; both share one promise. + const second = scheduler.refresh([target(ref), target(ref)]) + gate.resolve(scopedResult(['a'])) + await Promise.all([first, second]) + + expect(diagnostics.snapshot().byStableKey[hostStableKey(ref)].dedupeHits).toBe(1) + }) + + it('never records a request for a host it refuses to call', async () => { + const { diagnostics, schedule } = harness() + const listScoped = vi.fn() + const scheduler = schedule({ transport: { listScoped } }) + + await scheduler.refresh([target(selfRef(RUNTIME_AUTHORITY), { querySupport: 'incompatible' })]) + + expect(listScoped).not.toHaveBeenCalled() + // A local refusal is not wire traffic, and its failure is not a discard. + expect(diagnostics.snapshot().totals).toMatchObject({ + requests: 0, + failures: 0, + discardedFailures: 0, + discardedEntries: 0 + }) + }) + + // The pool drops queued work the catalog has made obsolete, so counting at + // submission would bill a host switch for calls that were never sent. + it('does not count a queued request that was cancelled before it was sent', async () => { + const { diagnostics, schedule } = harness() + const gate = deferred() + const listScoped = vi.fn(() => gate.promise) + const scheduler = schedule({ concurrency: 1, transport: { listScoped } }) + + const pending = scheduler.refresh([ + target(selfRef(RUNTIME_AUTHORITY)), + target(sshRef(RUNTIME_AUTHORITY, 'target-1')) + ]) + await Promise.resolve() + scheduler.cancelQueued() + gate.resolve(scopedResult(['a'])) + await pending + + expect(listScoped).toHaveBeenCalledTimes(1) + expect(diagnostics.snapshot().totals.requests).toBe(1) + }) + + // A cancelled job leaves nothing to join, so the refresh that follows is a new + // request — counting it as a dedupe hit would credit a call never sent. + it('does not count a dedupe hit against a cancelled queued request', async () => { + const { diagnostics, schedule } = harness() + const gate = deferred() + const listScoped = vi.fn(() => gate.promise) + const scheduler = schedule({ concurrency: 1, transport: { listScoped } }) + + const cancelled = sshRef(RUNTIME_AUTHORITY, 'target-1') + const pending = scheduler.refresh([target(selfRef(RUNTIME_AUTHORITY)), target(cancelled)]) + await Promise.resolve() + scheduler.cancelQueued() + gate.resolve(scopedResult(['a'])) + await pending + + await scheduler.refresh([target(cancelled)]) + expect(diagnostics.snapshot().byStableKey[hostStableKey(cancelled)]).toMatchObject({ + dedupeHits: 0, + requests: 1 + }) + }) +}) + +describe('unpooled capability probes', () => { + beforeEach(() => { + callRuntimeRpc.mockReset() + getRuntimeEnvironmentStatus.mockReset() + automationHostDiagnostics.reset() + }) + + // The probe rides outside the four-slot pool and always re-fetches, so an + // instrument blind to it would report half the relay traffic a refresh costs. + it('counts the status.get probe every runtime-scoped list rides on', async () => { + const { cache } = harness() + getRuntimeEnvironmentStatus.mockResolvedValue({ + capabilities: [AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY] + }) + callRuntimeRpc.mockImplementation((_target: unknown, _method: unknown, params: unknown) => { + const selector = (params as { selector: AutomationListScopeSelector }).selector + return Promise.resolve({ + automations: [{ id: 'a' }], + items: [{ automationId: 'a', selector }], + orphanCount: 0 + }) + }) + // No injected transport: this is the one suite that drives the real client. + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true + }) + + await scheduler.refresh([ + target(selfRef(RUNTIME_AUTHORITY)), + target(sshRef(RUNTIME_AUTHORITY, 'target-1')) + ]) + + const authority = + automationHostDiagnostics.snapshot().byAuthority[authorityKeyOf(RUNTIME_AUTHORITY)] + expect(getRuntimeEnvironmentStatus).toHaveBeenCalledTimes(2) + // Two pooled list calls, and two round trips the pool never sees. + expect(authority).toMatchObject({ requests: 2, capabilityProbes: 2 }) + }) +}) + +describe('automation host stale-response counters', () => { + it('counts a commit the fence threw away, and still counts the rows it cost', async () => { + const { cache, diagnostics, schedule } = harness() + const gate = deferred() + const scheduler = schedule({ transport: { listScoped: vi.fn(() => gate.promise) } }) + + const ref = selfRef(RUNTIME_AUTHORITY) + const pending = scheduler.refresh([target(ref)]) + await Promise.resolve() + // The user left this host while its answer was still on the wire. + cache.invalidate(ref) + gate.resolve(scopedResult(['a', 'b'])) + await pending + + const counters = diagnostics.snapshot().byStableKey[hostStableKey(ref)] + expect(cache.get(ref)?.data).toEqual([]) + expect(counters).toMatchObject({ discardedCommits: 1, discardedEntries: 1 }) + // Recorded even though nothing landed: the wire cost was paid regardless. + expect(counters.rows).toBe(2) + }) + + it('separates a discarded failure from a discarded commit', async () => { + const { cache, diagnostics, schedule } = harness() + const gate = deferred() + const scheduler = schedule({ transport: { listScoped: vi.fn(() => gate.promise) } }) + + const ref = selfRef(RUNTIME_AUTHORITY) + const pending = scheduler.refresh([target(ref)]) + await Promise.resolve() + cache.invalidate(ref) + gate.reject(new Error('host went away')) + await pending + + expect(diagnostics.snapshot().byStableKey[hostStableKey(ref)]).toMatchObject({ + discardedCommits: 0, + discardedFailures: 1, + // The attempt still happened and still took time, fence or no fence. + failures: 1 + }) + }) + + // One stale legacy answer is one discarded request over many discarded + // entries. Counting it per entry would report twelve stale responses for a + // twelve-host authority that received exactly one. + it('counts a stale legacy answer once as a request and once per entry', async () => { + const { cache, diagnostics, schedule } = harness() + const gate = deferred() + const scheduler = schedule({ transport: { listLegacy: vi.fn(() => gate.promise) } }) + + const refs = [ + selfRef(RUNTIME_AUTHORITY), + sshRef(RUNTIME_AUTHORITY, 'target-1'), + orphanRef(RUNTIME_AUTHORITY) + ] + const pending = scheduler.refresh(refs.map((ref) => target(ref, LEGACY))) + await Promise.resolve() + // The whole authority re-paired while its one answer was in flight. + cache.invalidateAuthority(stableAuthority(RUNTIME_AUTHORITY)) + gate.resolve([automation('a')]) + await pending + + expect(diagnostics.snapshot().byAuthority[authorityKeyOf(RUNTIME_AUTHORITY)]).toMatchObject({ + responses: 1, + discardedCommits: 1, + discardedEntries: 3 + }) + }) + + // The gate `discardedCommits === 0` has to stay assertable for a system that + // is behaving: an answer that landed somewhere was not thrown away. + it('does not call a legacy answer discarded when one of its entries landed', async () => { + const { cache, diagnostics, schedule } = harness() + const gate = deferred() + const scheduler = schedule({ transport: { listLegacy: vi.fn(() => gate.promise) } }) + + const stale = sshRef(RUNTIME_AUTHORITY, 'target-1') + const pending = scheduler.refresh([ + target(selfRef(RUNTIME_AUTHORITY), LEGACY), + target(stale, LEGACY) + ]) + await Promise.resolve() + cache.invalidate(stale) + gate.resolve([automation('a')]) + await pending + + expect(diagnostics.snapshot().byAuthority[authorityKeyOf(RUNTIME_AUTHORITY)]).toMatchObject({ + discardedCommits: 0, + discardedEntries: 1 + }) + }) +}) + +describe('automation host response measurements', () => { + it('records row counts and refresh duration per response', async () => { + const { diagnostics, schedule } = harness() + const ticks = [100, 145, 200, 320] + let tick = 0 + const scheduler = schedule({ + elapsed: () => ticks[Math.min(tick++, ticks.length - 1)], + concurrency: 1, + transport: { listScoped: vi.fn(() => Promise.resolve(scopedResult(['a', 'b', 'c']))) } + }) + + await scheduler.refresh([ + target(selfRef(RUNTIME_AUTHORITY)), + target(sshRef(RUNTIME_AUTHORITY, 'target-1')) + ]) + + const totals = diagnostics.snapshot().totals + expect(totals.responses).toBe(2) + expect(totals.rows).toBe(6) + expect(totals.durationMsTotal).toBe(45 + 120) + expect(totals.durationMsMax).toBe(120) + }) + + // A host that times out is the slowest thing a refresh does. Recording + // duration only on success hides exactly the requests worth finding. + it('records the duration of a request that failed', async () => { + const { diagnostics, schedule } = harness() + const ticks = [100, 175] + let tick = 0 + const scheduler = schedule({ + elapsed: () => ticks[Math.min(tick++, ticks.length - 1)], + concurrency: 1, + transport: { listScoped: vi.fn(() => Promise.reject(new Error('timed out'))) } + }) + + await scheduler.refresh([target(selfRef(RUNTIME_AUTHORITY))]) + + expect(diagnostics.snapshot().totals).toMatchObject({ + responses: 0, + failures: 1, + durationMsTotal: 75, + durationMsMax: 75 + }) + }) + + // Doc 416 asks for row counts per host, and the legacy fan-out is where they + // would otherwise be lost — precisely where the payload is largest. + it('attributes legacy rows to each host, not only to the authority', async () => { + const { diagnostics, schedule } = harness() + const scheduler = schedule({ + transport: { + listLegacy: vi.fn(() => + Promise.resolve([automation('a'), automation('b'), automation('c')]) + ) + } + }) + + const self = selfRef(RUNTIME_AUTHORITY) + const ssh = sshRef(RUNTIME_AUTHORITY, 'target-1') + await scheduler.refresh([target(self, LEGACY), target(ssh, LEGACY)]) + + const snapshot = diagnostics.snapshot() + // The authority's number is what the response carried... + expect(snapshot.byAuthority[authorityKeyOf(RUNTIME_AUTHORITY)].rows).toBe(3) + // ...and each key's is what that host took from it, request or no request. + expect(snapshot.byStableKey[hostStableKey(self)]).toMatchObject({ rows: 3, requests: 0 }) + expect(snapshot.byStableKey[hostStableKey(ssh)].rows).toBe(0) + }) + + // Sizing re-serializes a payload the transport already decoded, so it stays + // off unless someone is profiling. The flag keeps an unmeasured zero from + // reading as a real one. + it('leaves response size unmeasured until measurement is turned on', async () => { + const { diagnostics, schedule } = harness() + const scheduler = schedule({ + transport: { listScoped: vi.fn(() => Promise.resolve(scopedResult(['a']))) } + }) + + await scheduler.refresh([target(selfRef(RUNTIME_AUTHORITY))]) + expect(diagnostics.snapshot()).toMatchObject({ + serializedCharsMeasured: false, + totals: { approxSerializedChars: 0 } + }) + + diagnostics.setMeasureSerializedChars(true) + await scheduler.refresh([target(sshRef(RUNTIME_AUTHORITY, 'target-1'))]) + const measured = diagnostics.snapshot() + expect(measured.serializedCharsMeasured).toBe(true) + expect(measured.totals.approxSerializedChars).toBeGreaterThan(0) + }) +}) + +describe('automation host diagnostics bookkeeping', () => { + it('bounds tracked keys so a long session cannot grow without limit', () => { + const diagnostics = createAutomationHostDiagnostics(2) + for (const key of ['k1', 'k2', 'k3']) { + diagnostics.recordRequest({ + authorityKey: 'a', + stableKey: key, + transport: 'scoped' + }) + } + const snapshot = diagnostics.snapshot() + expect(Object.keys(snapshot.byStableKey)).toEqual(['k2', 'k3']) + // Evicting a key must not rewrite history: the totals still saw three calls. + // This is also why sum(byStableKey) is a lower bound on totals, not an equality. + expect(snapshot.totals.requests).toBe(3) + }) + + it('clears every bucket on reset so a profiling run starts from zero', () => { + const diagnostics = createAutomationHostDiagnostics() + diagnostics.recordRequest({ + authorityKey: 'a', + stableKey: 'k1', + transport: 'legacy' + }) + diagnostics.recordDedupeHit({ authorityKey: 'a', stableKey: 'k1' }) + diagnostics.reset() + expect(diagnostics.snapshot()).toMatchObject({ + totals: { requests: 0, dedupeHits: 0, legacyRequests: 0 }, + byAuthority: {}, + byStableKey: {} + }) + }) + + it('never copies an automation, a prompt, or a name into a counter', () => { + const diagnostics = createAutomationHostDiagnostics() + diagnostics.recordResponse({ + authorityKey: 'a', + stableKey: 'k1', + rows: 3, + durationMs: 12, + approxSerializedChars: 900 + }) + expect(JSON.stringify(diagnostics.snapshot())).not.toMatch(/prompt|automation-|secret/i) + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-diagnostics.ts b/src/renderer/src/components/automations/automation-host-diagnostics.ts new file mode 100644 index 00000000000..7428cf7d21a --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-diagnostics.ts @@ -0,0 +1,314 @@ +/** + * Counters behind the design doc's request-count and stale-response release + * gates: how many authority calls the cache actually sent, how many callers + * joined one already in flight, how many answers the fence threw away, and how + * big/slow each answer was. + * + * Nothing here holds an automation, a prompt, or run output — only counts, + * durations, and the stable/authority keys the gate is stated in terms of. + * + * Two accounting rules run through the whole file, because the gates are stated + * per *request* while the cache settles per *entry*: + * - A legacy unscoped call is one request for its whole authority. It is + * counted against the authority and against no stable key, so "one legacy + * call per authority per cycle" stays a number you can assert on. + * - Anything counted per entry (`discardedEntries`, per-key `rows`) says so at + * the field, and must never be divided by or compared against `responses`. + */ + +export type AutomationHostRequestTransport = 'scoped' | 'legacy' + +export type AutomationHostKeyCounters = { + /** Pooled authority calls sent. Legacy calls land on the authority only. */ + requests: number + scopedRequests: number + legacyRequests: number + /** + * Unpooled `status.get` capability probes, which ride outside the four-slot + * pool and always re-fetch. Relay traffic is `requests + capabilityProbes`; + * the in-flight ceiling is stated over `requests` alone. + */ + capabilityProbes: number + /** Callers that joined a request already in flight, keyed like `requests`. */ + dedupeHits: number + /** Requests whose answer landed nowhere because the fence rejected every entry. */ + discardedCommits: number + discardedFailures: number + /** + * Entries the fence rejected. One legacy answer can reject many at once, so + * this is per entry and is not comparable with `responses` or `requests`. + */ + discardedEntries: number + responses: number + /** Requests that threw. `durationMsTotal` spans these too. */ + failures: number + /** + * On `totals` and `byAuthority`, rows the response carried. On `byStableKey`, + * rows attributed to that entry — a legacy answer is counted once against its + * authority and fanned out to keys for row attribution only, so a key can + * carry rows while its `requests` is 0. + */ + rows: number + /** See `serializedCharsMeasured`; 0 also means "not measured". */ + approxSerializedChars: number + /** Spans every completed request; the denominator is `responses + failures`. */ + durationMsTotal: number + durationMsMax: number +} + +export type AutomationHostDiagnosticsSnapshot = { + totals: AutomationHostKeyCounters + /** + * Bucket totals can trail `totals` after eviction, so `sum(byStableKey)` is a + * lower bound on `totals`, never an equality. + */ + byAuthority: Record + byStableKey: Record + /** + * Sizing requires re-serializing a response the transport already decoded, + * which is the one measurement here expensive enough to disturb the 50 ms + * budget it exists to verify. False means unmeasured, not zero. + */ + serializedCharsMeasured: boolean +} + +export type AutomationHostRequestRecord = { + authorityKey: string + /** Null for a legacy authority call: it answers for every key at once. */ + stableKey: string | null + transport: AutomationHostRequestTransport +} + +export type AutomationHostResponseRecord = { + authorityKey: string + /** Null for a legacy authority answer, which belongs to no single key. */ + stableKey: string | null + rows: number + durationMs: number + /** UTF-16 code units of the re-serialized payload, when measurement is on. */ + approxSerializedChars?: number +} + +export type AutomationHostFailureRecord = { + authorityKey: string + stableKey: string | null + durationMs: number +} + +export type AutomationHostDiscardRecord = { + authorityKey: string + stableKey: string | null + outcome: 'commit' | 'failure' +} + +export type AutomationHostDiagnostics = { + recordRequest: (record: AutomationHostRequestRecord) => void + /** Keyed by authority alone: the probe answers for the host, not for one entry. */ + recordCapabilityProbe: (record: { authorityKey: string }) => void + recordDedupeHit: (record: { authorityKey: string; stableKey: string | null }) => void + recordResponse: (record: AutomationHostResponseRecord) => void + recordFailedRequest: (record: AutomationHostFailureRecord) => void + /** Rows one entry took from a legacy answer; touches that key's bucket only. */ + recordEntryRows: (record: { stableKey: string; rows: number }) => void + recordDiscardedRequest: (record: AutomationHostDiscardRecord) => void + recordDiscardedEntry: (record: { authorityKey: string; stableKey: string }) => void + /** True only while measuring; callers skip the serialization cost otherwise. */ + measuringSerializedChars: () => boolean + setMeasureSerializedChars: (enabled: boolean) => void + snapshot: () => AutomationHostDiagnosticsSnapshot + reset: () => void +} + +// Bounds memory when a session churns through hosts: the gate is stated over a +// 50-host fixture, so a cap well above that loses nothing it needs to prove. +const MAX_TRACKED_KEYS = 512 + +function emptyCounters(): AutomationHostKeyCounters { + return { + requests: 0, + scopedRequests: 0, + legacyRequests: 0, + capabilityProbes: 0, + dedupeHits: 0, + discardedCommits: 0, + discardedFailures: 0, + discardedEntries: 0, + responses: 0, + failures: 0, + rows: 0, + approxSerializedChars: 0, + durationMsTotal: 0, + durationMsMax: 0 + } +} + +export function createAutomationHostDiagnostics( + maxTrackedKeys = MAX_TRACKED_KEYS +): AutomationHostDiagnostics { + const totals = emptyCounters() + const byAuthority = new Map() + const byStableKey = new Map() + let measureSerializedChars = false + + const bucket = ( + buckets: Map, + key: string + ): AutomationHostKeyCounters => { + const existing = buckets.get(key) + if (existing) { + return existing + } + // Eviction is first-seen order, not least-recently-used: keeping it that way + // costs no re-insert on the recording path, and at 512 keys against a 50-host + // gate nothing under test is ever reached. + if (buckets.size >= maxTrackedKeys) { + const oldest = buckets.keys().next() + if (!oldest.done) { + buckets.delete(oldest.value) + } + } + const created = emptyCounters() + buckets.set(key, created) + return created + } + + const targets = ( + authorityKey: string, + stableKey: string | null + ): readonly AutomationHostKeyCounters[] => + stableKey === null + ? [totals, bucket(byAuthority, authorityKey)] + : [totals, bucket(byAuthority, authorityKey), bucket(byStableKey, stableKey)] + + const toRecord = ( + buckets: Map + ): Record => + Object.fromEntries([...buckets].map(([key, counters]) => [key, { ...counters }])) + + const addDuration = (counters: AutomationHostKeyCounters, durationMs: number): void => { + counters.durationMsTotal += durationMs + counters.durationMsMax = Math.max(counters.durationMsMax, durationMs) + } + + return { + recordRequest: ({ authorityKey, stableKey, transport }) => { + for (const counters of targets(authorityKey, stableKey)) { + counters.requests += 1 + if (transport === 'legacy') { + counters.legacyRequests += 1 + } else { + counters.scopedRequests += 1 + } + } + }, + recordCapabilityProbe: ({ authorityKey }) => { + for (const counters of targets(authorityKey, null)) { + counters.capabilityProbes += 1 + } + }, + recordDedupeHit: ({ authorityKey, stableKey }) => { + for (const counters of targets(authorityKey, stableKey)) { + counters.dedupeHits += 1 + } + }, + recordResponse: ({ authorityKey, stableKey, rows, durationMs, approxSerializedChars }) => { + for (const counters of targets(authorityKey, stableKey)) { + counters.responses += 1 + counters.rows += rows + counters.approxSerializedChars += approxSerializedChars ?? 0 + addDuration(counters, durationMs) + } + }, + recordFailedRequest: ({ authorityKey, stableKey, durationMs }) => { + for (const counters of targets(authorityKey, stableKey)) { + counters.failures += 1 + addDuration(counters, durationMs) + } + }, + recordEntryRows: ({ stableKey, rows }) => { + bucket(byStableKey, stableKey).rows += rows + }, + recordDiscardedRequest: ({ authorityKey, stableKey, outcome }) => { + for (const counters of targets(authorityKey, stableKey)) { + if (outcome === 'commit') { + counters.discardedCommits += 1 + } else { + counters.discardedFailures += 1 + } + } + }, + recordDiscardedEntry: ({ authorityKey, stableKey }) => { + for (const counters of targets(authorityKey, stableKey)) { + counters.discardedEntries += 1 + } + }, + measuringSerializedChars: () => measureSerializedChars, + setMeasureSerializedChars: (enabled) => { + measureSerializedChars = enabled + }, + snapshot: () => ({ + totals: { ...totals }, + byAuthority: toRecord(byAuthority), + byStableKey: toRecord(byStableKey), + serializedCharsMeasured: measureSerializedChars + }), + reset: () => { + Object.assign(totals, emptyCounters()) + byAuthority.clear() + byStableKey.clear() + } + } +} + +/** Shared instance the scheduler and list client write to when nothing is injected. */ +export const automationHostDiagnostics = createAutomationHostDiagnostics() + +/** + * Payload size as UTF-16 code units of a re-serialization — an approximation of + * what crossed the wire, not a byte count, which is why nothing here is named + * `bytes`. Guarded by the caller's `measuringSerializedChars()` check, and + * undefined when the payload will not serialize: an unmeasurable response must + * not read as a zero-sized one. + */ +export function measureAutomationHostResponseChars(payload: unknown): number | undefined { + try { + return JSON.stringify(payload)?.length + } catch { + return undefined + } +} + +export type AutomationHostDiagnosticsBridge = { + report: () => AutomationHostDiagnosticsSnapshot + measureSerializedChars: (enabled?: boolean) => string + reset: () => string +} + +type AutomationHostDiagnosticsWindow = Window & { + __orcaAutomationHostDiagnostic?: AutomationHostDiagnosticsBridge +} + +export function installAutomationHostDiagnostic(): void { + if (typeof window === 'undefined') { + return + } + const target = window as AutomationHostDiagnosticsWindow + if (target.__orcaAutomationHostDiagnostic) { + return + } + target.__orcaAutomationHostDiagnostic = { + report: () => { + const snapshot = automationHostDiagnostics.snapshot() + console.log('[orca] automation host cache diagnostics', snapshot) + return snapshot + }, + measureSerializedChars: (enabled = true) => { + automationHostDiagnostics.setMeasureSerializedChars(enabled) + return `Automation host response sizing ${enabled ? 'on' : 'off'}.` + }, + reset: () => { + automationHostDiagnostics.reset() + return 'Automation host cache diagnostics reset.' + } + } +} diff --git a/src/renderer/src/components/automations/automation-host-fetch-target.ts b/src/renderer/src/components/automations/automation-host-fetch-target.ts new file mode 100644 index 00000000000..a41f404793c --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-fetch-target.ts @@ -0,0 +1,42 @@ +/** + * Builds the fetch target a manual retry needs. + * + * The controller derives these itself for scheduled work; a user-initiated + * Retry has to name one entry, and it must carry the same incarnation the + * scheduler would have used or the response cannot commit. + */ + +import type { AutomationAuthorityRef } from '../../../../shared/automation-owner-ref' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import type { AutomationHostFetchTarget } from './automation-host-scheduler' + +export function automationHostAuthorityRef( + entry: AutomationHostCatalogEntry, + pairingRevision: (environmentId: string) => number +): AutomationAuthorityRef { + if (entry.owner) { + return entry.owner.authority + } + const authority = entry.stableRef.authority + return authority.kind === 'desktop' + ? { kind: 'desktop' } + : { + kind: 'runtime', + environmentId: authority.environmentId, + pairingRevision: pairingRevision(authority.environmentId) + } +} + +export function automationHostFetchTarget( + entry: AutomationHostCatalogEntry, + pairingRevision: (environmentId: string) => number +): AutomationHostFetchTarget { + return { + ref: entry.stableRef, + authority: automationHostAuthorityRef(entry, pairingRevision), + owner: entry.owner, + querySupport: entry.querySupport, + // A retry is always what the user is waiting on, so it jumps the queue. + priority: true + } +} diff --git a/src/renderer/src/components/automations/automation-host-filter-resolution.test.ts b/src/renderer/src/components/automations/automation-host-filter-resolution.test.ts new file mode 100644 index 00000000000..59ba583fb03 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-filter-resolution.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it } from 'vitest' +import type { AutomationHostFilter } from '../../../../shared/automation-host-filter' +import { hostStableKey } from '../../../../shared/automation-owner-key' +import type { StableAutomationCatalogRef } from '../../../../shared/automation-owner-ref' +import { buildAutomationHostCatalog } from './automation-host-catalog' +import type { + AutomationCatalogSshMirrorInput, + AutomationHostCatalogInput, + AutomationRuntimeAuthorityInput +} from './automation-host-catalog-types' +import { resolveAutomationHostFilter } from './automation-host-filter-resolution' + +function mirror( + overrides: Partial = {} +): AutomationCatalogSshMirrorInput { + return { + targetsHydrated: true, + targets: [], + removedTargetLabels: new Map(), + connectionStatusByTargetId: new Map(), + ...overrides + } +} + +function runtime( + overrides: Partial = {} +): AutomationRuntimeAuthorityInput { + return { + environmentId: 'env-1', + label: 'Build server', + pairingRevision: 3, + authorityHealth: 'fresh', + querySupport: 'scoped', + ssh: mirror(), + ...overrides + } +} + +function catalogOf(overrides: Partial = {}) { + return buildAutomationHostCatalog({ + desktop: { label: 'Local Mac', ssh: mirror() }, + runtimes: [], + runtimeCatalogSettled: true, + ...overrides + }) +} + +function filterFor(host: StableAutomationCatalogRef): AutomationHostFilter { + return { kind: 'host', host } +} + +const desktopSsh = (targetId: string): StableAutomationCatalogRef => ({ + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId } +}) +const runtimeSelf: StableAutomationCatalogRef = { + authority: { kind: 'runtime', environmentId: 'env-1' }, + selector: { kind: 'self' } +} +const runtimeSsh: StableAutomationCatalogRef = { + authority: { kind: 'runtime', environmentId: 'env-1' }, + selector: { kind: 'ssh', targetId: 'nested' } +} +const desktopOrphan: StableAutomationCatalogRef = { + authority: { kind: 'desktop' }, + selector: { kind: 'orphan' } +} + +describe('resolveAutomationHostFilter', () => { + it('passes All hosts through untouched', () => { + expect(resolveAutomationHostFilter({ filter: { kind: 'all' }, catalog: catalogOf() })).toEqual({ + effective: { kind: 'all' }, + entry: null, + status: 'all', + announceFallback: false + }) + }) + + it('resolves Desktop + Self immediately', () => { + const filter = filterFor({ authority: { kind: 'desktop' }, selector: { kind: 'self' } }) + const resolution = resolveAutomationHostFilter({ filter, catalog: catalogOf() }) + expect(resolution.status).toBe('ready') + expect(resolution.effective).toBe(filter) + expect(resolution.entry?.label).toBe('Local Mac') + }) + + it('retains a desktop SSH selection until the target list hydrates', () => { + const filter = filterFor(desktopSsh('pending')) + const resolution = resolveAutomationHostFilter({ + filter, + catalog: catalogOf({ + desktop: { label: 'Local Mac', ssh: mirror({ targetsHydrated: false }) } + }) + }) + expect(resolution).toMatchObject({ status: 'loading', announceFallback: false }) + expect(resolution.effective).toBe(filter) + }) + + it('falls back and announces once desktop SSH absence is authoritative', () => { + const resolution = resolveAutomationHostFilter({ + filter: filterFor(desktopSsh('gone')), + catalog: catalogOf() + }) + expect(resolution).toEqual({ + effective: { kind: 'all' }, + entry: null, + status: 'removed', + announceFallback: true + }) + }) + + it('keeps a removed host as a ghost while automations still reference it', () => { + const host = desktopSsh('gone') + const key = hostStableKey(host) + const resolution = resolveAutomationHostFilter({ + filter: filterFor(host), + catalog: catalogOf({ + desktop: { + label: 'Local Mac', + ssh: mirror({ removedTargetLabels: new Map([['gone', 'Old box']]) }) + }, + referencedStableKeys: [key] + }), + referencedStableKeys: new Set([key]) + }) + expect(resolution.status).toBe('ghost') + expect(resolution.announceFallback).toBe(false) + expect(resolution.entry?.label).toBe('Old box') + }) + + it('drops a tombstoned host that nothing else references', () => { + const host = desktopSsh('gone') + const resolution = resolveAutomationHostFilter({ + filter: filterFor(host), + catalog: catalogOf({ + desktop: { + label: 'Local Mac', + ssh: mirror({ removedTargetLabels: new Map([['gone', 'Old box']]) }) + } + }) + }) + expect(resolution.status).toBe('removed') + expect(resolution.announceFallback).toBe(true) + }) + + it('retains a Runtime Self selection until the saved runtime catalog settles', () => { + const filter = filterFor(runtimeSelf) + expect( + resolveAutomationHostFilter({ filter, catalog: catalogOf({ runtimeCatalogSettled: false }) }) + .status + ).toBe('loading') + expect(resolveAutomationHostFilter({ filter, catalog: catalogOf() })).toMatchObject({ + status: 'removed', + announceFallback: true + }) + }) + + it('retains a Runtime SSH selection until that runtime bucket hydrates', () => { + const filter = filterFor(runtimeSsh) + expect( + resolveAutomationHostFilter({ + filter, + catalog: catalogOf({ runtimes: [runtime({ ssh: mirror({ targetsHydrated: false }) })] }) + }).status + ).toBe('loading') + expect( + resolveAutomationHostFilter({ filter, catalog: catalogOf({ runtimes: [runtime()] }) }) + ).toMatchObject({ status: 'removed', announceFallback: true }) + }) + + it('accepts a removal tombstone as positive evidence for a nested target', () => { + const resolution = resolveAutomationHostFilter({ + filter: filterFor(runtimeSsh), + catalog: catalogOf({ + runtimes: [ + runtime({ + ssh: mirror({ + targetsHydrated: false, + removedTargetLabels: new Map([['nested', 'Nested box']]) + }) + }) + ] + }) + }) + expect(resolution.status).toBe('removed') + expect(resolution.announceFallback).toBe(true) + }) + + it('never claims removal while the owning authority is offline', () => { + const offline = catalogOf({ + runtimes: [ + runtime({ + authorityHealth: 'unavailable', + ssh: mirror({ removedTargetLabels: new Map([['nested', 'Nested box']]) }) + }) + ] + }) + expect( + resolveAutomationHostFilter({ filter: filterFor(runtimeSsh), catalog: offline }) + ).toMatchObject({ status: 'unavailable', announceFallback: false }) + expect( + resolveAutomationHostFilter({ + filter: filterFor({ + authority: { kind: 'runtime', environmentId: 'env-1' }, + selector: { kind: 'ssh', targetId: 'never-seen' } + }), + catalog: offline + }) + ).toMatchObject({ status: 'unavailable', announceFallback: false }) + }) + + it('settles an orphan selection only on an authoritative orphan count', () => { + const filter = filterFor(desktopOrphan) + expect(resolveAutomationHostFilter({ filter, catalog: catalogOf() }).status).toBe('loading') + expect( + resolveAutomationHostFilter({ + filter, + catalog: catalogOf({ desktop: { label: 'Local Mac', ssh: mirror(), orphanCount: 2 } }) + }).status + ).toBe('ready') + expect( + resolveAutomationHostFilter({ + filter, + catalog: catalogOf({ desktop: { label: 'Local Mac', ssh: mirror(), orphanCount: 0 } }) + }) + ).toMatchObject({ status: 'removed', announceFallback: true }) + }) + + it('retains an orphan selection as unavailable while its authority is offline', () => { + const resolution = resolveAutomationHostFilter({ + filter: filterFor({ + authority: { kind: 'runtime', environmentId: 'env-1' }, + selector: { kind: 'orphan' } + }), + catalog: catalogOf({ runtimes: [runtime({ authorityHealth: 'unavailable' })] }) + }) + expect(resolution).toMatchObject({ status: 'unavailable', announceFallback: false }) + }) + + it('retains the display selection through a same-id re-pair', () => { + const filter = filterFor(runtimeSelf) + const before = resolveAutomationHostFilter({ + filter, + catalog: catalogOf({ runtimes: [runtime({ pairingRevision: 3 })] }) + }) + const after = resolveAutomationHostFilter({ + filter, + catalog: catalogOf({ runtimes: [runtime({ pairingRevision: 4 })] }) + }) + expect(after.status).toBe('ready') + expect(after.entry?.stableKey).toBe(before.entry?.stableKey) + // The display slot survives; the captured incarnation does not. + expect(after.entry?.owner).not.toEqual(before.entry?.owner) + }) + + it('retains the selection through a rename', () => { + const filter = filterFor(runtimeSelf) + const resolution = resolveAutomationHostFilter({ + filter, + catalog: catalogOf({ runtimes: [runtime({ label: 'Renamed server' })] }) + }) + expect(resolution.status).toBe('ready') + expect(resolution.entry?.label).toBe('Renamed server') + expect(resolution.entry?.stableKey).toBe(hostStableKey(runtimeSelf)) + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-filter-resolution.ts b/src/renderer/src/components/automations/automation-host-filter-resolution.ts new file mode 100644 index 00000000000..15350cb00b7 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-filter-resolution.ts @@ -0,0 +1,141 @@ +import { + ALL_AUTOMATION_HOSTS_FILTER, + automationHostFilterStableKey, + type AutomationHostFilter +} from '../../../../shared/automation-host-filter' +import type { StableAutomationCatalogRef } from '../../../../shared/automation-owner-ref' +import { + automationAuthorityCatalogKey, + type AutomationCatalogHydrationEvidence, + type AutomationHostCatalog, + type AutomationHostCatalogEntry +} from './automation-host-catalog-types' + +/** + * Restores a persisted host selection against the live catalog. + * + * The rule that makes this non-trivial: missing catalog data is never evidence + * of removal (required invariant 4). Until the relevant catalog settles the + * selection is retained and reported as `loading`; only positive evidence — + * a hydrated target list, a settled runtime catalog, or a tombstone — may drop it. + */ + +export type AutomationHostFilterStatus = + | 'all' + /** The selected host is present and authoritative. */ + | 'ready' + /** Relevant state is unhydrated. Retain the selection and show `Loading host…`. */ + | 'loading' + /** The owning authority is offline, so absence proves nothing. Retain. */ + | 'unavailable' + /** Positively removed but still referenced by automations or cached rows. Retain as a ghost. */ + | 'ghost' + /** Positively removed and unreferenced: fall back to All hosts and announce it. */ + | 'removed' + +export type AutomationHostFilterResolution = { + /** What the list should actually render and query. */ + effective: AutomationHostFilter + entry: AutomationHostCatalogEntry | null + status: AutomationHostFilterStatus + /** True only when a saved selection was dropped; the change must be announced. */ + announceFallback: boolean +} + +export type AutomationHostFilterResolutionInput = { + filter: AutomationHostFilter + catalog: AutomationHostCatalog + /** Stable keys still referenced by a stored automation or cached row, tombstones aside. */ + referencedStableKeys?: ReadonlySet +} + +const ALL_RESOLUTION: AutomationHostFilterResolution = { + effective: ALL_AUTOMATION_HOSTS_FILTER, + entry: null, + status: 'all', + announceFallback: false +} + +function retain( + filter: AutomationHostFilter, + entry: AutomationHostCatalogEntry | null, + status: AutomationHostFilterStatus +): AutomationHostFilterResolution { + return { effective: filter, entry, status, announceFallback: false } +} + +function fallBackToAllHosts(): AutomationHostFilterResolution { + return { + effective: ALL_AUTOMATION_HOSTS_FILTER, + entry: null, + status: 'removed', + announceFallback: true + } +} + +function resolvePresentEntry( + filter: AutomationHostFilter, + entry: AutomationHostCatalogEntry, + referenced: boolean +): AutomationHostFilterResolution { + if (entry.authorityHealth === 'unavailable') { + // Why: an offline authority can neither confirm removal nor prove an orphan was repaired. + return retain(filter, entry, 'unavailable') + } + if (entry.catalogState === 'unhydrated') { + return retain(filter, entry, 'loading') + } + if (entry.catalogState === 'removed') { + return referenced ? retain(filter, entry, 'ghost') : fallBackToAllHosts() + } + return retain(filter, entry, 'ready') +} + +/** Positive-evidence gate for a selection that has no catalog entry at all. */ +function resolveAbsentEntry( + filter: AutomationHostFilter, + host: StableAutomationCatalogRef, + hydration: AutomationCatalogHydrationEvidence +): AutomationHostFilterResolution { + const authorityKey = automationAuthorityCatalogKey(host.authority) + if (hydration.unavailableAuthorityKeys.has(authorityKey)) { + return retain(filter, null, 'unavailable') + } + if (host.authority.kind === 'runtime') { + const environmentId = host.authority.environmentId + if (!hydration.savedRuntimeEnvironmentIds.has(environmentId)) { + // The whole authority is gone — only a settled saved-runtime catalog proves that. + return hydration.runtimeCatalogSettled + ? fallBackToAllHosts() + : retain(filter, null, 'loading') + } + } + if (host.selector.kind === 'self') { + // Desktop Self and every saved runtime's Self are always projected, so an + // absent one means the saved catalog has not produced it yet. + return retain(filter, null, 'loading') + } + if (host.selector.kind === 'orphan') { + return hydration.orphanSettledAuthorityKeys.has(authorityKey) + ? fallBackToAllHosts() + : retain(filter, null, 'loading') + } + const sshHydrated = + host.authority.kind === 'desktop' + ? hydration.desktopSshHydrated + : (hydration.runtimeSshHydratedByEnvironmentId.get(host.authority.environmentId) ?? false) + return sshHydrated ? fallBackToAllHosts() : retain(filter, null, 'loading') +} + +export function resolveAutomationHostFilter( + input: AutomationHostFilterResolutionInput +): AutomationHostFilterResolution { + const stableKey = automationHostFilterStableKey(input.filter) + if (input.filter.kind === 'all' || stableKey === null) { + return ALL_RESOLUTION + } + const entry = input.catalog.byStableKey.get(stableKey) + return entry + ? resolvePresentEntry(input.filter, entry, input.referencedStableKeys?.has(stableKey) ?? false) + : resolveAbsentEntry(input.filter, input.filter.host, input.catalog.hydration) +} diff --git a/src/renderer/src/components/automations/automation-host-health.test.ts b/src/renderer/src/components/automations/automation-host-health.test.ts new file mode 100644 index 00000000000..571a36b544e --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-health.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { + resolveAutomationAuthorityHealth, + resolveSelfExecutionHealth, + resolveSshExecutionHealth +} from './automation-host-health' + +const reachable = { reachable: true, compatible: true, hasData: true } as const + +describe('resolveAutomationAuthorityHealth', () => { + it('reports incompatibility ahead of every other signal', () => { + expect( + resolveAutomationAuthorityHealth({ + reachable: false, + compatible: false, + queryState: 'error', + hasData: false + }) + ).toBe('incompatible') + }) + + it('separates first load from a background refresh', () => { + expect( + resolveAutomationAuthorityHealth({ ...reachable, hasData: false, queryState: 'loading' }) + ).toBe('loading') + expect(resolveAutomationAuthorityHealth({ ...reachable, queryState: 'loading' })).toBe( + 'refreshing' + ) + expect(resolveAutomationAuthorityHealth({ ...reachable, queryState: 'idle' })).toBe('fresh') + expect(resolveAutomationAuthorityHealth({ ...reachable, queryState: 'error' })).toBe( + 'stale-error' + ) + }) + + it('reports an unreachable authority as unavailable', () => { + expect( + resolveAutomationAuthorityHealth({ ...reachable, reachable: false, queryState: 'idle' }) + ).toBe('unavailable') + }) +}) + +describe('execution health', () => { + it('never borrows a status for unverified or removed registrations', () => { + expect(resolveSshExecutionHealth('removed', 'connected', 'disconnected')).toBe('unavailable') + expect(resolveSshExecutionHealth('unhydrated', 'connected', 'disconnected')).toBe('unknown') + }) + + it('maps every SSH status onto a distinct execution state', () => { + expect(resolveSshExecutionHealth('authoritative', 'connected', undefined)).toBe('connected') + expect(resolveSshExecutionHealth('authoritative', 'reconnecting', undefined)).toBe('connecting') + expect(resolveSshExecutionHealth('authoritative', 'deploying-relay', undefined)).toBe( + 'connecting' + ) + expect(resolveSshExecutionHealth('authoritative', 'auth-failed', undefined)).toBe( + 'disconnected' + ) + expect(resolveSshExecutionHealth('authoritative', undefined, undefined)).toBe('unknown') + expect(resolveSshExecutionHealth('authoritative', undefined, 'disconnected')).toBe( + 'disconnected' + ) + }) + + it('treats an offline authority as disconnected, not gone', () => { + expect(resolveSelfExecutionHealth('unavailable')).toBe('disconnected') + expect(resolveSelfExecutionHealth('stale-error')).toBe('connected') + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-health.ts b/src/renderer/src/components/automations/automation-host-health.ts new file mode 100644 index 00000000000..df1a461cfc0 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-health.ts @@ -0,0 +1,90 @@ +import type { SshConnectionStatus } from '../../../../shared/ssh-types' +import type { + AutomationAuthorityHealth, + AutomationExecutionHealth, + AutomationHostCatalogState +} from './automation-host-catalog-types' + +/** Cache-side view of an authority's list query, supplied by the caller. */ +export type AutomationAuthorityQueryState = 'idle' | 'loading' | 'refreshing' | 'error' + +export type AutomationAuthorityHealthInput = { + /** The authority answered recently. Desktop is always reachable. */ + reachable: boolean + /** False once the authority is known to lack the required list capability. */ + compatible: boolean + queryState: AutomationAuthorityQueryState + /** Usable rows are already cached, so a failure is *stale*, not empty. */ + hasData: boolean +} + +/** + * Query health only. Callers must not fold execution-target state in here — + * a disconnected SSH target leaves its owning authority perfectly listable. + */ +export function resolveAutomationAuthorityHealth( + input: AutomationAuthorityHealthInput +): AutomationAuthorityHealth { + if (!input.compatible) { + return 'incompatible' + } + if (!input.reachable) { + return 'unavailable' + } + if (input.queryState === 'error') { + return 'stale-error' + } + if (input.queryState === 'refreshing') { + return 'refreshing' + } + if (input.queryState === 'loading') { + return input.hasData ? 'refreshing' : 'loading' + } + // Why: idle with nothing cached has not produced a list yet, so it is still loading. + return input.hasData ? 'fresh' : 'loading' +} + +function executionHealthForStatus(status: SshConnectionStatus): AutomationExecutionHealth { + switch (status) { + case 'connected': + return 'connected' + case 'connecting': + case 'reconnecting': + case 'deploying-relay': + return 'connecting' + case 'disconnected': + case 'auth-failed': + case 'reconnection-failed': + case 'error': + return 'disconnected' + } +} + +/** + * Execution health of one SSH target. An unhydrated or removed registration + * reports `unknown`/`unavailable` rather than borrowing a stale status. + */ +export function resolveSshExecutionHealth( + catalogState: AutomationHostCatalogState, + status: SshConnectionStatus | undefined, + missingStatus: SshConnectionStatus | undefined +): AutomationExecutionHealth { + if (catalogState === 'removed') { + return 'unavailable' + } + if (catalogState === 'unhydrated') { + return 'unknown' + } + const effective = status ?? missingStatus + return effective ? executionHealthForStatus(effective) : 'unknown' +} + +/** + * Execution health of an authority's own machine. Offline is `disconnected` + * (reconnectable), never `unavailable`, which is reserved for hosts that are gone. + */ +export function resolveSelfExecutionHealth( + authorityHealth: AutomationAuthorityHealth +): AutomationExecutionHealth { + return authorityHealth === 'unavailable' ? 'disconnected' : 'connected' +} diff --git a/src/renderer/src/components/automations/automation-host-invalidation-window-events.test.ts b/src/renderer/src/components/automations/automation-host-invalidation-window-events.test.ts new file mode 100644 index 00000000000..d5929f16b62 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-invalidation-window-events.test.ts @@ -0,0 +1,145 @@ +/** + * The window event is the only place the publisher's identity can be lost, so + * these cover the whole attribution path: a scoped runtime event must reach one + * host, and an unattributed one must still reach a whole authority. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import { hostStableKey } from '../../../../shared/automation-owner-key' +import type { + StableAutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import { + AUTOMATIONS_CHANGED_EVENT, + emitAutomationsChangedWindowEvent +} from '@/lib/automations-changed-window-event' +import { createAutomationHostCache, type AutomationHostCache } from './automation-host-cache' +import type { AutomationHostRow } from './automation-host-cache-types' +import { createAutomationHostInvalidation } from './automation-host-invalidation' +import { + subscribeAutomationHostInvalidation, + toAutomationAuthorityChangeEvent +} from './automation-host-invalidation-window-events' + +const DESKTOP: StableAutomationAuthorityRef = { kind: 'desktop' } +const RUNTIME: StableAutomationAuthorityRef = { kind: 'runtime', environmentId: 'env-1' } +const DESKTOP_SELF: StableAutomationCatalogRef = { authority: DESKTOP, selector: { kind: 'self' } } +const RUNTIME_SELF: StableAutomationCatalogRef = { authority: RUNTIME, selector: { kind: 'self' } } +const RUNTIME_SSH: StableAutomationCatalogRef = { + authority: RUNTIME, + selector: { kind: 'ssh', targetId: 'target-1' } +} + +function row(id: string): AutomationHostRow { + return { + automation: { id } as Automation, + owner: null, + selector: { kind: 'self' }, + usageSummary: null, + usageKnown: false + } +} + +function seeded(): AutomationHostCache { + const cache = createAutomationHostCache({ + catalogGeneration: () => 0, + connectionGeneration: () => 0 + }) + for (const ref of [DESKTOP_SELF, RUNTIME_SELF, RUNTIME_SSH]) { + cache.commit(cache.beginRequest(ref), { rows: [row('a')] }) + } + return cache +} + +function subscribedCache(): { cache: AutomationHostCache; invalidated: ReturnType } { + const cache = seeded() + const invalidated = vi.fn() + const invalidation = createAutomationHostInvalidation({ + cache, + onInvalidated: invalidated, + schedule: (flush) => flush() + }) + subscribeAutomationHostInvalidation(invalidation.handle) + return { cache, invalidated } +} + +describe('automationsChanged window event attribution', () => { + // The suite runs in node, so the publisher and the subscriber need a real shared target. + beforeEach(() => { + vi.stubGlobal('window', new EventTarget()) + }) + + it('maps an environment id to its runtime authority and keeps the selector', () => { + expect( + toAutomationAuthorityChangeEvent({ + environmentId: 'env-1', + selector: { kind: 'ssh', targetId: 'target-1' }, + reason: 'definition' + }) + ).toEqual({ + authority: RUNTIME, + selector: { kind: 'ssh', targetId: 'target-1' }, + reason: 'definition' + }) + }) + + it('treats an absent environment id as the desktop authority', () => { + expect(toAutomationAuthorityChangeEvent({ reason: 'run' })).toEqual({ + authority: DESKTOP, + reason: 'run' + }) + }) + + it('routes a scoped runtime event to one host and leaves the others alone', () => { + const { cache, invalidated } = subscribedCache() + + emitAutomationsChangedWindowEvent({ + environmentId: 'env-1', + selector: { kind: 'ssh', targetId: 'target-1' }, + reason: 'definition' + }) + + expect(invalidated).toHaveBeenCalledWith([hostStableKey(RUNTIME_SSH)]) + expect(cache.get(RUNTIME_SELF)?.requestGeneration).toBe(0) + expect(cache.get(DESKTOP_SELF)?.requestGeneration).toBe(0) + }) + + it('falls back to the whole authority when an old host sends no selector', () => { + const { cache, invalidated } = subscribedCache() + + emitAutomationsChangedWindowEvent({ environmentId: 'env-1' }) + + expect(invalidated.mock.calls[0]?.[0]).toEqual( + expect.arrayContaining([hostStableKey(RUNTIME_SELF), hostStableKey(RUNTIME_SSH)]) + ) + expect(cache.get(DESKTOP_SELF)?.requestGeneration).toBe(0) + }) + + // A publisher that predates the detail payload still has to produce a refresh. + it('degrades a bare Event to an unscoped desktop invalidation', () => { + const { cache, invalidated } = subscribedCache() + + window.dispatchEvent(new Event(AUTOMATIONS_CHANGED_EVENT)) + + expect(invalidated).toHaveBeenCalledWith([hostStableKey(DESKTOP_SELF)]) + expect(cache.get(RUNTIME_SELF)?.requestGeneration).toBe(0) + }) + + it('stops delivering once unsubscribed', () => { + const cache = seeded() + const invalidated = vi.fn() + const invalidation = createAutomationHostInvalidation({ + cache, + onInvalidated: invalidated, + schedule: (flush) => flush() + }) + const unsubscribe = subscribeAutomationHostInvalidation(invalidation.handle) + + unsubscribe() + emitAutomationsChangedWindowEvent({ environmentId: 'env-1' }) + + expect(invalidated).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-invalidation-window-events.ts b/src/renderer/src/components/automations/automation-host-invalidation-window-events.ts new file mode 100644 index 00000000000..dab76c78dbf --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-invalidation-window-events.ts @@ -0,0 +1,41 @@ +/** + * Adapts the renderer's `automationsChanged` window event to the invalidation + * model's authority events. + * + * The publisher's authority is the environment it arrived from — desktop when + * none is named. An event that carries no selector still invalidates that one + * authority, which is what keeps an old host (or an unattributed local write) + * refreshing rather than being dropped. + */ + +import { + AUTOMATIONS_CHANGED_EVENT, + automationsChangedWindowDetail, + type AutomationsChangedWindowDetail +} from '@/lib/automations-changed-window-event' +import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref' +import type { AutomationAuthorityChangeEvent } from './automation-host-invalidation' + +export function toAutomationAuthorityChangeEvent( + detail: AutomationsChangedWindowDetail +): AutomationAuthorityChangeEvent { + const authority: StableAutomationAuthorityRef = detail.environmentId + ? { kind: 'runtime', environmentId: detail.environmentId } + : { kind: 'desktop' } + return { + authority, + ...(detail.selector ? { selector: detail.selector } : {}), + ...(detail.reason ? { reason: detail.reason } : {}) + } +} + +export function subscribeAutomationHostInvalidation( + handle: (event: AutomationAuthorityChangeEvent) => void, + target: EventTarget = window +): () => void { + const listener = (event: Event): void => { + handle(toAutomationAuthorityChangeEvent(automationsChangedWindowDetail(event))) + } + target.addEventListener(AUTOMATIONS_CHANGED_EVENT, listener) + return () => target.removeEventListener(AUTOMATIONS_CHANGED_EVENT, listener) +} diff --git a/src/renderer/src/components/automations/automation-host-invalidation.test.ts b/src/renderer/src/components/automations/automation-host-invalidation.test.ts new file mode 100644 index 00000000000..cfeb2db0902 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-invalidation.test.ts @@ -0,0 +1,147 @@ +/** + * Event invalidation has one blast-radius rule: a scoped event touches its own + * entry, an unscoped one touches its authority, and neither ever touches + * another authority's rows. + */ + +import { describe, expect, it, vi } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import { hostStableKey } from '../../../../shared/automation-owner-key' +import type { + StableAutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import { createAutomationHostCache, type AutomationHostCache } from './automation-host-cache' +import type { AutomationHostRow } from './automation-host-cache-types' +import { createAutomationHostInvalidation } from './automation-host-invalidation' + +const DESKTOP: StableAutomationAuthorityRef = { kind: 'desktop' } +const RUNTIME: StableAutomationAuthorityRef = { kind: 'runtime', environmentId: 'env-1' } +const DESKTOP_SELF: StableAutomationCatalogRef = { authority: DESKTOP, selector: { kind: 'self' } } +const DESKTOP_SSH: StableAutomationCatalogRef = { + authority: DESKTOP, + selector: { kind: 'ssh', targetId: 'target-1' } +} +const RUNTIME_SELF: StableAutomationCatalogRef = { authority: RUNTIME, selector: { kind: 'self' } } + +function row(id: string): AutomationHostRow { + return { + automation: { id } as Automation, + owner: null, + selector: { kind: 'self' }, + usageSummary: null, + usageKnown: false + } +} + +function seeded(): AutomationHostCache { + const cache = createAutomationHostCache({ + catalogGeneration: () => 0, + connectionGeneration: () => 0 + }) + for (const ref of [DESKTOP_SELF, DESKTOP_SSH, RUNTIME_SELF]) { + cache.commit(cache.beginRequest(ref), { rows: [row('a')] }) + } + return cache +} + +function generationOf(cache: AutomationHostCache, ref: StableAutomationCatalogRef): number { + return cache.get(ref)?.requestGeneration ?? -1 +} + +describe('automation host invalidation', () => { + it('invalidates only the entry a scoped event names', () => { + const cache = seeded() + const onInvalidated = vi.fn() + const invalidation = createAutomationHostInvalidation({ + cache, + onInvalidated, + schedule: (f) => f() + }) + const before = generationOf(cache, DESKTOP_SELF) + + invalidation.handle({ authority: DESKTOP, selector: { kind: 'ssh', targetId: 'target-1' } }) + + expect(onInvalidated).toHaveBeenCalledWith([hostStableKey(DESKTOP_SSH)]) + expect(generationOf(cache, DESKTOP_SELF)).toBe(before) + }) + + it('invalidates every entry of one authority for an unscoped event, and no other authority', () => { + const cache = seeded() + const onInvalidated = vi.fn() + const invalidation = createAutomationHostInvalidation({ + cache, + onInvalidated, + schedule: (f) => f() + }) + const untouched = generationOf(cache, RUNTIME_SELF) + + invalidation.handle({ authority: DESKTOP, reason: 'definition' }) + + expect(onInvalidated.mock.calls[0]?.[0]).toEqual( + expect.arrayContaining([hostStableKey(DESKTOP_SELF), hostStableKey(DESKTOP_SSH)]) + ) + expect(onInvalidated.mock.calls[0]?.[0]).not.toContain(hostStableKey(RUNTIME_SELF)) + expect(generationOf(cache, RUNTIME_SELF)).toBe(untouched) + }) + + it('coalesces a burst into one invalidation pass', async () => { + const cache = seeded() + const onInvalidated = vi.fn() + const invalidation = createAutomationHostInvalidation({ cache, onInvalidated }) + + invalidation.handle({ authority: DESKTOP, selector: { kind: 'self' }, reason: 'definition' }) + invalidation.handle({ authority: DESKTOP, selector: { kind: 'self' }, reason: 'run' }) + invalidation.handle({ authority: DESKTOP, selector: { kind: 'ssh', targetId: 'target-1' } }) + expect(invalidation.pending()).toBe(2) + expect(onInvalidated).not.toHaveBeenCalled() + + await Promise.resolve() + expect(onInvalidated).toHaveBeenCalledTimes(1) + expect(onInvalidated.mock.calls[0]?.[0]).toHaveLength(2) + // Why: one refresh per burst, so an entry's generation advances once, not three times. + expect(generationOf(cache, DESKTOP_SELF)).toBe(1) + }) + + it('folds a scoped event into the authority pass it is already covered by', async () => { + const cache = seeded() + const onInvalidated = vi.fn() + const invalidation = createAutomationHostInvalidation({ cache, onInvalidated }) + + invalidation.handle({ authority: DESKTOP }) + invalidation.handle({ authority: DESKTOP, selector: { kind: 'self' } }) + await Promise.resolve() + + expect(onInvalidated.mock.calls[0]?.[0]).toHaveLength(2) + expect(generationOf(cache, DESKTOP_SELF)).toBe(1) + }) + + it('reports a scoped event for a host nothing is caching yet', async () => { + const cache = seeded() + const uncached: StableAutomationCatalogRef = { + authority: RUNTIME, + selector: { kind: 'ssh', targetId: 'never-seen' } + } + const onInvalidated = vi.fn() + const invalidation = createAutomationHostInvalidation({ cache, onInvalidated }) + + // A create is the first thing that ever addresses its host: dropping the key + // for want of a stale entry is how the new row never appears. + invalidation.handle({ authority: RUNTIME, selector: { kind: 'ssh', targetId: 'never-seen' } }) + await Promise.resolve() + + expect(onInvalidated).toHaveBeenCalledWith([hostStableKey(uncached)]) + expect(cache.get(uncached)).toBeNull() + expect(generationOf(cache, RUNTIME_SELF)).toBe(0) + }) + + it('drops queued work once disposed', async () => { + const cache = seeded() + const onInvalidated = vi.fn() + const invalidation = createAutomationHostInvalidation({ cache, onInvalidated }) + invalidation.handle({ authority: DESKTOP }) + invalidation.dispose() + await Promise.resolve() + expect(onInvalidated).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-invalidation.ts b/src/renderer/src/components/automations/automation-host-invalidation.ts new file mode 100644 index 00000000000..77e7655f8b7 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-invalidation.ts @@ -0,0 +1,122 @@ +/** + * Turns `automationsChanged` events into cache invalidations. + * + * A scoped event invalidates its one entry; an older or unscoped event + * invalidates every entry of *that authority only*, never the whole cache — one + * chatty host must not evict the rows of every other host. Bursts collapse into + * a single microtask so a run that writes definition, run, and usage events in + * a row costs one refresh, and an old runtime's shared legacy request is issued + * once for the whole burst. + */ + +import { hostStableKey } from '../../../../shared/automation-owner-key' +import type { + StableAutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import { automationAuthorityCatalogKey } from './automation-host-catalog-types' +import type { AutomationHostCache } from './automation-host-cache' + +/** What changed on the host. Carried for diagnostics; every reason invalidates alike. */ +export type AutomationAuthorityChangeReason = 'definition' | 'run' | 'usage' + +/** The publisher payload plus the authority the subscriber resolved it from. */ +export type AutomationAuthorityChangeEvent = { + authority: StableAutomationAuthorityRef + selector?: { kind: 'self' } | { kind: 'ssh'; targetId: string } | { kind: 'orphan' } + reason?: AutomationAuthorityChangeReason +} + +export type AutomationHostInvalidationOptions = { + cache: AutomationHostCache + /** Called once per coalesced burst with the entries that need refetching. */ + onInvalidated?: (stableKeys: readonly string[]) => void + schedule?: (flush: () => void) => void +} + +export type AutomationHostInvalidation = { + handle: (event: AutomationAuthorityChangeEvent) => void + flush: () => void + pending: () => number + dispose: () => void +} + +function catalogRefFor(event: AutomationAuthorityChangeEvent): StableAutomationCatalogRef | null { + if (!event.selector) { + return null + } + if (event.selector.kind === 'ssh') { + return { + authority: event.authority, + selector: { kind: 'ssh', targetId: event.selector.targetId } + } + } + return event.selector.kind === 'orphan' + ? { authority: event.authority, selector: { kind: 'orphan' } } + : { authority: event.authority, selector: { kind: 'self' } } +} + +export function createAutomationHostInvalidation( + options: AutomationHostInvalidationOptions +): AutomationHostInvalidation { + const schedule = options.schedule ?? queueMicrotask + const scopedKeys = new Set() + const authorities = new Map() + let scheduled = false + let disposed = false + + const flush = (): void => { + scheduled = false + if (disposed) { + return + } + const invalidated = new Set() + for (const authority of authorities.values()) { + for (const key of options.cache.invalidateAuthority(authority)) { + invalidated.add(key) + } + } + authorities.clear() + for (const key of scopedKeys) { + if (invalidated.has(key)) { + continue + } + // A key with no entry has nothing stale to discard, but a create lands on + // exactly such a host — dropping it here is how a new row never appears. + // Report it either way and let the catalog decide whether it is fetchable. + if (options.cache.getByKey(key)) { + options.cache.invalidateKey(key) + } + invalidated.add(key) + } + scopedKeys.clear() + if (invalidated.size > 0) { + options.onInvalidated?.([...invalidated]) + } + } + + return { + handle: (event) => { + if (disposed) { + return + } + const ref = catalogRefFor(event) + if (ref) { + scopedKeys.add(hostStableKey(ref)) + } else { + authorities.set(automationAuthorityCatalogKey(event.authority), event.authority) + } + if (!scheduled) { + scheduled = true + schedule(flush) + } + }, + flush, + pending: () => scopedKeys.size + authorities.size, + dispose: () => { + disposed = true + scopedKeys.clear() + authorities.clear() + } + } +} diff --git a/src/renderer/src/components/automations/automation-host-list-rows.test.ts b/src/renderer/src/components/automations/automation-host-list-rows.test.ts new file mode 100644 index 00000000000..afe4720c316 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-list-rows.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import type { AutomationHostCacheEntry, AutomationHostRow } from './automation-host-cache-types' +import type { + AutomationHostCatalog, + AutomationHostCatalogEntry +} from './automation-host-catalog-types' +import type { AutomationHostFilterResolution } from './automation-host-filter-resolution' +import { + filterAutomationHostGroups, + resolveAutomationHostListRows +} from './automation-host-list-rows' + +function entry( + overrides: Partial & Pick +): AutomationHostCatalogEntry { + return { + owner: null, + stableKey: 'host:desktop:self', + label: 'This computer', + authorityLabel: 'This computer', + kind: 'self', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'connected', + querySupport: 'scoped', + ...overrides + } +} + +const DESKTOP_SELF = entry({ + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'self' } }, + owner: { authority: { kind: 'desktop' }, selector: { kind: 'self' } } +}) +const DESKTOP_ORPHAN = entry({ + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'orphan' } }, + stableKey: 'host:desktop:orphan', + label: 'Unassigned legacy automations', + kind: 'orphan' +}) +const RUNTIME_SELF = entry({ + stableRef: { authority: { kind: 'runtime', environmentId: 'gpu' }, selector: { kind: 'self' } }, + stableKey: 'host:runtime:gpu:self', + label: 'GPU box', + authorityLabel: 'GPU box', + owner: { + authority: { kind: 'runtime', environmentId: 'gpu', pairingRevision: 4 }, + selector: { kind: 'self' } + } +}) + +const RUNTIME_SSH = entry({ + stableRef: { + authority: { kind: 'runtime', environmentId: 'gpu' }, + selector: { kind: 'ssh', targetId: 'web-01' } + }, + stableKey: 'host:runtime:gpu:ssh:web-01', + label: 'web-01', + authorityLabel: 'GPU box', + kind: 'ssh', + owner: { + authority: { kind: 'runtime', environmentId: 'gpu', pairingRevision: 4 }, + selector: { kind: 'ssh', targetId: 'web-01', targetGeneration: 2 } + } +}) + +function automation(id: string, name = id): Automation { + return { id, name } as Automation +} + +function row(id: string, owner: AutomationHostRow['owner'], name = id): AutomationHostRow { + return { + automation: automation(id, name), + owner, + selector: owner ? { kind: 'self' } : { kind: 'orphan', issue: 'missing_repo' }, + usageSummary: null, + usageKnown: false + } +} + +function cached(rows: AutomationHostRow[], fetchedAt: number | null = 1): AutomationHostCacheEntry { + return { + data: rows, + fetchedAt, + attempt: 0, + requestGeneration: 0, + catalogGeneration: 0, + request: null, + error: null, + orphanCount: null + } +} + +function catalogOf(entries: AutomationHostCatalogEntry[]): AutomationHostCatalog { + return { + entries, + byStableKey: new Map(entries.map((candidate) => [candidate.stableKey, candidate])), + hydration: { + runtimeCatalogSettled: true, + desktopSshHydrated: true, + runtimeSshHydratedByEnvironmentId: new Map(), + savedRuntimeEnvironmentIds: new Set(), + orphanSettledAuthorityKeys: new Set(), + unavailableAuthorityKeys: new Set() + } + } +} + +const ALL_HOSTS: AutomationHostFilterResolution = { + effective: { kind: 'all' }, + entry: null, + status: 'all', + announceFallback: false +} + +describe('automation host list rows', () => { + it('reports nothing answered until a host has actually committed', () => { + const rows = resolveAutomationHostListRows({ + catalog: catalogOf([DESKTOP_SELF]), + resolution: ALL_HOSTS, + entry: () => cached([], null) + }) + + // Why this matters: the caller keeps its own list while this is false, so a + // never-answered host must not read as an empty one. + expect(rows.answered).toBe(false) + expect(rows.automations).toEqual([]) + }) + + it('keeps the owned copy when the orphan scope returns the same record', () => { + const owned = row('a-1', DESKTOP_SELF.owner) + const rows = resolveAutomationHostListRows({ + catalog: catalogOf([DESKTOP_SELF, DESKTOP_ORPHAN]), + resolution: ALL_HOSTS, + entry: (stableKey) => cached(stableKey === 'host:desktop:self' ? [owned] : [row('a-1', null)]) + }) + + expect(rows.automations).toHaveLength(1) + expect(rows.capturedOwners.get(rows.rows[0]!.key)?.owner).toEqual(DESKTOP_SELF.owner) + }) + + it('keeps both authorities’ copies of one automation ID', () => { + // An ID is unique only inside its authority (doc:38), so the desktop and a + // runtime-owned SSH host may both hold `a-1`. Dropping either is a row the + // user stored and can no longer see. + const rows = resolveAutomationHostListRows({ + catalog: catalogOf([DESKTOP_SELF, RUNTIME_SSH]), + resolution: ALL_HOSTS, + entry: (stableKey) => + cached([ + stableKey === 'host:desktop:self' + ? row('a-1', DESKTOP_SELF.owner, 'Nightly desktop') + : row('a-1', RUNTIME_SSH.owner, 'Nightly web-01') + ]) + }) + + expect(rows.automations.map((item) => item.name)).toEqual(['Nightly desktop', 'Nightly web-01']) + // Each copy keeps its own owner: a bare-ID map would hand both rows the last host's. + expect(rows.rows.map((item) => rows.capturedOwners.get(item.key)?.owner)).toEqual([ + DESKTOP_SELF.owner, + RUNTIME_SSH.owner + ]) + }) + + it("carries each row's host of origin so two authorities stay distinguishable", () => { + const rows = resolveAutomationHostListRows({ + catalog: catalogOf([DESKTOP_SELF, RUNTIME_SELF]), + resolution: ALL_HOSTS, + entry: (stableKey) => + cached([ + stableKey === 'host:desktop:self' + ? row('local-1', DESKTOP_SELF.owner) + : row('gpu-1', RUNTIME_SELF.owner) + ]) + }) + + expect(rows.rows.map((item) => [item.automation.id, item.hostLabel])).toEqual([ + ['local-1', 'This computer'], + ['gpu-1', 'GPU box'] + ]) + expect(rows.capturedOwners.get(rows.rows[1]!.key)?.owner).toEqual(RUNTIME_SELF.owner) + expect(rows.groups.map((group) => group.authorityKey)).toEqual([ + 'authority:desktop', + 'authority:runtime:gpu' + ]) + }) + + it('applies the search to grouped rows but keeps every host on screen', () => { + const rows = resolveAutomationHostListRows({ + catalog: catalogOf([DESKTOP_SELF, RUNTIME_SELF]), + resolution: ALL_HOSTS, + entry: (stableKey) => + cached([ + stableKey === 'host:desktop:self' + ? row('local-1', DESKTOP_SELF.owner) + : row('gpu-1', RUNTIME_SELF.owner) + ]) + }) + const visible = rows.rows + .filter((item) => item.automation.id === 'gpu-1') + .map((item) => item.key) + const filtered = filterAutomationHostGroups(rows.groups, new Set(visible)) + + // Why every host survives: a query hiding a host's rows is not the host going away. + expect(filtered).toHaveLength(2) + expect(filtered[0].hosts[0].rows).toEqual([]) + expect(filtered[1].hosts[0].rows.map((item) => item.automation.id)).toEqual(['gpu-1']) + }) + + it('renders one host without group headers', () => { + const rows = resolveAutomationHostListRows({ + catalog: catalogOf([DESKTOP_SELF, RUNTIME_SELF]), + resolution: { + effective: { kind: 'host', host: DESKTOP_SELF.stableRef }, + entry: DESKTOP_SELF, + status: 'ready', + announceFallback: false + }, + entry: () => cached([row('a-1', DESKTOP_SELF.owner)]) + }) + + expect(rows.groups).toEqual([]) + expect(rows.automations.map((item) => item.id)).toEqual(['a-1']) + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-list-rows.ts b/src/renderer/src/components/automations/automation-host-list-rows.ts new file mode 100644 index 00000000000..68b37e34431 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-list-rows.ts @@ -0,0 +1,169 @@ +/** + * Turns cached per-host rows into the list the page renders. + * + * The rows keep their host of origin all the way through, because under All + * hosts two authorities can hold records with the same name and only the + * originating host explains which is which. `answered` is the honest gate: until + * some host in view has actually committed a response, this projection has + * nothing to say and the caller keeps whatever it already had. + */ + +import type { Automation } from '../../../../shared/automations-types' +import type { AutomationHostCacheEntry } from './automation-host-cache-types' +import type { + AutomationHostCatalog, + AutomationHostCatalogEntry +} from './automation-host-catalog-types' +import { automationAuthorityCatalogKey } from './automation-host-catalog-types' +import type { AutomationHostFilterResolution } from './automation-host-filter-resolution' +import { + captureAutomationOwners, + type AutomationCapturedOwner, + type AutomationCapturedRow +} from './automation-captured-owner' +import { + automationAuthorityRecordKey, + automationListRowKey, + type AutomationListRow +} from './automation-list-row-identity' + +export type AutomationHostGroupHost = { + entry: AutomationHostCatalogEntry + rows: readonly AutomationListRow[] +} + +export type AutomationHostGroup = { + authorityKey: string + authorityLabel: string + hosts: readonly AutomationHostGroupHost[] +} + +export type AutomationHostListRows = { + /** + * Catalog-ordered, deduplicated per authority. Each row carries its own host + * label and usage, because two authorities may hold the same automation ID and + * a map keyed by that ID can only describe one of them. + */ + rows: readonly AutomationListRow[] + /** The same rows' records, for callers that only need the automations. */ + automations: readonly Automation[] + /** Keyed by `AutomationListRow.key`; a bare ID cannot name one of two hosts' copies. */ + capturedOwners: ReadonlyMap + /** Populated only under All hosts; a single-host view needs no group headers. */ + groups: readonly AutomationHostGroup[] + /** False until a host in view committed a response, cached or fresh. */ + answered: boolean +} + +export type AutomationHostListRowsInput = { + catalog: AutomationHostCatalog + resolution: AutomationHostFilterResolution + entry: (stableKey: string) => AutomationHostCacheEntry | null +} + +const EMPTY_ROWS: AutomationHostListRows = { + rows: [], + automations: [], + capturedOwners: new Map(), + groups: [], + answered: false +} + +export type AutomationHostGroupVisibleHost = AutomationHostGroupHost & { + /** Rows the host had before the query, so an emptied group can name the query as the cause. */ + hostRowCount: number +} + +export type AutomationHostVisibleGroup = Omit & { + hosts: readonly AutomationHostGroupVisibleHost[] +} + +/** + * Applies the list search to the grouped view. Empty groups are kept: a host + * whose rows the query hid still owes the user its status row, and dropping it + * would make a search look like a host disappearing. + */ +export function filterAutomationHostGroups( + groups: readonly AutomationHostGroup[], + visibleRowKeys: ReadonlySet +): readonly AutomationHostVisibleGroup[] { + return groups.map((group) => ({ + ...group, + hosts: group.hosts.map((host) => ({ + ...host, + hostRowCount: host.rows.length, + rows: host.rows.filter((row) => visibleRowKeys.has(row.key)) + })) + })) +} + +/** Which hosts the current filter puts on screen, in catalog order. */ +export function automationHostEntriesInView( + input: Pick +): readonly AutomationHostCatalogEntry[] { + if (input.resolution.effective.kind === 'all') { + return input.catalog.entries + } + return input.resolution.entry ? [input.resolution.entry] : [] +} + +export function resolveAutomationHostListRows( + input: AutomationHostListRowsInput +): AutomationHostListRows { + const entries = automationHostEntriesInView(input) + if (entries.length === 0) { + return EMPTY_ROWS + } + const rows: AutomationListRow[] = [] + const seen = new Set() + const groups: AutomationHostGroup[] = [] + const groupByKey = new Map() + const capturedRows: AutomationCapturedRow[] = [] + let answered = false + + for (const entry of entries) { + const cached = input.entry(entry.stableKey) + if (cached && cached.fetchedAt !== null) { + answered = true + } + const authorityKey = automationAuthorityCatalogKey(entry.stableRef.authority) + const hostRows: AutomationListRow[] = [] + for (const row of cached?.data ?? []) { + // Scoped to the authority: another authority's identically named record is + // a different record, and dropping it would hide a row the user stored. + const recordKey = automationAuthorityRecordKey(authorityKey, row.automation.id) + if (seen.has(recordKey)) { + continue + } + seen.add(recordKey) + const listRow: AutomationListRow = { + key: automationListRowKey(entry.stableKey, row.automation.id), + automation: row.automation, + catalogRef: entry.stableRef, + hostLabel: entry.label, + usageSummary: row.usageSummary ?? null + } + capturedRows.push({ rowKey: listRow.key, row }) + rows.push(listRow) + hostRows.push(listRow) + } + let group = groupByKey.get(authorityKey) + if (!group) { + group = { authorityKey, authorityLabel: entry.authorityLabel, hosts: [] } + groupByKey.set(authorityKey, group) + groups.push(group) + } + group.hosts.push({ entry, rows: hostRows }) + } + + return { + rows, + automations: rows.map((row) => row.automation), + // Catalog order decides duplicates, and it places an authority's Self and + // SSH hosts before its orphan bucket — so a record keeps its owner rather + // than the ownerless copy the orphan scope also returns. + capturedOwners: captureAutomationOwners(capturedRows), + groups: input.resolution.effective.kind === 'all' ? groups : [], + answered + } +} diff --git a/src/renderer/src/components/automations/automation-host-orphan-entry.test.ts b/src/renderer/src/components/automations/automation-host-orphan-entry.test.ts new file mode 100644 index 00000000000..87ff14a8c32 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-orphan-entry.test.ts @@ -0,0 +1,216 @@ +/** + * How an authority's orphan bucket becomes visible at all. + * + * Two independent ways it fails to appear, and fixing one leaves the other: a + * scoped answer that omits `orphanCount` must stay unsettled rather than commit + * a fabricated zero, and an old runtime's unscoped answer must report the count + * it just computed so the entry can bootstrap without an orphan request that + * only exists once the entry does. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import { AUTOMATION_ORPHAN_ISSUES } from '../../../../shared/automation-list-scope' +import { validateAutomationListResponse } from '../../../../shared/automation-list-response' +import type { AutomationHostFilter } from '../../../../shared/automation-host-filter' +import { hostStableKey } from '../../../../shared/automation-owner-key' +import type { + StableAutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import { createAutomationHostCache } from './automation-host-cache' +import { createAutomationHostQueryController } from './automation-host-cache-controller' +import { + AUTOMATION_ORPHAN_ENTRY_LABEL, + buildAutomationHostCatalog +} from './automation-host-catalog' +import { resetAutomationHostCatalogGenerationsForTests } from './automation-host-catalog-generation' +import { + automationAuthorityCatalogKey, + type AutomationCatalogSshMirrorInput, + type AutomationHostCatalog, + type AutomationHostQuerySupport +} from './automation-host-catalog-types' +import { resolveAutomationHostFilter } from './automation-host-filter-resolution' +import type { AutomationHostSchedulerTransport } from './automation-host-scheduler' +import type { ScopedAutomationList } from './automation-scoped-list-client' + +const RUNTIME: StableAutomationAuthorityRef = { kind: 'runtime', environmentId: 'env-1' } +const RUNTIME_ORPHAN: StableAutomationCatalogRef = { + authority: RUNTIME, + selector: { kind: 'orphan' } +} +const RUNTIME_ORPHAN_KEY = hostStableKey(RUNTIME_ORPHAN) +const RUNTIME_AUTHORITY_KEY = automationAuthorityCatalogKey(RUNTIME) + +const EMPTY_SSH: AutomationCatalogSshMirrorInput = { + targetsHydrated: true, + targets: [], + removedTargetLabels: new Map(), + connectionStatusByTargetId: new Map() +} + +function catalogWith( + orphanCount: number | null, + querySupport: AutomationHostQuerySupport, + referencedStableKeys: readonly string[] = [] +): AutomationHostCatalog { + return buildAutomationHostCatalog({ + desktop: { label: 'Desktop', ssh: EMPTY_SSH }, + runtimes: [ + { + environmentId: 'env-1', + label: 'Runtime', + pairingRevision: 2, + authorityHealth: 'fresh', + querySupport, + ssh: EMPTY_SSH, + ...(orphanCount === null ? {} : { orphanCount }) + } + ], + runtimeCatalogSettled: true, + referencedStableKeys + }) +} + +/** A local record whose project this client cannot resolve; #49's `projectUnverified` case. */ +function unverifiedAutomation(id: string): Automation { + return { id, name: id, projectId: 'repo-gone', executionTargetType: 'local' } as Automation +} + +/** Goes through the real validator so an omitted `orphanCount` is omitted the way a host omits it. */ +function scopedAnswer(raw: Record): ScopedAutomationList { + const validation = validateAutomationListResponse(raw, { kind: 'self' }) + if (!validation.ok) { + throw new Error(validation.error.message) + } + return { ...validation.result, invalidRows: validation.invalidRows } +} + +function createController(transport: Partial) { + const cache = createAutomationHostCache({ + catalogGeneration: () => 0, + connectionGeneration: () => 0 + }) + const controller = createAutomationHostQueryController({ + cache, + // A runtime whose repos this client has not mirrored: every project lookup misses. + legacyPartitionContext: () => ({ + repoConnectionId: () => undefined, + projectsAuthoritative: false + }), + isVisible: () => true, + schedule: (flush) => flush(), + transport + }) + return { cache, controller } +} + +afterEach(() => { + resetAutomationHostCatalogGenerationsForTests() +}) + +describe('an authority that reports no orphan count', () => { + it('leaves the count unreported rather than parsing it as zero', () => { + const validation = validateAutomationListResponse( + { automations: [{ id: 'a1' }], items: [{ automationId: 'a1', selector: { kind: 'self' } }] }, + { kind: 'self' } + ) + expect( + validation.ok && validation.result.orphanCount, + 'an omitted orphanCount was committed as an authoritative zero' + ).toBeUndefined() + }) + + it('keeps the authority unsettled instead of claiming it has no orphans', async () => { + const listScoped = vi.fn(() => Promise.resolve(scopedAnswer({ automations: [], items: [] }))) + const { controller } = createController({ listScoped }) + + await controller.applyCatalog(catalogWith(null, 'scoped')) + + expect( + controller.authorityOrphanCount(RUNTIME), + 'an authority that reported no orphanCount was recorded as having zero orphans' + ).toBeNull() + controller.dispose() + }) + + it('retains a persisted orphan selection as loading rather than dropping it', async () => { + const listScoped = vi.fn(() => Promise.resolve(scopedAnswer({ automations: [], items: [] }))) + const { controller } = createController({ listScoped }) + await controller.applyCatalog(catalogWith(null, 'scoped')) + + const catalog = catalogWith(controller.authorityOrphanCount(RUNTIME), 'scoped') + expect( + catalog.hydration.orphanSettledAuthorityKeys.has(RUNTIME_AUTHORITY_KEY), + 'the orphan bucket settled on an authority that never reported a count' + ).toBe(false) + + const filter: AutomationHostFilter = { kind: 'host', host: RUNTIME_ORPHAN } + const resolution = resolveAutomationHostFilter({ filter, catalog }) + expect( + resolution.status, + 'a persisted orphan selection was dropped without an authoritative count' + ).toBe('loading') + expect(resolution.announceFallback).toBe(false) + controller.dispose() + }) +}) + +describe('an old runtime answering with one unscoped list', () => { + it('bootstraps the orphan entry from projectUnverified rows it never requested', async () => { + const listScoped = vi.fn(() => Promise.resolve(scopedAnswer({ automations: [], items: [] }))) + const listLegacy = vi.fn(() => + Promise.resolve([unverifiedAutomation('a1'), unverifiedAutomation('a2')]) + ) + const { cache, controller } = createController({ listScoped, listLegacy }) + + // The catalog cannot contain an orphan entry yet: nothing has reported one. + const first = catalogWith(null, 'legacy-unscoped') + expect(first.byStableKey.has(RUNTIME_ORPHAN_KEY)).toBe(false) + await controller.applyCatalog(first) + + expect( + controller.authorityOrphanCount(RUNTIME), + 'the unscoped answer classified rows as orphans but reported no count for the authority' + ).toBe(2) + + const second = catalogWith(controller.authorityOrphanCount(RUNTIME), 'legacy-unscoped') + const orphanEntry = second.byStableKey.get(RUNTIME_ORPHAN_KEY) + expect( + orphanEntry?.label, + 'the orphan entry never materialised, so the old runtime shows zero automations' + ).toBe(AUTOMATION_ORPHAN_ENTRY_LABEL) + expect(second.hydration.orphanSettledAuthorityKeys.has(RUNTIME_AUTHORITY_KEY)).toBe(true) + + await controller.applyCatalog(second) + const orphanRows = cache.getByKey(RUNTIME_ORPHAN_KEY)?.data ?? [] + expect(orphanRows.map((row) => row.automation.id)).toEqual(['a1', 'a2']) + expect(orphanRows.map((row) => row.selector)).toEqual([ + { kind: 'orphan', issue: AUTOMATION_ORPHAN_ISSUES.projectUnverified }, + { kind: 'orphan', issue: AUTOMATION_ORPHAN_ISSUES.projectUnverified } + ]) + controller.dispose() + }) + + // Already worked before the bootstrap fix: a persisted orphan selection puts its + // own entry in the catalog, which is what got the orphan request sent at all. + it('settles a selected orphan entry the selection itself put in the catalog', async () => { + const listScoped = vi.fn(() => Promise.resolve(scopedAnswer({ automations: [], items: [] }))) + const listLegacy = vi.fn(() => Promise.resolve([unverifiedAutomation('a1')])) + const { controller } = createController({ listScoped, listLegacy }) + + const filter: AutomationHostFilter = { kind: 'host', host: RUNTIME_ORPHAN } + await controller.applyCatalog(catalogWith(null, 'legacy-unscoped', [RUNTIME_ORPHAN_KEY])) + + const catalog = catalogWith(controller.authorityOrphanCount(RUNTIME), 'legacy-unscoped', [ + RUNTIME_ORPHAN_KEY + ]) + const resolution = resolveAutomationHostFilter({ filter, catalog }) + expect( + resolution.status, + 'a persisted orphan selection stayed loading forever after an old-server unscoped list' + ).toBe('ready') + controller.dispose() + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-picker-groups.test.ts b/src/renderer/src/components/automations/automation-host-picker-groups.test.ts new file mode 100644 index 00000000000..bf4632c607f --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-picker-groups.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { orderAutomationHostCatalogEntries } from './automation-host-catalog-order' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import { + ALL_HOSTS_OPTION_VALUE, + automationHostFilterForEntry, + automationHostSearchText, + groupAutomationHostEntriesByAuthority +} from './automation-host-picker-groups' + +function desktopSsh(targetId: string, label: string): AutomationHostCatalogEntry { + return { + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId } }, + owner: null, + stableKey: `host:desktop:ssh:${targetId}`, + label, + authorityLabel: 'This computer', + kind: 'ssh', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'connected', + querySupport: 'scoped' + } +} + +function runtimeSelf(environmentId: string, label: string): AutomationHostCatalogEntry { + return { + stableRef: { authority: { kind: 'runtime', environmentId }, selector: { kind: 'self' } }, + owner: null, + stableKey: `host:runtime:${environmentId}:self`, + label, + authorityLabel: label, + kind: 'self', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'connected', + querySupport: 'scoped' + } +} + +describe('automation host picker groups', () => { + it('groups by authority while preserving the shared catalog order', () => { + const entries = [ + runtimeSelf('env-b', 'Build box'), + desktopSsh('t2', 'web-02'), + desktopSsh('t1', 'web-01'), + runtimeSelf('env-a', 'Alpha box') + ] + const groups = groupAutomationHostEntriesByAuthority(entries) + + expect(groups.map((group) => group.authorityLabel)).toEqual([ + 'This computer', + 'Alpha box', + 'Build box' + ]) + expect(groups.flatMap((group) => group.entries).map((entry) => entry.stableKey)).toEqual( + orderAutomationHostCatalogEntries(entries).map((entry) => entry.stableKey) + ) + }) + + it('keeps two authorities with equal target ids in separate groups', () => { + const collidingRuntime: AutomationHostCatalogEntry = { + ...desktopSsh('t1', 'web-01'), + stableRef: { + authority: { kind: 'runtime', environmentId: 'env-a' }, + selector: { kind: 'ssh', targetId: 't1' } + }, + stableKey: 'host:runtime:env-a:ssh:t1', + authorityLabel: 'Alpha box' + } + const groups = groupAutomationHostEntriesByAuthority([ + desktopSsh('t1', 'web-01'), + collidingRuntime + ]) + + expect(groups).toHaveLength(2) + expect(groups.map((group) => group.entries.length)).toEqual([1, 1]) + }) + + it('builds a stable filter that carries no incarnation', () => { + expect(automationHostFilterForEntry(desktopSsh('t1', 'web-01'))).toEqual({ + kind: 'host', + host: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 't1' } } + }) + }) + + it('uses an All hosts sentinel that cannot collide with a host stable key', () => { + const keys = [desktopSsh('t1', 'web-01'), runtimeSelf('env-a', 'Alpha box')].map( + (entry) => entry.stableKey + ) + expect(keys).not.toContain(ALL_HOSTS_OPTION_VALUE) + expect(keys.every((key) => key.startsWith('host:'))).toBe(true) + }) + + it('indexes search text over both the host and its authority', () => { + expect(automationHostSearchText(runtimeSelf('env-a', 'Alpha Box'))).toBe('alpha box alpha box') + expect(automationHostSearchText(desktopSsh('t1', 'WEB-01'))).toBe('this computer web-01') + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-picker-groups.ts b/src/renderer/src/components/automations/automation-host-picker-groups.ts new file mode 100644 index 00000000000..55784c0b6a2 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-picker-groups.ts @@ -0,0 +1,51 @@ +import type { AutomationHostFilter } from '../../../../shared/automation-host-filter' +import { automationAuthorityCatalogKey } from './automation-host-catalog-types' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import { orderAutomationHostCatalogEntries } from './automation-host-catalog-order' + +/** Sentinel for the All hosts option; stable keys are `host:`-namespaced, so it cannot collide. */ +export const ALL_HOSTS_OPTION_VALUE = '__all_automation_hosts__' + +export type AutomationHostPickerGroup = { + authorityKey: string + authorityLabel: string + entries: AutomationHostCatalogEntry[] +} + +/** + * Groups by authority while preserving the one deterministic catalog order, so + * the picker and the All-hosts list never disagree about position. + */ +export function groupAutomationHostEntriesByAuthority( + entries: readonly AutomationHostCatalogEntry[] +): AutomationHostPickerGroup[] { + const ordered = orderAutomationHostCatalogEntries(entries) + const groups: AutomationHostPickerGroup[] = [] + const byKey = new Map() + + for (const entry of ordered) { + const authorityKey = automationAuthorityCatalogKey(entry.stableRef.authority) + let group = byKey.get(authorityKey) + if (!group) { + group = { authorityKey, authorityLabel: entry.authorityLabel, entries: [] } + byKey.set(authorityKey, group) + groups.push(group) + } + group.entries.push(entry) + } + return groups +} + +export function automationHostFilterForEntry( + entry: AutomationHostCatalogEntry +): AutomationHostFilter { + return { kind: 'host', host: entry.stableRef } +} + +/** + * Text a searchable picker matches against. Built once per entry by the caller, + * never inside a comparator. + */ +export function automationHostSearchText(entry: AutomationHostCatalogEntry): string { + return `${entry.authorityLabel} ${entry.label}`.toLocaleLowerCase() +} diff --git a/src/renderer/src/components/automations/automation-host-recovery.test.ts b/src/renderer/src/components/automations/automation-host-recovery.test.ts new file mode 100644 index 00000000000..8d6419fb128 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-recovery.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import { + runAutomationHostRecovery, + type AutomationHostRecoveryDeps +} from './automation-host-recovery' + +function entry( + overrides: Partial & Pick +): AutomationHostCatalogEntry { + return { + owner: null, + stableKey: 'host', + label: 'Host', + authorityLabel: 'Authority', + kind: 'self', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'connected', + querySupport: 'scoped', + ...overrides + } +} + +function deps() { + return { + retry: vi.fn(), + connectSshTarget: vi.fn(), + connectRuntimeEnvironment: vi.fn(), + openSettings: vi.fn() + } +} + +const DESKTOP_SSH = entry({ + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 't1' } }, + kind: 'ssh' +}) +const RUNTIME_SSH = entry({ + stableRef: { + authority: { kind: 'runtime', environmentId: 'gpu' }, + selector: { kind: 'ssh', targetId: 't1' } + }, + kind: 'ssh' +}) + +describe('automation host recovery', () => { + it('retries the host rather than the whole page', () => { + const target = deps() + runAutomationHostRecovery('retry', DESKTOP_SSH, target) + expect(target.retry).toHaveBeenCalledWith(DESKTOP_SSH) + }) + + it('dials the SSH target when the authority is fine', () => { + const target = deps() + runAutomationHostRecovery('reconnect', DESKTOP_SSH, target) + expect(target.connectSshTarget).toHaveBeenCalledWith('t1') + expect(target.connectRuntimeEnvironment).not.toHaveBeenCalled() + }) + + it('dials the runtime first when the server itself is unreachable', () => { + const target = deps() + // Why: an unreachable server cannot be asked to dial its own SSH target. + runAutomationHostRecovery( + 'reconnect', + { ...RUNTIME_SSH, authorityHealth: 'unavailable' }, + target + ) + expect(target.connectRuntimeEnvironment).toHaveBeenCalledWith('gpu') + expect(target.connectSshTarget).not.toHaveBeenCalled() + }) + + it('re-asks a desktop Self host, which has no transport to dial', () => { + const target = deps() + const desktopSelf = entry({ + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'self' } } + }) + runAutomationHostRecovery('reconnect', desktopSelf, target) + expect(target.retry).toHaveBeenCalledWith(desktopSelf) + }) + + it('sends a runtime to the servers pane, since nothing here updates a host', () => { + const target = deps() + runAutomationHostRecovery('update-server', RUNTIME_SSH, target) + expect(target.openSettings).toHaveBeenCalledWith({ pane: 'servers', repoId: null }) + }) + + it('sends a stale desktop SSH registration to the SSH pane instead', () => { + const target = deps() + runAutomationHostRecovery('update-server', DESKTOP_SSH, target) + expect(target.openSettings).toHaveBeenCalledWith({ pane: 'ssh', repoId: null }) + }) + + it('does nothing without an entry to act on', () => { + const target = deps() + runAutomationHostRecovery('retry', null, target) + expect(target.retry).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-recovery.ts b/src/renderer/src/components/automations/automation-host-recovery.ts new file mode 100644 index 00000000000..bc71f08ff18 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-recovery.ts @@ -0,0 +1,70 @@ +/** + * Turns a recovery verb into the one action that can actually fix it. + * + * The verbs come from `automation-host-status-descriptors.ts` and are never + * invented here; this module only decides *what* Retry, Reconnect, and Update + * server mean for a given host. Reconnect dials the thing that is down — the + * runtime transport or the SSH target — and Update server can only take the user + * to where the version is managed, because nothing in the app updates a host. + */ + +import type { SettingsNavigationTarget } from '@/lib/settings-navigation-types' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors' + +export type AutomationHostRecoveryDeps = { + /** Forces one fresh query for this host, bypassing TTL and retry cooldown. */ + retry: (entry: AutomationHostCatalogEntry) => void + connectSshTarget: (targetId: string) => void + connectRuntimeEnvironment: (environmentId: string) => void + openSettings: (target: SettingsNavigationTarget) => void +} + +/** Where a host's version is managed; the app cannot update a host on its own. */ +function versionSettingsTarget(entry: AutomationHostCatalogEntry): SettingsNavigationTarget { + if (entry.stableRef.authority.kind === 'runtime') { + return { pane: 'servers', repoId: null } + } + // A desktop SSH host with no registration generation is a stale registration, + // repaired by re-adding the target rather than by updating anything. + return { pane: entry.stableRef.selector.kind === 'ssh' ? 'ssh' : 'automations', repoId: null } +} + +function reconnect(entry: AutomationHostCatalogEntry, deps: AutomationHostRecoveryDeps): void { + const authority = entry.stableRef.authority + // Authority first: an unreachable server cannot be asked to dial its own targets. + if (authority.kind === 'runtime' && entry.authorityHealth === 'unavailable') { + deps.connectRuntimeEnvironment(authority.environmentId) + return + } + if (entry.stableRef.selector.kind === 'ssh') { + deps.connectSshTarget(entry.stableRef.selector.targetId) + return + } + if (authority.kind === 'runtime') { + deps.connectRuntimeEnvironment(authority.environmentId) + return + } + // Desktop Self has no transport to dial, so the only honest fallback is to re-ask. + deps.retry(entry) +} + +export function runAutomationHostRecovery( + action: AutomationHostRecoveryAction, + entry: AutomationHostCatalogEntry | null, + deps: AutomationHostRecoveryDeps +): void { + if (!entry) { + return + } + switch (action) { + case 'retry': + deps.retry(entry) + return + case 'reconnect': + reconnect(entry, deps) + return + case 'update-server': + deps.openSettings(versionSettingsTarget(entry)) + } +} diff --git a/src/renderer/src/components/automations/automation-host-request-instrumentation.ts b/src/renderer/src/components/automations/automation-host-request-instrumentation.ts new file mode 100644 index 00000000000..65ca85e483a --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-request-instrumentation.ts @@ -0,0 +1,108 @@ +/** + * The scheduler's side of the cache counters: it knows which fence a call was + * made under, and this knows what that means to the release gate. + * + * Keeping the translation here is what lets the scheduler stay a description of + * when to fetch rather than of how each fetch is counted. The rule it enforces + * on the scheduler's behalf: a legacy call is one request for its authority and + * belongs to no stable key, on every counter that has a keyed form. + */ + +import { hostStableKey } from '../../../../shared/automation-owner-key' +import type { StableAutomationCatalogRef } from '../../../../shared/automation-owner-ref' +import { + automationAuthorityCatalogKey, + type AutomationHostQuerySupport +} from './automation-host-catalog-types' +import type { AutomationHostRequestFence } from './automation-host-cache-types' +import { + automationHostDiagnostics, + measureAutomationHostResponseChars, + type AutomationHostDiagnostics, + type AutomationHostRequestTransport +} from './automation-host-diagnostics' + +export type AutomationHostRequestInstrumentation = { + /** Wall clock for request duration; kept apart from the scheduler's fake-time `now`. */ + startedAt: () => number + /** Called where the request is sent, not where it is queued: a cancelled job never counts. */ + request: (fence: AutomationHostRequestFence, transport: AutomationHostRequestTransport) => void + dedupeHit: (ref: StableAutomationCatalogRef, querySupport: AutomationHostQuerySupport) => void + response: ( + fence: AutomationHostRequestFence, + stableKey: string | null, + startedAt: number, + rows: number, + payload: unknown + ) => void + /** A request that threw still spent the time; the slowest refreshes are usually these. */ + failed: (fence: AutomationHostRequestFence, stableKey: string | null, startedAt: number) => void + /** One entry's share of a legacy answer, so per-host row counts survive the fan-out. */ + entryRows: (stableKey: string, rows: number) => void + entryDiscarded: (fence: AutomationHostRequestFence) => void + requestDiscarded: ( + fence: AutomationHostRequestFence, + stableKey: string | null, + outcome: 'commit' | 'failure' + ) => void +} + +export function createAutomationHostRequestInstrumentation( + diagnostics: AutomationHostDiagnostics = automationHostDiagnostics, + elapsed: () => number = () => performance.now() +): AutomationHostRequestInstrumentation { + return { + startedAt: elapsed, + request: (fence, transport) => { + diagnostics.recordRequest({ + authorityKey: fence.authorityKey, + stableKey: transport === 'legacy' ? null : fence.stableKey, + transport + }) + }, + // Keyed exactly as the request it joined, or per-key invariants over the two + // could not be written: a legacy host would read 0 requests against N hits. + dedupeHit: (ref, querySupport) => { + diagnostics.recordDedupeHit({ + authorityKey: automationAuthorityCatalogKey(ref.authority), + stableKey: querySupport === 'legacy-unscoped' ? null : hostStableKey(ref) + }) + }, + // Recorded before the fence decides: the payload crossed the wire either + // way, and the size/duration budgets are about what was spent, not kept. + response: (fence, stableKey, startedAt, rows, payload) => { + diagnostics.recordResponse({ + authorityKey: fence.authorityKey, + stableKey, + rows, + durationMs: elapsed() - startedAt, + approxSerializedChars: diagnostics.measuringSerializedChars() + ? measureAutomationHostResponseChars(payload) + : undefined + }) + }, + failed: (fence, stableKey, startedAt) => { + diagnostics.recordFailedRequest({ + authorityKey: fence.authorityKey, + stableKey, + durationMs: elapsed() - startedAt + }) + }, + entryRows: (stableKey, rows) => { + diagnostics.recordEntryRows({ stableKey, rows }) + }, + entryDiscarded: (fence) => { + diagnostics.recordDiscardedEntry({ + authorityKey: fence.authorityKey, + stableKey: fence.stableKey + }) + }, + requestDiscarded: (fence, stableKey, outcome) => { + diagnostics.recordDiscardedRequest({ + authorityKey: fence.authorityKey, + stableKey, + outcome + }) + } + } +} diff --git a/src/renderer/src/components/automations/automation-host-retry-schedule.ts b/src/renderer/src/components/automations/automation-host-retry-schedule.ts new file mode 100644 index 00000000000..18e37a152e7 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-retry-schedule.ts @@ -0,0 +1,75 @@ +/** + * What happens to an entry after its request fails: how the error is classified, + * whether another attempt is allowed, and when it fires. + * + * Separate from the scheduler because these timers outlive the request that + * armed them — a disposed controller has to cancel them, and a manual retry has + * to cancel the pending one rather than race it. + */ + +import type { AutomationHostCache } from './automation-host-cache' +import { classifyAutomationHostQueryError } from './automation-host-cache-error' +import type { PlannedAutomationHostTarget } from './automation-host-scheduler-plan' + +export type AutomationHostRetrySchedule = { + /** False when the fence rejected the failure, which is a discard like any other. */ + record: (target: PlannedAutomationHostTarget, error: unknown) => boolean + cancel: (stableKey: string) => void + dispose: () => void +} + +export type AutomationHostRetryScheduleOptions = { + cache: AutomationHostCache + now: () => number + random?: () => number + /** A hidden window records the failure but arms nothing; the next visit re-plans it. */ + isVisible: () => boolean + scheduleRetry: (run: () => void, delayMs: number) => () => void + /** Starts the next attempt; the scheduler owns how that request is sent. */ + retry: (target: PlannedAutomationHostTarget) => void +} + +export function createAutomationHostRetrySchedule( + options: AutomationHostRetryScheduleOptions +): AutomationHostRetrySchedule { + const timers = new Map void>() + + const cancel = (stableKey: string): void => { + timers.get(stableKey)?.() + timers.delete(stableKey) + } + + return { + cancel, + record: (target, error) => { + const attempt = (options.cache.getByKey(target.stableKey)?.attempt ?? 0) + 1 + const queryError = classifyAutomationHostQueryError(error, { + attempt, + now: options.now(), + random: options.random + }) + if (!options.cache.fail(target.fence, queryError)) { + return false + } + if (!queryError.retryable || !options.isVisible()) { + return true + } + const delay = Math.max(0, (queryError.retryAt ?? options.now()) - options.now()) + cancel(target.stableKey) + timers.set( + target.stableKey, + options.scheduleRetry(() => { + timers.delete(target.stableKey) + options.retry(target) + }, delay) + ) + return true + }, + dispose: () => { + for (const stop of timers.values()) { + stop() + } + timers.clear() + } + } +} diff --git a/src/renderer/src/components/automations/automation-host-scale-gates.test.ts b/src/renderer/src/components/automations/automation-host-scale-gates.test.ts new file mode 100644 index 00000000000..e85ed6d1ae5 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-scale-gates.test.ts @@ -0,0 +1,467 @@ +/** + * The design doc's release gates at the scale it states them: 1,000 automations + * across 50 hosts, driven through the real list client, cache, and scheduler. + * + * Every mechanism these gates rest on already has a unit test proving it works + * in isolation. What only a fixture this size can show is that they *compose* — + * that the four-slot pool still holds when each job also makes an unpooled + * capability probe, that the one-call-per-authority budget survives a fan-out + * over five entries, and that the commit fence still rejects everything stale + * when the rejections happen under real concurrency rather than one at a time. + * + * The wire is mocked at the RPC boundary and nowhere above it, so response + * validation, row projection, and legacy partitioning are all real work here. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import type { AutomationListScopeSelector } from '../../../../shared/automation-list-scope' +import { hostStableKey } from '../../../../shared/automation-owner-key' +import { AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { + AutomationAuthorityRef, + StableAutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import { automationAuthorityCatalogKey } from './automation-host-catalog-types' +import { createAutomationHostCache, type AutomationHostCache } from './automation-host-cache' +import { + automationHostDiagnostics, + type AutomationHostDiagnosticsSnapshot +} from './automation-host-diagnostics' +import { + createAutomationHostScheduler, + type AutomationHostFetchTarget +} from './automation-host-scheduler' +import { AUTOMATION_HOST_REQUEST_CONCURRENCY } from './automation-host-scheduler-queue' + +const callRuntimeRpc = vi.fn() +const getRuntimeEnvironmentStatus = vi.fn() + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: (...args: unknown[]) => callRuntimeRpc(...args), + getRuntimeEnvironmentStatus: (...args: unknown[]) => getRuntimeEnvironmentStatus(...args), + hasRuntimeRpcErrorCode: () => false +})) + +// 10 authorities x 5 entries = 50 hosts; 50 hosts x 20 = 1,000 automations. +// Half the authorities are old servers, because the legacy fan-out is where the +// per-authority call budget is easiest to lose. +const AUTHORITY_COUNT = 10 +const ENTRIES_PER_AUTHORITY = 5 +const AUTOMATIONS_PER_HOST = 20 +const SCOPED_AUTHORITIES = AUTHORITY_COUNT / 2 +const HOST_COUNT = AUTHORITY_COUNT * ENTRIES_PER_AUTHORITY +const AUTOMATION_COUNT = HOST_COUNT * AUTOMATIONS_PER_HOST +const SSH_TARGET_GENERATION = 3 +// Doc 417: a refresh at this size must not block the renderer for a frame budget's worth of work. +const LONG_TASK_BUDGET_MS = 50 + +function authorityAt(index: number): AutomationAuthorityRef { + return { kind: 'runtime', environmentId: `env-${index}`, pairingRevision: index + 1 } +} + +function stableAuthorityAt(index: number): StableAutomationAuthorityRef { + return { kind: 'runtime', environmentId: `env-${index}` } +} + +function isLegacyAuthority(index: number): boolean { + return index >= SCOPED_AUTHORITIES +} + +function environmentIndex(environmentId: string): number { + return Number(environmentId.slice('env-'.length)) +} + +function sshTargetId(authorityIndex: number, slot: number): string { + return `ssh-${authorityIndex}-${slot}` +} + +// Every authority carries all three selector kinds, because each takes a +// different route: orphan scopes carry no owner, and the legacy partition has to +// separate them from Self without any help from the server. +function refsForAuthority(authorityIndex: number): StableAutomationCatalogRef[] { + const authority = stableAuthorityAt(authorityIndex) + return [ + { authority, selector: { kind: 'self' } }, + { authority, selector: { kind: 'orphan' } }, + ...Array.from({ length: ENTRIES_PER_AUTHORITY - 2 }, (_unused, slot) => ({ + authority, + selector: { kind: 'ssh' as const, targetId: sshTargetId(authorityIndex, slot) } + })) + ] +} + +/** The host label an automation's id carries, so a misrouted row names its real owner. */ +function hostLabel(ref: StableAutomationCatalogRef): string { + return ref.selector.kind === 'ssh' ? ref.selector.targetId : ref.selector.kind +} + +function refForSelector( + index: number, + selector: AutomationListScopeSelector +): StableAutomationCatalogRef { + const authority = stableAuthorityAt(index) + if (selector.kind === 'ssh') { + return { authority, selector: { kind: 'ssh', targetId: selector.targetId } } + } + return selector.kind === 'orphan' + ? { authority, selector: { kind: 'orphan' } } + : { authority, selector: { kind: 'self' } } +} + +const ALL_REFS: StableAutomationCatalogRef[] = Array.from( + { length: AUTHORITY_COUNT }, + (_unused, index) => refsForAuthority(index) +).flat() + +function targetFor(ref: StableAutomationCatalogRef): AutomationHostFetchTarget { + const index = environmentIndex( + ref.authority.kind === 'runtime' ? ref.authority.environmentId : 'env-0' + ) + const authority = authorityAt(index) + return { + ref, + authority, + // An orphan entry has no executable owner, which is the whole reason it is + // one; the scheduler still scopes the request by the selector alone. + owner: + ref.selector.kind === 'ssh' + ? { + authority, + selector: { + kind: 'ssh', + targetId: ref.selector.targetId, + targetGeneration: SSH_TARGET_GENERATION + } + } + : ref.selector.kind === 'orphan' + ? null + : { authority, selector: { kind: 'self' } }, + querySupport: isLegacyAuthority(index) ? 'legacy-unscoped' : 'scoped' + } +} + +/** Encodes its host in the id, so a misattributed row is visible rather than merely counted. */ +function automationsForHost(ref: StableAutomationCatalogRef, index: number): Automation[] { + return Array.from({ length: AUTOMATIONS_PER_HOST }, (_unused, n) => { + const base = { + id: `auto-${index}-${hostLabel(ref)}-${n}`, + name: `automation ${n}`, + projectId: 'repo-1' + } + if (ref.selector.kind === 'ssh') { + return { + ...base, + executionTargetType: 'ssh', + executionTargetId: ref.selector.targetId + } as Automation + } + // What an old server's orphan looks like on the wire: a record it admits is + // scheduled somewhere it cannot speak for. + return ( + ref.selector.kind === 'orphan' + ? { ...base, executionTargetType: 'local', schedulerOwner: 'remote_host_service' } + : { ...base, executionTargetType: 'local' } + ) as Automation + }) +} + +function respondingSelector(selector: AutomationListScopeSelector): unknown { + if (selector.kind === 'ssh') { + return { + kind: 'ssh', + targetId: selector.targetId, + targetGeneration: selector.expectedTargetGeneration + } + } + return selector.kind === 'orphan' ? { kind: 'orphan', issue: '' } : { kind: 'self' } +} + +function scopedPayload(environmentId: string, selector: AutomationListScopeSelector): unknown { + const index = environmentIndex(environmentId) + const automations = automationsForHost(refForSelector(index, selector), index) + return { + automations, + // Echoing the requested selector is what a correct host does; the client + // rejects anything else, so this keeps the validation path honest. + items: automations.map((entry) => ({ + automationId: entry.id, + selector: respondingSelector(selector) + })), + orphanCount: selector.kind === 'orphan' ? AUTOMATIONS_PER_HOST : 0 + } +} + +/** One old server's whole authority in a single answer, exactly as it would arrive. */ +function legacyPayload(environmentId: string): unknown { + const index = environmentIndex(environmentId) + return { + automations: refsForAuthority(index).flatMap((ref) => automationsForHost(ref, index)) + } +} + +type WireActivity = { + inFlight: number + maxInFlight: number + scopedCalls: number + probes: number + legacyCallsByEnvironment: Map +} + +let wire: WireActivity +let onWireCall: ((environmentId: string) => void) | null + +function beginWireCall(environmentId: string): void { + wire.inFlight += 1 + wire.maxInFlight = Math.max(wire.maxInFlight, wire.inFlight) + onWireCall?.(environmentId) +} + +/** A relay round trip yields; without that nothing overlaps and the ceiling is trivially met. */ +async function roundTrip(environmentId: string, value: () => T): Promise { + beginWireCall(environmentId) + try { + await new Promise((resolve) => setTimeout(resolve, 0)) + return value() + } finally { + wire.inFlight -= 1 + } +} + +/** + * Longest gap between successive macrotasks: a synchronous block anywhere in the + * refresh delays the next timer by its own duration, which is the only way to + * observe "no long task" rather than to assert it about one function. + */ +function watchLongTasks(): () => number { + let worst = 0 + let last = performance.now() + let running = true + const tick = (): void => { + if (!running) { + return + } + const at = performance.now() + worst = Math.max(worst, at - last) + last = at + setTimeout(tick, 0) + } + setTimeout(tick, 0) + return () => { + running = false + return worst + } +} + +function harness(): { cache: AutomationHostCache; refresh: () => Promise } { + const cache = createAutomationHostCache({ + catalogGeneration: () => 0, + connectionGeneration: () => 0 + }) + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => ({ repoConnectionId: () => null, projectsAuthoritative: true }), + isVisible: () => true, + scheduleRetry: () => () => {} + }) + return { cache, refresh: () => scheduler.refresh(ALL_REFS.map(targetFor)) } +} + +function snapshot(): AutomationHostDiagnosticsSnapshot { + return automationHostDiagnostics.snapshot() +} + +function authorityCounters(snap: AutomationHostDiagnosticsSnapshot, index: number) { + return snap.byAuthority[automationAuthorityCatalogKey(stableAuthorityAt(index))] +} + +beforeEach(() => { + automationHostDiagnostics.reset() + wire = { + inFlight: 0, + maxInFlight: 0, + scopedCalls: 0, + probes: 0, + legacyCallsByEnvironment: new Map() + } + onWireCall = null + callRuntimeRpc.mockReset() + getRuntimeEnvironmentStatus.mockReset() + getRuntimeEnvironmentStatus.mockImplementation((environmentId: string) => { + wire.probes += 1 + return roundTrip(environmentId, () => ({ + capabilities: [AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY] + })) + }) + callRuntimeRpc.mockImplementation((target: unknown, _method: unknown, params: unknown) => { + const environmentId = (target as { environmentId: string }).environmentId + if (params === null) { + wire.legacyCallsByEnvironment.set( + environmentId, + (wire.legacyCallsByEnvironment.get(environmentId) ?? 0) + 1 + ) + return roundTrip(environmentId, () => legacyPayload(environmentId)) + } + wire.scopedCalls += 1 + const selector = (params as { selector: AutomationListScopeSelector }).selector + return roundTrip(environmentId, () => scopedPayload(environmentId, selector)) + }) +}) + +describe('one refresh of 50 hosts carrying 1,000 automations', () => { + it('never exceeds four requests in flight, however many hosts are waiting', async () => { + const { refresh } = harness() + + await refresh() + + expect(wire.maxInFlight).toBeGreaterThan(1) + expect(wire.maxInFlight).toBeLessThanOrEqual(AUTOMATION_HOST_REQUEST_CONCURRENCY) + }) + + // The gate a fan-out is most likely to break: five entries on one old server + // must still cost that server one call, not five. + it('asks each old server exactly once, and each modern host exactly once', async () => { + const { refresh } = harness() + + await refresh() + + const snap = snapshot() + expect(wire.scopedCalls).toBe(SCOPED_AUTHORITIES * ENTRIES_PER_AUTHORITY) + expect([...wire.legacyCallsByEnvironment.values()]).toEqual( + Array.from({ length: AUTHORITY_COUNT - SCOPED_AUTHORITIES }, () => 1) + ) + expect(snap.totals).toMatchObject({ + scopedRequests: SCOPED_AUTHORITIES * ENTRIES_PER_AUTHORITY, + legacyRequests: AUTHORITY_COUNT - SCOPED_AUTHORITIES + }) + for (let index = SCOPED_AUTHORITIES; index < AUTHORITY_COUNT; index += 1) { + expect(authorityCounters(snap, index).legacyRequests).toBe(1) + } + }) + + it('lands all 1,000 automations on the host each one belongs to', async () => { + const { cache, refresh } = harness() + + await refresh() + + let landed = 0 + for (const ref of ALL_REFS) { + const rows = cache.get(ref)?.data ?? [] + const index = environmentIndex( + ref.authority.kind === 'runtime' ? ref.authority.environmentId : 'env-0' + ) + const prefix = `auto-${index}-${hostLabel(ref)}-` + expect(rows).toHaveLength(AUTOMATIONS_PER_HOST) + // A row carrying another host's prefix is a misattribution, not a miscount. + expect(rows.every((row) => row.automation.id.startsWith(prefix))).toBe(true) + landed += rows.length + } + expect(landed).toBe(AUTOMATION_COUNT) + }) + + // Per-host row counts have to survive the legacy fan-out, or doc 416's numbers + // go missing for exactly the hosts whose payload is worst. + it('accounts for every row twice over: once per response and once per host', async () => { + const { refresh } = harness() + + await refresh() + + const snap = snapshot() + const perHost = Object.values(snap.byStableKey).reduce((sum, entry) => sum + entry.rows, 0) + expect(snap.totals.rows).toBe(AUTOMATION_COUNT) + expect(perHost).toBe(AUTOMATION_COUNT) + }) + + it('does no synchronous work long enough to drop a frame', async () => { + const { refresh } = harness() + const stop = watchLongTasks() + + await refresh() + + expect(stop()).toBeLessThan(LONG_TASK_BUDGET_MS) + }) +}) + +describe('relay cost the request pool cannot see', () => { + // The probe is deliberate and unpooled, so the honest number for a 50-host + // refresh is requests plus probes — not the pooled count on its own. + it('counts one capability probe for every scoped list, and none for a legacy one', async () => { + const { refresh } = harness() + + await refresh() + + const snap = snapshot() + const scopedEntries = SCOPED_AUTHORITIES * ENTRIES_PER_AUTHORITY + expect(wire.probes).toBe(scopedEntries) + expect(snap.totals.capabilityProbes).toBe(scopedEntries) + for (let index = 0; index < AUTHORITY_COUNT; index += 1) { + expect(authorityCounters(snap, index).capabilityProbes).toBe( + isLegacyAuthority(index) ? 0 : ENTRIES_PER_AUTHORITY + ) + } + // Scoped hosts cost two round trips each, so the pooled count alone reports + // barely half the traffic this refresh actually put on the relay. + expect(snap.totals.requests + snap.totals.capabilityProbes).toBe( + scopedEntries * 2 + (AUTHORITY_COUNT - SCOPED_AUTHORITIES) + ) + }) +}) + +describe('the commit fence under real concurrency', () => { + it('commits nothing from an authority that re-paired while its answer was in flight', async () => { + const { cache, refresh } = harness() + // One modern and one old server, so both the per-entry and the fan-out arm + // are fenced out while the other 40 hosts keep landing around them. + const stale = new Set(['env-0', `env-${AUTHORITY_COUNT - 1}`]) + const invalidated = new Set() + onWireCall = (environmentId) => { + if (!stale.has(environmentId) || invalidated.has(environmentId)) { + return + } + invalidated.add(environmentId) + cache.invalidateAuthority(stableAuthorityAt(environmentIndex(environmentId))) + } + + await refresh() + + const snap = snapshot() + for (const environmentId of stale) { + const index = environmentIndex(environmentId) + for (const ref of refsForAuthority(index)) { + expect(cache.get(ref)?.data ?? []).toEqual([]) + } + // The answer arrived and was thrown away, which is the gate's whole point. + expect(authorityCounters(snap, index).discardedCommits).toBeGreaterThanOrEqual(1) + } + // Everything else still landed: a fence that rejected the innocent would + // satisfy the stale-response gate and lose the user's rows doing it. + const survivors = ALL_REFS.filter( + (ref) => !stale.has(ref.authority.kind === 'runtime' ? ref.authority.environmentId : '') + ) + expect(survivors).toHaveLength(HOST_COUNT - stale.size * ENTRIES_PER_AUTHORITY) + for (const ref of survivors) { + expect(cache.get(ref)?.data ?? []).toHaveLength(AUTOMATIONS_PER_HOST) + } + }) + + it('leaves no stale row anywhere in the cache, not merely on its own host', async () => { + const { cache, refresh } = harness() + const invalidated = new Set() + onWireCall = (environmentId) => { + if (environmentId !== 'env-0' || invalidated.has(environmentId)) { + return + } + invalidated.add(environmentId) + cache.invalidateAuthority(stableAuthorityAt(0)) + } + + await refresh() + + for (const ref of ALL_REFS) { + const rows = cache.get(ref)?.data ?? [] + expect(rows.some((row) => row.automation.id.startsWith('auto-0-'))).toBe(false) + } + expect(snapshot().byStableKey[hostStableKey(ALL_REFS[0])].rows).toBeGreaterThan(0) + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-scheduler-plan.ts b/src/renderer/src/components/automations/automation-host-scheduler-plan.ts new file mode 100644 index 00000000000..b9e2c17f8bd --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-scheduler-plan.ts @@ -0,0 +1,68 @@ +/** + * Decides which of the requested entries actually get fetched this cycle, and + * captures the fence each one will have to commit under. + * + * Everything that makes a request *not* happen lives here: a duplicate ref, a + * still-fresh entry, one already in flight, and one inside its retry cooldown. + * The scheduler then only has to decide how to send what survives. + */ + +import { hostStableKey } from '../../../../shared/automation-owner-key' +import type { AutomationHostCache } from './automation-host-cache' +import type { AutomationHostRequestFence } from './automation-host-cache-types' +// Type-only, so it is erased before it could become an import cycle at runtime. +import type { AutomationHostFetchTarget } from './automation-host-scheduler' + +export type PlannedAutomationHostTarget = AutomationHostFetchTarget & { + stableKey: string + fence: AutomationHostRequestFence +} + +export type AutomationHostRequestPlanOptions = { + cache: AutomationHostCache + targets: readonly AutomationHostFetchTarget[] + /** Skips freshness and cooldown, and supersedes anything already in flight. */ + force: boolean + /** Manual All-host refresh: it bypasses TTL but never re-enqueues a known failure. */ + skipKnownFailures?: boolean + now: () => number +} + +export function planAutomationHostRequests({ + cache, + targets, + force, + skipKnownFailures = false, + now +}: AutomationHostRequestPlanOptions): PlannedAutomationHostTarget[] { + const planned = new Map() + for (const target of targets) { + const stableKey = hostStableKey(target.ref) + const existing = planned.get(stableKey) + if (existing) { + existing.priority = existing.priority === true || target.priority === true + continue + } + const entry = cache.getByKey(stableKey) + if (force) { + if (skipKnownFailures && entry?.error && !entry.error.retryable) { + continue + } + // Why: a replacement request must outrank the one it replaces, or the older + // answer lands last and overwrites the fresher rows it was meant to supersede. + if (entry?.request) { + cache.supersede(target.ref) + } + } else { + if (cache.freshness(target.ref) === 'fresh' || entry?.request) { + continue + } + const error = entry?.error + if (error && (!error.retryable || (error.retryAt !== null && error.retryAt > now()))) { + continue + } + } + planned.set(stableKey, { ...target, stableKey, fence: cache.beginRequest(target.ref) }) + } + return [...planned.values()] +} diff --git a/src/renderer/src/components/automations/automation-host-scheduler-queue.ts b/src/renderer/src/components/automations/automation-host-scheduler-queue.ts new file mode 100644 index 00000000000..41e4161b726 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-scheduler-queue.ts @@ -0,0 +1,102 @@ +/** + * The four-slot request pool every authority call goes through. + * + * Priority work (the selected host, and Desktop + Self) jumps the queue, and + * queued work that the catalog has made obsolete is dropped before it is sent — + * transport already in flight is left to finish and rejected by the commit fence + * instead, because cancelling it would not un-send it. + */ + +export const AUTOMATION_HOST_REQUEST_CONCURRENCY = 4 + +export type AutomationHostPoolJob = { + priority?: boolean + run: () => Promise + /** Runs instead of `run` when the job is dropped before it starts. */ + cancel?: () => void +} + +export type AutomationHostRequestPool = { + submit: (job: AutomationHostPoolJob) => Promise + cancelQueued: () => void + inFlight: () => number + queued: () => number + /** Resolves once nothing is queued or in flight. */ + idle: () => Promise +} + +type WaitingJob = { + job: AutomationHostPoolJob + settle: () => void +} + +export function createAutomationHostRequestPool( + concurrency: number = AUTOMATION_HOST_REQUEST_CONCURRENCY +): AutomationHostRequestPool { + const urgent: WaitingJob[] = [] + const waiting: WaitingJob[] = [] + const idleWaiters: (() => void)[] = [] + let active = 0 + + const pending = (): number => urgent.length + waiting.length + + const releaseIdle = (): void => { + if (active > 0 || pending() > 0) { + return + } + for (const waiter of idleWaiters.splice(0)) { + waiter() + } + } + + const pump = (): void => { + while (active < concurrency && pending() > 0) { + const next = urgent.shift() ?? waiting.shift() + if (!next) { + break + } + active += 1 + void next.job.run().then( + () => { + active -= 1 + next.settle() + pump() + releaseIdle() + }, + () => { + // Jobs own their error handling; the pool only tracks occupancy. + active -= 1 + next.settle() + pump() + releaseIdle() + } + ) + } + releaseIdle() + } + + return { + submit: (job) => + new Promise((resolve) => { + ;(job.priority === true ? urgent : waiting).push({ job, settle: resolve }) + pump() + }), + cancelQueued: () => { + for (const entry of [...urgent.splice(0), ...waiting.splice(0)]) { + // Settling alone resolves the submitter without ever running the job, so a + // dropped job must first hand back whatever it reserved on submission. + entry.job.cancel?.() + entry.settle() + } + releaseIdle() + }, + inFlight: () => active, + queued: () => pending(), + idle: () => + active === 0 && pending() === 0 + ? Promise.resolve() + : new Promise((resolve) => { + idleWaiters.push(resolve) + }) + } +} diff --git a/src/renderer/src/components/automations/automation-host-scheduler.test.ts b/src/renderer/src/components/automations/automation-host-scheduler.test.ts new file mode 100644 index 00000000000..7942eb49fe8 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-scheduler.test.ts @@ -0,0 +1,561 @@ +/** + * The scheduler's budget is the contract: four calls in flight, one unscoped + * call per old authority per cycle, and no run-history fetch to paper over the + * usage an old response cannot carry. + */ + +import { describe, expect, it, vi } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import type { AutomationListScopeSelector } from '../../../../shared/automation-list-scope' +import { hostStableKey } from '../../../../shared/automation-owner-key' +import type { + AutomationAuthorityRef, + StableAutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import { createAutomationHostCache, type AutomationHostCache } from './automation-host-cache' +import type { AutomationHostQuerySupport } from './automation-host-catalog-types' +import { + createAutomationHostScheduler, + type AutomationHostFetchTarget +} from './automation-host-scheduler' +import type { ScopedAutomationList } from './automation-scoped-list-client' + +const RUNTIME_AUTHORITY: AutomationAuthorityRef = { + kind: 'runtime', + environmentId: 'env-1', + pairingRevision: 4 +} +const OTHER_AUTHORITY: AutomationAuthorityRef = { + kind: 'runtime', + environmentId: 'env-2', + pairingRevision: 1 +} + +function stableAuthority(authority: AutomationAuthorityRef): StableAutomationAuthorityRef { + return authority.kind === 'desktop' + ? { kind: 'desktop' } + : { kind: 'runtime', environmentId: authority.environmentId } +} + +function selfRef(authority: AutomationAuthorityRef): StableAutomationCatalogRef { + return { authority: stableAuthority(authority), selector: { kind: 'self' } } +} + +function sshRef(authority: AutomationAuthorityRef, targetId: string): StableAutomationCatalogRef { + return { authority: stableAuthority(authority), selector: { kind: 'ssh', targetId } } +} + +function target( + ref: StableAutomationCatalogRef, + options: { + authority?: AutomationAuthorityRef + querySupport?: AutomationHostQuerySupport + targetGeneration?: number + priority?: boolean + } = {} +): AutomationHostFetchTarget { + const authority = options.authority ?? RUNTIME_AUTHORITY + return { + ref, + authority, + owner: + ref.selector.kind === 'ssh' + ? { + authority, + selector: { + kind: 'ssh', + targetId: ref.selector.targetId, + targetGeneration: options.targetGeneration ?? 2 + } + } + : { authority, selector: { kind: 'self' } }, + querySupport: options.querySupport ?? 'scoped', + priority: options.priority + } +} + +function automation(id: string, overrides: Partial = {}): Automation { + return { + id, + name: id, + projectId: 'repo-1', + executionTargetType: 'local', + ...overrides + } as Automation +} + +function scopedResult(ids: readonly string[]): ScopedAutomationList { + return { + automations: ids.map((id) => automation(id)), + items: ids.map((id) => ({ automationId: id, selector: { kind: 'self' as const } })), + orphanCount: 0, + invalidRows: 0 + } +} + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } +} + +function createCache(now?: () => number): AutomationHostCache { + return createAutomationHostCache({ + now, + catalogGeneration: () => 0, + connectionGeneration: () => 0 + }) +} + +const LOCAL_REPO = { repoConnectionId: () => null, projectsAuthoritative: true } + +describe('automation host scheduler concurrency', () => { + it('keeps at most four authority calls in flight', async () => { + const cache = createCache() + const gates = Array.from({ length: 8 }, () => deferred()) + let started = 0 + const listScoped = vi.fn(() => gates[started++].promise) + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + transport: { listScoped } + }) + + const targets = Array.from({ length: 8 }, (_, index) => + target(sshRef(RUNTIME_AUTHORITY, `target-${index}`)) + ) + const done = scheduler.refresh(targets) + await Promise.resolve() + expect(scheduler.inFlight()).toBe(4) + expect(listScoped).toHaveBeenCalledTimes(4) + + for (const gate of gates) { + gate.resolve(scopedResult([])) + await Promise.resolve() + } + await done + expect(listScoped).toHaveBeenCalledTimes(8) + }) + + it('runs the selected host before the rest of the queue', async () => { + const cache = createCache() + const order: string[] = [] + const listScoped = vi.fn((_authority, selector: AutomationListScopeSelector) => { + order.push(selector.kind === 'ssh' ? selector.targetId : selector.kind) + return Promise.resolve(scopedResult([])) + }) + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + concurrency: 1, + transport: { listScoped } + }) + await scheduler.refresh([ + target(sshRef(RUNTIME_AUTHORITY, 'a')), + target(sshRef(RUNTIME_AUTHORITY, 'b')), + target(sshRef(RUNTIME_AUTHORITY, 'selected'), { priority: true }) + ]) + expect(order[0]).toBe('a') + expect(order[1]).toBe('selected') + }) + + it('shares one in-flight request between concurrent callers for the same owner', async () => { + const cache = createCache() + const gate = deferred() + const listScoped = vi.fn(() => gate.promise) + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + transport: { listScoped } + }) + const first = scheduler.refresh([target(selfRef(RUNTIME_AUTHORITY))]) + await Promise.resolve() + const second = scheduler.refresh([target(selfRef(RUNTIME_AUTHORITY))]) + gate.resolve(scopedResult(['a'])) + await Promise.all([first, second]) + expect(listScoped).toHaveBeenCalledTimes(1) + }) + + // Ordinary startup: the catalog settles a moment after the first refresh, so the + // re-apply cancels a queue full of hosts nothing else will ever ask for again. + it('re-fetches queued work that was cancelled before it was sent', async () => { + const cache = createCache() + const gates: { resolve: (value: ScopedAutomationList) => void }[] = [] + const queried: string[] = [] + let blocking = true + const listScoped = vi.fn((_authority: AutomationAuthorityRef, selector) => { + queried.push(selector.kind === 'ssh' ? selector.targetId : selector.kind) + if (!blocking) { + return Promise.resolve(scopedResult([])) + } + const gate = deferred() + gates.push(gate) + return gate.promise + }) + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + concurrency: 2, + transport: { listScoped } + }) + + const targets = Array.from({ length: 5 }, (_, index) => + target(sshRef(RUNTIME_AUTHORITY, `target-${index}`)) + ) + const first = scheduler.refresh(targets) + await Promise.resolve() + expect(queried).toEqual(['target-0', 'target-1']) + + scheduler.cancelQueued() + for (const gate of gates) { + gate.resolve(scopedResult([])) + } + await first + + blocking = false + await scheduler.refresh(targets) + expect(queried).toEqual(['target-0', 'target-1', 'target-2', 'target-3', 'target-4']) + }) + + it('clears the request marker of a queued job the pool dropped', async () => { + const cache = createCache() + const gate = deferred() + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + concurrency: 1, + transport: { listScoped: () => gate.promise } + }) + const queued = target(sshRef(RUNTIME_AUTHORITY, 'queued')) + const done = scheduler.refresh([target(selfRef(RUNTIME_AUTHORITY)), queued]) + await Promise.resolve() + + scheduler.cancelQueued() + expect(cache.get(queued.ref)?.request).toBeNull() + gate.resolve(scopedResult([])) + await done + }) + + // One legacy job answers for a whole authority, so cancelling it strands every + // entry in the group, not just the one that happened to submit it. + it('clears every marker of a cancelled legacy authority job', async () => { + const cache = createCache() + const gate = deferred() + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + concurrency: 1, + transport: { listScoped: () => gate.promise, listLegacy: () => Promise.resolve([]) } + }) + const legacy = { querySupport: 'legacy-unscoped' as const, authority: OTHER_AUTHORITY } + const group = [ + target(selfRef(OTHER_AUTHORITY), legacy), + target(sshRef(OTHER_AUTHORITY, 'target-1'), legacy) + ] + const done = scheduler.refresh([target(selfRef(RUNTIME_AUTHORITY)), ...group]) + await Promise.resolve() + + scheduler.cancelQueued() + expect(group.map((entry) => cache.get(entry.ref)?.request)).toEqual([null, null]) + gate.resolve(scopedResult([])) + await done + }) + + it('clears the marker of a target it has no way to address', async () => { + const cache = createCache() + const listScoped = vi.fn() + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + transport: { listScoped } + }) + // A ghost SSH entry: no registration generation, so no selector can be fenced. + const ghost = { ...target(sshRef(RUNTIME_AUTHORITY, 'ghost')), owner: null } + await scheduler.refresh([ghost]) + + expect(listScoped).not.toHaveBeenCalled() + expect(cache.get(ghost.ref)?.request).toBeNull() + }) + + it('drops queued work whose entry was invalidated before it was sent', async () => { + const cache = createCache() + const gate = deferred() + const listScoped = vi.fn(() => gate.promise) + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + concurrency: 1, + transport: { listScoped } + }) + const queued = target(sshRef(RUNTIME_AUTHORITY, 'queued')) + const done = scheduler.refresh([target(selfRef(RUNTIME_AUTHORITY)), queued]) + await Promise.resolve() + cache.invalidate(queued.ref) + gate.resolve(scopedResult([])) + await done + expect(listScoped).toHaveBeenCalledTimes(1) + }) +}) + +describe('automation host scheduler legacy path', () => { + it('makes one unscoped call per authority per cycle and partitions it', async () => { + const cache = createCache() + const listRuns = vi.fn() + vi.stubGlobal('window', { api: { automations: { listRuns } } }) + const listLegacy = vi.fn((authority: AutomationAuthorityRef) => + Promise.resolve( + authority.kind === 'runtime' && authority.environmentId === 'env-1' + ? [ + automation('self-1'), + automation('ssh-1', { + executionTargetType: 'ssh', + executionTargetId: 'target-1' + }), + automation('broken', { executionTargetType: 'ssh' }) + ] + : [automation('other-self')] + ) + ) + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + transport: { listLegacy } + }) + + const legacy = { querySupport: 'legacy-unscoped' as const } + await scheduler.refresh([ + target(selfRef(RUNTIME_AUTHORITY), legacy), + target(sshRef(RUNTIME_AUTHORITY, 'target-1'), legacy), + target( + { authority: stableAuthority(RUNTIME_AUTHORITY), selector: { kind: 'orphan' } }, + legacy + ), + target(selfRef(OTHER_AUTHORITY), { ...legacy, authority: OTHER_AUTHORITY }) + ]) + + expect(listLegacy).toHaveBeenCalledTimes(2) + const self = cache.get(selfRef(RUNTIME_AUTHORITY)) + expect(self?.data.map((row) => row.automation.id)).toEqual(['self-1']) + expect( + cache.get(sshRef(RUNTIME_AUTHORITY, 'target-1'))?.data.map((row) => row.automation.id) + ).toEqual(['ssh-1']) + const orphanEntry = cache.getByKey( + hostStableKey({ authority: stableAuthority(RUNTIME_AUTHORITY), selector: { kind: 'orphan' } }) + ) + expect(orphanEntry?.data.map((row) => row.automation.id)).toEqual(['broken']) + expect(orphanEntry?.orphanCount).toBe(1) + vi.unstubAllGlobals() + expect(listRuns).not.toHaveBeenCalled() + }) + + it('leaves legacy rows unfenced and their usage unavailable', async () => { + const cache = createCache() + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + transport: { listLegacy: () => Promise.resolve([automation('a')]) } + }) + await scheduler.refresh([ + target(selfRef(RUNTIME_AUTHORITY), { querySupport: 'legacy-unscoped' }) + ]) + const row = cache.get(selfRef(RUNTIME_AUTHORITY))?.data[0] + expect(row?.owner).toBeNull() + expect(row?.usageKnown).toBe(false) + expect(row?.usageSummary).toBeNull() + }) + + it('records an incompatible host without sending anything', async () => { + const cache = createCache() + const listScoped = vi.fn() + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + transport: { listScoped } + }) + await scheduler.refresh([target(selfRef(RUNTIME_AUTHORITY), { querySupport: 'incompatible' })]) + expect(listScoped).not.toHaveBeenCalled() + expect(cache.get(selfRef(RUNTIME_AUTHORITY))?.error).toMatchObject({ + code: 'incompatible', + retryable: false + }) + }) +}) + +describe('automation host scheduler revalidation', () => { + it('skips a fresh entry and refetches once it is stale or forced', async () => { + let clock = 1_000 + const cache = createCache(() => clock) + const listScoped = vi.fn(() => Promise.resolve(scopedResult([]))) + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + now: () => clock, + transport: { listScoped } + }) + const only = [target(selfRef(RUNTIME_AUTHORITY))] + await scheduler.refresh(only) + await scheduler.refresh(only) + expect(listScoped).toHaveBeenCalledTimes(1) + await scheduler.refresh(only, { force: true }) + expect(listScoped).toHaveBeenCalledTimes(2) + clock += 30_001 + await scheduler.refresh(only) + expect(listScoped).toHaveBeenCalledTimes(3) + }) + + it('retries a transient failure with jittered backoff and stops at the attempt cap', async () => { + let clock = 0 + const cache = createCache(() => clock) + const delays: number[] = [] + const listScoped = vi.fn(() => Promise.reject(new Error('runtime_unavailable'))) + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + now: () => clock, + random: () => 1, + transport: { listScoped }, + scheduleRetry: (run, delayMs) => { + delays.push(delayMs) + void Promise.resolve().then(run) + return () => {} + } + }) + await scheduler.refresh([target(selfRef(RUNTIME_AUTHORITY))]) + await new Promise((resolve) => setTimeout(resolve, 0)) + // Full jitter with a 1s base: 1s then 2s, and no third retry once the cap is reached. + expect(delays).toEqual([1_000, 2_000]) + expect(listScoped).toHaveBeenCalledTimes(3) + expect(cache.get(selfRef(RUNTIME_AUTHORITY))?.error).toMatchObject({ + code: 'authority_unavailable', + retryable: false + }) + }) + + it('does not schedule automatic retries while the page is hidden', async () => { + const cache = createCache() + const scheduleRetry = vi.fn(() => () => {}) + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => false, + transport: { listScoped: () => Promise.reject(new Error('runtime_unavailable')) }, + scheduleRetry + }) + await scheduler.refresh([target(selfRef(RUNTIME_AUTHORITY))]) + expect(scheduleRetry).not.toHaveBeenCalled() + }) + + it('lets a manual retry bypass the cooldown', async () => { + let clock = 0 + const cache = createCache(() => clock) + let calls = 0 + const listScoped = vi.fn(() => { + calls += 1 + return calls === 1 + ? Promise.reject(new Error('runtime_unavailable')) + : Promise.resolve(scopedResult(['a'])) + }) + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + now: () => clock, + random: () => 1, + transport: { listScoped }, + scheduleRetry: () => () => {} + }) + const only = target(selfRef(RUNTIME_AUTHORITY)) + await scheduler.refresh([only]) + // Still inside the backoff window, so an ordinary refresh is a no-op. + await scheduler.refresh([only]) + expect(listScoped).toHaveBeenCalledTimes(1) + await scheduler.retry(only) + expect(listScoped).toHaveBeenCalledTimes(2) + expect(cache.get(selfRef(RUNTIME_AUTHORITY))?.data).toHaveLength(1) + }) + + // Design doc: a manual All-host refresh bypasses TTL but does not enqueue + // requests already known to fail; that host keeps its own Retry instead. + it('leaves a permanently failed host out of a manual all-hosts refresh', async () => { + const cache = createCache() + let outcome: () => Promise = () => + Promise.reject(new Error('permission_denied')) + const listScoped = vi.fn(() => outcome()) + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + transport: { listScoped } + }) + const only = target(selfRef(RUNTIME_AUTHORITY)) + await scheduler.refresh([only]) + expect(cache.get(only.ref)?.error).toMatchObject({ + code: 'permission_denied', + retryable: false + }) + + await scheduler.refresh([only], { force: true, skipKnownFailures: true }) + expect(listScoped).toHaveBeenCalledTimes(1) + + outcome = () => Promise.resolve(scopedResult(['a'])) + await scheduler.retry(only) + expect(listScoped).toHaveBeenCalledTimes(2) + }) + + it('lets a forced refresh supersede the request it replaces', async () => { + const cache = createCache() + const gates = [deferred(), deferred()] + let started = 0 + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + transport: { listScoped: () => gates[started++].promise } + }) + const only = target(selfRef(RUNTIME_AUTHORITY)) + const first = scheduler.refresh([only]) + await Promise.resolve() + const second = scheduler.refresh([only], { force: true }) + await Promise.resolve() + + gates[1].resolve(scopedResult(['fresh'])) + gates[0].resolve(scopedResult(['stale'])) + await Promise.all([first, second]) + + expect(cache.get(only.ref)?.data.map((row) => row.automation.id)).toEqual(['fresh']) + }) + + it('discards a response whose entry was invalidated while in flight', async () => { + const cache = createCache() + const gate = deferred() + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + transport: { listScoped: () => gate.promise } + }) + const only = target(selfRef(RUNTIME_AUTHORITY)) + const done = scheduler.refresh([only]) + await Promise.resolve() + cache.invalidate(only.ref) + gate.resolve(scopedResult(['a'])) + await done + expect(cache.get(only.ref)?.data).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-scheduler.ts b/src/renderer/src/components/automations/automation-host-scheduler.ts new file mode 100644 index 00000000000..310e2bd0c8b --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-scheduler.ts @@ -0,0 +1,339 @@ +/** + * Decides which host entries to fetch, over which transport, and how often. + * + * Three rules shape everything here: at most four authority calls are in flight + * at once, an old runtime is asked once per cycle and its answer partitioned + * into every entry that wanted it, and a response commits only through the + * cache's fence — so work that was obsolete before it returned is discarded + * rather than rendered. + */ + +import type { Automation } from '../../../../shared/automations-types' +import type { AutomationListScopeSelector } from '../../../../shared/automation-list-scope' +import type { LegacyAutomationPartitionContext } from '../../../../shared/automation-legacy-list-partition' +import { hostStableKey } from '../../../../shared/automation-owner-key' +import type { + AutomationAuthorityRef, + AutomationOwnerRef, + StableAutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import { + automationAuthorityCatalogKey, + type AutomationHostQuerySupport +} from './automation-host-catalog-types' +import type { AutomationHostCache } from './automation-host-cache' +import type { AutomationHostRow } from './automation-host-cache-types' +import { + partitionLegacyAutomationHostRows, + toScopedAutomationHostRows +} from './automation-host-cache-rows' +import { + AutomationHostScopeUnsupportedError, + listLegacyAutomations, + listScopedAutomations, + type ScopedAutomationList +} from './automation-scoped-list-client' +import { + createAutomationHostRequestPool, + type AutomationHostRequestPool +} from './automation-host-scheduler-queue' +import type { AutomationHostDiagnostics } from './automation-host-diagnostics' +import { createAutomationHostRequestInstrumentation } from './automation-host-request-instrumentation' +import { createAutomationHostRetrySchedule } from './automation-host-retry-schedule' +import { + planAutomationHostRequests, + type PlannedAutomationHostTarget +} from './automation-host-scheduler-plan' + +export type AutomationHostFetchTarget = { + ref: StableAutomationCatalogRef + /** Incarnation-bearing authority; its pairing revision pins the request. */ + authority: AutomationAuthorityRef + /** The entry's fenceable owner, or null for a ghost/unhydrated/legacy entry. */ + owner: AutomationOwnerRef | null + querySupport: AutomationHostQuerySupport + /** The selected host and Desktop + Self jump the queue. */ + priority?: boolean +} + +export type AutomationHostSchedulerTransport = { + listScoped: ( + authority: AutomationAuthorityRef, + selector: AutomationListScopeSelector + ) => Promise + listLegacy: (authority: AutomationAuthorityRef) => Promise +} + +export type AutomationHostSchedulerOptions = { + cache: AutomationHostCache + legacyPartitionContext: ( + authority: StableAutomationAuthorityRef + ) => LegacyAutomationPartitionContext + transport?: Partial + concurrency?: number + now?: () => number + random?: () => number + isVisible?: () => boolean + scheduleRetry?: (run: () => void, delayMs: number) => () => void + diagnostics?: AutomationHostDiagnostics + /** Wall clock for request duration; separate from `now`, which fake time drives. */ + elapsed?: () => number +} + +export type AutomationHostRefreshOptions = { + /** Skips freshness and cooldown, and supersedes anything already in flight. */ + force?: boolean + /** Manual All-host refresh: leaves entries whose last error cannot succeed on a retry. */ + skipKnownFailures?: boolean +} + +export type AutomationHostScheduler = { + refresh: ( + targets: readonly AutomationHostFetchTarget[], + options?: AutomationHostRefreshOptions + ) => Promise + /** Manual retry: bypasses the cooldown and starts one new attempt. */ + retry: (target: AutomationHostFetchTarget) => Promise + cancelQueued: () => void + inFlight: () => number + dispose: () => void +} + +type PlannedTarget = PlannedAutomationHostTarget + +function scopeSelectorFor(target: AutomationHostFetchTarget): AutomationListScopeSelector | null { + const selector = target.ref.selector + if (selector.kind === 'self' || selector.kind === 'orphan') { + return { kind: selector.kind } + } + // Why: an SSH scope without a registration generation cannot be fenced, and a + // ghost target's records are returned by the authority's orphan scope anyway. + return target.owner?.selector.kind === 'ssh' + ? { + kind: 'ssh', + targetId: target.owner.selector.targetId, + expectedTargetGeneration: target.owner.selector.targetGeneration + } + : null +} + +export function createAutomationHostScheduler( + options: AutomationHostSchedulerOptions +): AutomationHostScheduler { + const cache = options.cache + const now = options.now ?? Date.now + const isVisible = options.isVisible ?? (() => document.visibilityState !== 'hidden') + const listScoped = options.transport?.listScoped ?? listScopedAutomations + const listLegacy = options.transport?.listLegacy ?? listLegacyAutomations + const scheduleRetry = + options.scheduleRetry ?? + ((run, delayMs) => { + const handle = setTimeout(run, delayMs) + return () => clearTimeout(handle) + }) + const pool: AutomationHostRequestPool = createAutomationHostRequestPool(options.concurrency) + const instrument = createAutomationHostRequestInstrumentation( + options.diagnostics, + options.elapsed + ) + let disposed = false + + const retries = createAutomationHostRetrySchedule({ + cache, + now, + random: options.random, + isVisible, + scheduleRetry, + retry: (target) => void refresh([target], { force: true }) + }) + + const stillCurrent = (target: PlannedTarget): boolean => + !disposed && + cache.getByKey(target.stableKey)?.requestGeneration === target.fence.requestGeneration + + /** + * Settles one answer against every entry it was fetched for, counting the two + * things the release gate distinguishes: each entry the fence rejected, and — + * only when nothing at all landed — the request whose answer was thrown away. + * A legacy answer that lands for eleven of twelve hosts is not a stale + * response; it is one response and one stale entry. + */ + const settleGroup = ( + live: readonly PlannedTarget[], + stableKey: string | null, + outcome: 'commit' | 'failure', + apply: (target: PlannedTarget) => boolean + ): void => { + let landed = 0 + for (const target of live) { + if (apply(target)) { + landed += 1 + } else { + instrument.entryDiscarded(target.fence) + } + } + if (landed === 0) { + instrument.requestDiscarded(live[0].fence, stableKey, outcome) + } + } + + const runScoped = async (target: PlannedTarget): Promise => { + if (!stillCurrent(target)) { + return + } + const selector = scopeSelectorFor(target) + if (!selector) { + // Nothing will ever answer for this entry, so its marker has to go too. + cache.abandonRequest(target.fence) + return + } + // Counted here rather than at submission: a queued job the catalog dropped + // is never sent, and a request that was never sent must not be in the count. + instrument.request(target.fence, 'scoped') + const startedAt = instrument.startedAt() + try { + const result = await listScoped(target.authority, selector) + const rows = toScopedAutomationHostRows(target.authority, result) + instrument.response(target.fence, target.stableKey, startedAt, rows.length, result) + settleGroup([target], target.stableKey, 'commit', (entry) => + cache.commit(entry.fence, { rows, orphanCount: result.orphanCount }) + ) + } catch (error) { + instrument.failed(target.fence, target.stableKey, startedAt) + settleGroup([target], target.stableKey, 'failure', (entry) => retries.record(entry, error)) + } + } + + const runLegacyAuthority = async (targets: readonly PlannedTarget[]): Promise => { + const live = targets.filter(stillCurrent) + if (live.length === 0) { + return + } + const authority = live[0].authority + instrument.request(live[0].fence, 'legacy') + const startedAt = instrument.startedAt() + try { + const automations = await listLegacy(authority) + // Attributed to the authority alone: one answer serves every entry in the group. + instrument.response(live[0].fence, null, startedAt, automations.length, automations) + const partition = partitionLegacyAutomationHostRows( + automations, + live.map((target) => target.ref), + options.legacyPartitionContext(live[0].ref.authority), + hostStableKey + ) + const rowsFor = (target: PlannedTarget): readonly AutomationHostRow[] => + partition.rowsByStableKey.get(target.stableKey) ?? [] + // The call belongs to the authority, but the rows belong to the hosts — + // without this, per-host row counts are missing exactly where payload is worst. + for (const target of live) { + instrument.entryRows(target.stableKey, rowsFor(target).length) + } + // The count is the whole authority's, so every entry reports it — as the scoped + // path does. Recording it only under an orphan request would need the orphan + // entry that only a reported count creates. + settleGroup(live, null, 'commit', (target) => + cache.commit(target.fence, { rows: rowsFor(target), orphanCount: partition.orphanCount }) + ) + } catch (error) { + instrument.failed(live[0].fence, null, startedAt) + settleGroup(live, null, 'failure', (target) => retries.record(target, error)) + } + } + + /** Hands back the markers of queued work the pool dropped before sending it. */ + const abandon = (targets: readonly PlannedTarget[]): void => { + for (const target of targets) { + cache.abandonRequest(target.fence) + } + } + + const refresh = async ( + targets: readonly AutomationHostFetchTarget[], + refreshOptions: AutomationHostRefreshOptions = {} + ): Promise => { + if (disposed) { + return + } + const force = refreshOptions.force === true + const joined: Promise[] = [] + const counted = new Set() + for (const target of force ? [] : targets) { + // plan() collapses a repeated ref into one entry, so counting the raw array + // would score two hits on the single promise they share. + const stableKey = hostStableKey(target.ref) + if (counted.has(stableKey)) { + continue + } + counted.add(stableKey) + const pending = cache.pendingRequest(target.ref) + if (!pending) { + continue + } + instrument.dedupeHit(target.ref, target.querySupport) + joined.push(pending) + } + const planned = planAutomationHostRequests({ + cache, + targets, + force, + skipKnownFailures: refreshOptions.skipKnownFailures === true, + now + }) + const incompatible = planned.filter((target) => target.querySupport === 'incompatible') + for (const target of incompatible) { + retries.record( + target, + new AutomationHostScopeUnsupportedError( + 'This host is too old to list automations. Update the HUB and try again.' + ) + ) + } + const fetchable = planned.filter((target) => target.querySupport !== 'incompatible') + const legacyByAuthority = new Map() + const submitted: Promise[] = [...joined] + for (const target of fetchable) { + if (target.querySupport === 'legacy-unscoped') { + const key = automationAuthorityCatalogKey(target.ref.authority) + legacyByAuthority.set(key, [...(legacyByAuthority.get(key) ?? []), target]) + continue + } + const request = pool.submit({ + priority: target.priority, + run: () => runScoped(target), + cancel: () => abandon([target]) + }) + cache.trackRequest(target.fence, request) + submitted.push(request) + } + for (const group of legacyByAuthority.values()) { + // One request for the whole authority; every entry in the group shares it. + const request = pool.submit({ + priority: group.some((target) => target.priority === true), + run: () => runLegacyAuthority(group), + cancel: () => abandon(group) + }) + for (const target of group) { + cache.trackRequest(target.fence, request) + } + submitted.push(request) + } + await Promise.all(submitted) + } + + return { + refresh, + retry: async (target) => { + retries.cancel(hostStableKey(target.ref)) + await refresh([{ ...target, priority: true }], { force: true }) + }, + cancelQueued: () => pool.cancelQueued(), + inFlight: () => pool.inFlight(), + dispose: () => { + disposed = true + pool.cancelQueued() + retries.dispose() + } + } +} diff --git a/src/renderer/src/components/automations/automation-host-scope-descriptors.ts b/src/renderer/src/components/automations/automation-host-scope-descriptors.ts new file mode 100644 index 00000000000..1f5fb04e4d1 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-scope-descriptors.ts @@ -0,0 +1,94 @@ +import { translate } from '@/i18n/i18n' +import type { + AutomationHostCatalogEntry, + AutomationHostScopeGap +} from './automation-host-catalog-types' +import type { AutomationHostStatusDescriptor } from './automation-host-status-descriptors' + +/** + * Copy for the query-contract axis. + * + * Every degraded entry shares one consequence — view only — but not one cause. + * A removed target, a target we have not verified since the connection dropped, + * and a server that advertises no host scoping each get their own sentence, so + * the badge never claims a server is old on the strength of missing evidence. + */ + +const VIEW_ONLY_LABEL = (): string => + translate('auto.components.automations.hostStatus.query.viewOnly', 'View only') + +function scopeGapDescriptor(gap: AutomationHostScopeGap): AutomationHostStatusDescriptor { + switch (gap) { + case 'target-removed': + return { + id: 'query-target-removed', + label: VIEW_ONLY_LABEL(), + description: translate( + 'auto.components.automations.hostStatus.query.targetRemovedDescription', + 'This host was removed, so its automations can no longer be edited or run from here.' + ), + tone: 'attention', + isDefault: false + } + case 'target-unverified': + // Why quiet: this is missing evidence, not a fault; it clears on reconnect. + return { + id: 'query-target-unverified', + label: VIEW_ONLY_LABEL(), + description: translate( + 'auto.components.automations.hostStatus.query.targetUnverifiedDescription', + 'This host has not been verified since its connection dropped, so its automations cannot be edited or run from here.' + ), + tone: 'quiet', + isDefault: false + } + case 'target-unregistered': + return { + id: 'query-target-unregistered', + label: VIEW_ONLY_LABEL(), + description: translate( + 'auto.components.automations.hostStatus.query.targetUnregisteredDescription', + 'This host has no registration record, so its automations cannot be edited or run from here.' + ), + tone: 'attention', + isDefault: false + } + case 'authority-unscoped': + return { + id: 'query-legacy-unscoped', + label: VIEW_ONLY_LABEL(), + description: translate( + 'auto.components.automations.hostStatus.query.legacyUnscopedDescription', + 'This server lists automations without host scoping, so they cannot be edited or run from here.' + ), + tone: 'attention', + isDefault: false + } + } +} + +/** Null for `scoped`; the degraded contracts stay distinct rather than sharing one badge. */ +export function hostScopeDescriptor( + entry: Pick +): AutomationHostStatusDescriptor | null { + if (entry.querySupport === 'scoped') { + return null + } + if (entry.querySupport === 'incompatible') { + return { + id: 'query-incompatible', + label: translate( + 'auto.components.automations.hostStatus.query.incompatible', + 'Update server' + ), + description: translate( + 'auto.components.automations.hostStatus.query.incompatibleDescription', + 'This server is too old to scope automations by host. Update it to manage automations here.' + ), + tone: 'attention', + isDefault: false + } + } + // An unscoped contract with no recorded cause can only be the authority's own. + return scopeGapDescriptor(entry.scopeGap ?? 'authority-unscoped') +} diff --git a/src/renderer/src/components/automations/automation-host-status-descriptors.test.ts b/src/renderer/src/components/automations/automation-host-status-descriptors.test.ts new file mode 100644 index 00000000000..9a9262b7130 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-status-descriptors.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest' +import type { + AutomationAuthorityHealth, + AutomationExecutionHealth, + AutomationHostCatalogEntry, + AutomationHostScopeGap +} from './automation-host-catalog-types' +import { hostScopeDescriptor } from './automation-host-scope-descriptors' +import { + authorityHealthDescriptor, + automationHostRecoveryActions, + executionHealthDescriptor +} from './automation-host-status-descriptors' + +const AUTHORITY_HEALTHS: AutomationAuthorityHealth[] = [ + 'loading', + 'fresh', + 'refreshing', + 'stale-error', + 'unavailable', + 'incompatible' +] +const SCOPE_GAPS: AutomationHostScopeGap[] = [ + 'target-removed', + 'target-unverified', + 'target-unregistered', + 'authority-unscoped' +] +const EXECUTION_HEALTHS: AutomationExecutionHealth[] = [ + 'connected', + 'connecting', + 'disconnected', + 'unavailable', + 'unknown' +] + +function entry(overrides: Partial = {}): AutomationHostCatalogEntry { + return { + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 't1' } }, + owner: null, + stableKey: 'host:desktop:ssh:t1', + label: 'web-01', + authorityLabel: 'This computer', + kind: 'ssh', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'connected', + querySupport: 'scoped', + ...overrides + } +} + +describe('automation host status descriptors', () => { + it('gives every authority health a distinct id and non-empty copy', () => { + const ids = AUTHORITY_HEALTHS.map((health) => authorityHealthDescriptor(health).id) + expect(new Set(ids).size).toBe(AUTHORITY_HEALTHS.length) + for (const health of AUTHORITY_HEALTHS) { + const descriptor = authorityHealthDescriptor(health) + expect(descriptor.label).not.toBe('') + expect(descriptor.description).not.toBe('') + } + }) + + it('gives every execution health a distinct id that cannot collide with an authority id', () => { + const authorityIds = new Set(AUTHORITY_HEALTHS.map((h) => authorityHealthDescriptor(h).id)) + const executionIds = EXECUTION_HEALTHS.map((h) => executionHealthDescriptor(h).id) + expect(new Set(executionIds).size).toBe(EXECUTION_HEALTHS.length) + expect(executionIds.some((id) => authorityIds.has(id))).toBe(false) + }) + + it('treats only fresh and connected as resting states', () => { + expect(AUTHORITY_HEALTHS.filter((h) => authorityHealthDescriptor(h).isDefault)).toEqual([ + 'fresh' + ]) + expect(EXECUTION_HEALTHS.filter((h) => executionHealthDescriptor(h).isDefault)).toEqual([ + 'connected' + ]) + }) + + it('never renders an unknown connection as a failure', () => { + expect(executionHealthDescriptor('unknown').tone).toBe('quiet') + }) + + it('badges only the degraded query contracts, and distinguishes them', () => { + expect(hostScopeDescriptor(entry())).toBeNull() + expect(hostScopeDescriptor(entry({ querySupport: 'incompatible' }))?.id).toBe( + 'query-incompatible' + ) + const gapIds = SCOPE_GAPS.map( + (scopeGap) => hostScopeDescriptor(entry({ querySupport: 'legacy-unscoped', scopeGap }))?.id + ) + expect(new Set(gapIds).size).toBe(SCOPE_GAPS.length) + }) + + it('falls back to the authority contract when no cause was recorded', () => { + expect(hostScopeDescriptor(entry({ querySupport: 'legacy-unscoped' }))?.id).toBe( + 'query-legacy-unscoped' + ) + }) + + it('blames the server only when the server itself answered without host scoping', () => { + const blamesServer = (scopeGap: AutomationHostScopeGap): boolean => + /server/i.test( + hostScopeDescriptor(entry({ querySupport: 'legacy-unscoped', scopeGap }))?.description ?? '' + ) + expect(SCOPE_GAPS.filter(blamesServer)).toEqual(['authority-unscoped']) + }) +}) + +describe('automation host recovery actions', () => { + it('resolves the two axes independently', () => { + expect( + automationHostRecoveryActions( + entry({ authorityHealth: 'stale-error', executionHealth: 'disconnected' }) + ) + ).toEqual({ authority: 'retry', execution: 'reconnect' }) + }) + + it('offers no action while a healthy host is merely loading', () => { + expect(automationHostRecoveryActions(entry({ authorityHealth: 'loading' }))).toEqual({ + authority: null, + execution: null + }) + }) + + it('asks for a server update when the authority itself lacks host scoping', () => { + expect( + automationHostRecoveryActions( + entry({ querySupport: 'legacy-unscoped', scopeGap: 'authority-unscoped' }) + ).authority + ).toBe('update-server') + expect( + automationHostRecoveryActions(entry({ authorityHealth: 'incompatible' })).authority + ).toBe('update-server') + }) + + it('never offers a server update for a host that was removed', () => { + expect( + automationHostRecoveryActions( + entry({ + catalogState: 'removed', + executionHealth: 'unavailable', + querySupport: 'legacy-unscoped', + scopeGap: 'target-removed' + }) + ) + ).toEqual({ authority: null, execution: null }) + }) + + it('offers Reconnect, not a server update, for a target with no verified registration', () => { + expect( + automationHostRecoveryActions( + entry({ + catalogState: 'unhydrated', + executionHealth: 'unknown', + querySupport: 'legacy-unscoped', + scopeGap: 'target-unverified' + }) + ) + ).toEqual({ authority: 'reconnect', execution: null }) + }) + + it('offers no repair for a live target the authority never registered', () => { + expect( + automationHostRecoveryActions( + entry({ querySupport: 'legacy-unscoped', scopeGap: 'target-unregistered' }) + ).authority + ).toBeNull() + }) + + it('reconnects rather than retries when the authority itself is unreachable', () => { + expect(automationHostRecoveryActions(entry({ authorityHealth: 'unavailable' })).authority).toBe( + 'reconnect' + ) + }) + + it('leaves the execution axis alone while the target is connecting', () => { + expect( + automationHostRecoveryActions(entry({ executionHealth: 'connecting' })).execution + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-status-descriptors.ts b/src/renderer/src/components/automations/automation-host-status-descriptors.ts new file mode 100644 index 00000000000..c73df4288ca --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-status-descriptors.ts @@ -0,0 +1,257 @@ +import { translate } from '@/i18n/i18n' +import type { + AutomationAuthorityHealth, + AutomationExecutionHealth, + AutomationHostCatalogEntry, + AutomationHostScopeGap +} from './automation-host-catalog-types' + +/** + * Copy and tone for the two independent health axes. They stay separate all the + * way to the badge: an authority that cannot be queried is a different failure + * from a target that is not connected, and collapsing them hides one of the two. + */ + +export type AutomationHostStatusTone = 'neutral' | 'quiet' | 'attention' | 'error' + +export type AutomationHostStatusDescriptor = { + /** Locale-independent id — the stable hook for tests and CSS, never displayed. */ + id: string + label: string + description: string + tone: AutomationHostStatusTone + /** The healthy resting value; callers hide these unless asked to show everything. */ + isDefault: boolean +} + +/** What a persistent failure offers the user; never a toast (design doc, UX section). */ +export type AutomationHostRecoveryAction = 'retry' | 'reconnect' | 'update-server' + +export type AutomationHostRecoveryActions = { + authority: AutomationHostRecoveryAction | null + execution: AutomationHostRecoveryAction | null +} + +export function authorityHealthDescriptor( + health: AutomationAuthorityHealth +): AutomationHostStatusDescriptor { + switch (health) { + case 'loading': + return { + id: 'authority-loading', + label: translate('auto.components.automations.hostStatus.authority.loading', 'Loading'), + description: translate( + 'auto.components.automations.hostStatus.authority.loadingDescription', + 'Loading automations from this host.' + ), + tone: 'quiet', + isDefault: false + } + case 'fresh': + return { + id: 'authority-fresh', + label: translate('auto.components.automations.hostStatus.authority.fresh', 'Up to date'), + description: translate( + 'auto.components.automations.hostStatus.authority.freshDescription', + 'Automations loaded from this host.' + ), + tone: 'neutral', + isDefault: true + } + case 'refreshing': + return { + id: 'authority-refreshing', + label: translate( + 'auto.components.automations.hostStatus.authority.refreshing', + 'Refreshing' + ), + description: translate( + 'auto.components.automations.hostStatus.authority.refreshingDescription', + 'Checking this host for changes.' + ), + tone: 'quiet', + isDefault: false + } + case 'stale-error': + // Why: the rows on screen are real, just not re-verified — say that, don't call them stale data. + return { + id: 'authority-stale-error', + label: translate( + 'auto.components.automations.hostStatus.authority.staleError', + 'Not refreshed' + ), + description: translate( + 'auto.components.automations.hostStatus.authority.staleErrorDescription', + 'Showing the automations last loaded from this host. The most recent refresh did not complete.' + ), + tone: 'attention', + isDefault: false + } + case 'unavailable': + return { + id: 'authority-unavailable', + label: translate( + 'auto.components.automations.hostStatus.authority.unavailable', + 'Unreachable' + ), + description: translate( + 'auto.components.automations.hostStatus.authority.unavailableDescription', + 'This host could not be reached, so its automations could not be loaded.' + ), + tone: 'error', + isDefault: false + } + case 'incompatible': + return { + id: 'authority-incompatible', + label: translate( + 'auto.components.automations.hostStatus.authority.incompatible', + 'Update server' + ), + description: translate( + 'auto.components.automations.hostStatus.authority.incompatibleDescription', + 'This server does not support host-scoped automation queries. Update it to load automations for this host.' + ), + tone: 'attention', + isDefault: false + } + } +} + +export function executionHealthDescriptor( + health: AutomationExecutionHealth +): AutomationHostStatusDescriptor { + switch (health) { + case 'connected': + return { + id: 'execution-connected', + label: translate('auto.components.automations.hostStatus.execution.connected', 'Connected'), + description: translate( + 'auto.components.automations.hostStatus.execution.connectedDescription', + 'This host is connected and can run automations.' + ), + tone: 'neutral', + isDefault: true + } + case 'connecting': + return { + id: 'execution-connecting', + label: translate( + 'auto.components.automations.hostStatus.execution.connecting', + 'Connecting' + ), + description: translate( + 'auto.components.automations.hostStatus.execution.connectingDescription', + 'Connecting to this host.' + ), + tone: 'quiet', + isDefault: false + } + case 'disconnected': + return { + id: 'execution-disconnected', + label: translate( + 'auto.components.automations.hostStatus.execution.disconnected', + 'Not connected' + ), + description: translate( + 'auto.components.automations.hostStatus.execution.disconnectedDescription', + 'Automations on this host cannot run until the connection is restored.' + ), + tone: 'attention', + isDefault: false + } + case 'unavailable': + return { + id: 'execution-unavailable', + label: translate( + 'auto.components.automations.hostStatus.execution.unavailable', + 'Unavailable' + ), + description: translate( + 'auto.components.automations.hostStatus.execution.unavailableDescription', + 'This execution target is no longer available.' + ), + tone: 'error', + isDefault: false + } + case 'unknown': + // Why: not knowing is not the same as being down; never render this as a failure. + return { + id: 'execution-unknown', + label: translate( + 'auto.components.automations.hostStatus.execution.unknown', + 'Connection unknown' + ), + description: translate( + 'auto.components.automations.hostStatus.execution.unknownDescription', + 'The connection state for this host has not loaded yet.' + ), + tone: 'quiet', + isDefault: false + } + } +} + +export function automationHostRecoveryActions( + entry: AutomationHostCatalogEntry +): AutomationHostRecoveryActions { + return { + authority: authorityRecoveryAction(entry), + execution: executionRecoveryAction(entry.executionHealth) + } +} + +function authorityRecoveryAction( + entry: AutomationHostCatalogEntry +): AutomationHostRecoveryAction | null { + if (entry.authorityHealth === 'incompatible' || entry.querySupport === 'incompatible') { + return 'update-server' + } + if (entry.authorityHealth === 'unavailable') { + // The transport to the authority is down, so retrying the query alone cannot help. + return 'reconnect' + } + if (entry.querySupport !== 'scoped') { + return scopeGapRecoveryAction(entry.scopeGap) + } + return entry.authorityHealth === 'stale-error' ? 'retry' : null +} + +/** Updating a server only repairs a server that actually answered without host scoping. */ +function scopeGapRecoveryAction( + gap: AutomationHostScopeGap | undefined +): AutomationHostRecoveryAction | null { + switch (gap) { + case 'target-unverified': + return 'reconnect' + case 'target-removed': + case 'target-unregistered': + return null + // An unscoped contract with no recorded cause can only be the authority's own. + case 'authority-unscoped': + case undefined: + return 'update-server' + } +} + +function executionRecoveryAction( + health: AutomationExecutionHealth +): AutomationHostRecoveryAction | null { + // `unavailable` is only reached for a removed target, which reconnecting cannot revive. + return health === 'disconnected' ? 'reconnect' : null +} + +export function recoveryActionLabel(action: AutomationHostRecoveryAction): string { + switch (action) { + case 'retry': + return translate('auto.components.automations.hostStatus.action.retry', 'Retry') + case 'reconnect': + return translate('auto.components.automations.hostStatus.action.reconnect', 'Reconnect') + case 'update-server': + return translate( + 'auto.components.automations.hostStatus.action.updateServer', + 'Update server' + ) + } +} diff --git a/src/renderer/src/components/automations/automation-list-empty-state.test.ts b/src/renderer/src/components/automations/automation-list-empty-state.test.ts new file mode 100644 index 00000000000..717f1053b64 --- /dev/null +++ b/src/renderer/src/components/automations/automation-list-empty-state.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from 'vitest' +import type { + AutomationExecutionHealth, + AutomationHostCatalogEntry +} from './automation-host-catalog-types' +import type { AutomationHostFilterResolution } from './automation-host-filter-resolution' +import { + resolveAutomationHostGroupEmptyState, + resolveAutomationListEmptyState, + type AutomationListEmptyStateInput, + type AutomationListHostGroupEmptyStateInput +} from './automation-list-empty-state' + +function entry(overrides: Partial = {}): AutomationHostCatalogEntry { + return { + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 't1' } }, + owner: null, + stableKey: 'host:desktop:ssh:t1', + label: 'web-01', + authorityLabel: 'This computer', + kind: 'ssh', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'connected', + querySupport: 'scoped', + ...overrides + } +} + +function hostResolution( + overrides: Partial = {} +): AutomationHostFilterResolution { + const resolved = overrides.entry === undefined ? entry() : overrides.entry + return { + effective: { kind: 'host', host: (resolved ?? entry()).stableRef }, + entry: resolved, + status: 'ready', + announceFallback: false, + ...overrides + } +} + +function input( + overrides: Partial = {} +): AutomationListEmptyStateInput { + return { + resolution: hostResolution(), + hostRowCount: 0, + visibleRowCount: 0, + searchActive: false, + ...overrides + } +} + +/** The grouped All-hosts view: one host, its own counts, no selection to resolve. */ +function groupInput( + overrides: Partial = {} +): AutomationListHostGroupEmptyStateInput { + return { + entry: entry(), + hostRowCount: 0, + visibleRowCount: 0, + searchActive: false, + ...overrides + } +} + +const ALL_HOSTS: AutomationHostFilterResolution = { + effective: { kind: 'all' }, + entry: null, + status: 'all', + announceFallback: false +} + +describe('automation list empty state', () => { + it('renders nothing when rows are visible', () => { + expect(resolveAutomationListEmptyState(input({ visibleRowCount: 3 })).kind).toBe('rows') + }) + + it('uses the single-host empty string only for a healthy, connected, authoritative host', () => { + const state = resolveAutomationListEmptyState(input()) + expect(state.kind).toBe('host-empty') + expect(state.title).toBe('No automations on web-01') + }) + + it('uses the all-hosts string only under All hosts', () => { + const state = resolveAutomationListEmptyState(input({ resolution: ALL_HOSTS })) + expect(state.kind).toBe('all-hosts-empty') + expect(state.title).toBe('No automations across loaded hosts') + }) + + it('uses the search string only when the host has rows the query hid', () => { + const state = resolveAutomationListEmptyState( + input({ searchActive: true, hostRowCount: 5, visibleRowCount: 0 }) + ) + expect(state.kind).toBe('search-no-match') + expect(state.title).toBe('No automations match your search') + }) + + it('does not claim a search miss when the host itself has no rows', () => { + const state = resolveAutomationListEmptyState( + input({ searchActive: true, hostRowCount: 0, visibleRowCount: 0 }) + ) + expect(state.kind).toBe('host-empty') + }) + + it('uses the load-failure string when the authority could not be reached', () => { + const state = resolveAutomationListEmptyState( + input({ + resolution: hostResolution({ + entry: entry({ authorityHealth: 'unavailable' }), + status: 'unavailable' + }) + }) + ) + expect(state.kind).toBe('host-unavailable') + expect(state.title).toBe('Automations could not be loaded from web-01') + expect(state.recovery).toBe('reconnect') + }) + + it('offers Retry rather than Reconnect for a stale refresh', () => { + const state = resolveAutomationListEmptyState( + input({ resolution: hostResolution({ entry: entry({ authorityHealth: 'stale-error' }) }) }) + ) + expect(state.kind).toBe('host-error') + expect(state.title).toBe('Automations could not be loaded from web-01') + expect(state.recovery).toBe('retry') + }) + + it('offers Update server for an incompatible authority', () => { + const state = resolveAutomationListEmptyState( + input({ resolution: hostResolution({ entry: entry({ authorityHealth: 'incompatible' }) }) }) + ) + expect(state.kind).toBe('host-error') + expect(state.recovery).toBe('update-server') + }) + + // The rule the design doc calls out explicitly. + const NOT_CONNECTED: AutomationExecutionHealth[] = [ + 'disconnected', + 'connecting', + 'unavailable', + 'unknown' + ] + it.each(NOT_CONNECTED)('never calls a %s host empty', (executionHealth) => { + const state = resolveAutomationListEmptyState( + input({ resolution: hostResolution({ entry: entry({ executionHealth }) }) }) + ) + expect(state.kind).toBe('host-not-connected') + expect(state.title).not.toContain('No automations') + expect(state.title).toBe('web-01 is not connected') + }) + + it('never calls an unhydrated host empty', () => { + const state = resolveAutomationListEmptyState( + input({ + resolution: hostResolution({ + entry: entry({ catalogState: 'unhydrated' }), + status: 'loading' + }) + }) + ) + expect(state.kind).toBe('host-loading') + expect(state.title).toBe('Loading host…') + expect(state.title).not.toContain('No automations') + }) + + it('never claims a host the group could not reach is empty', () => { + const state = resolveAutomationHostGroupEmptyState( + groupInput({ entry: entry({ authorityHealth: 'unavailable' }) }) + ) + expect(state.kind).toBe('host-unavailable') + expect(state.title).toBe('Automations could not be loaded from web-01') + expect(state.recovery).toBe('reconnect') + }) + + it.each(NOT_CONNECTED)('never calls a %s host in a group empty', (executionHealth) => { + const state = resolveAutomationHostGroupEmptyState( + groupInput({ entry: entry({ executionHealth }) }) + ) + expect(state.kind).toBe('host-not-connected') + expect(state.title).not.toContain('No automations') + }) + + it('never calls an unhydrated host in a group empty', () => { + const state = resolveAutomationHostGroupEmptyState( + groupInput({ entry: entry({ catalogState: 'unhydrated' }) }) + ) + expect(state.kind).toBe('host-loading') + expect(state.title).not.toContain('No automations') + }) + + it('blames the query, not the host, when a group is emptied by search', () => { + const state = resolveAutomationHostGroupEmptyState( + groupInput({ searchActive: true, hostRowCount: 3, visibleRowCount: 0 }) + ) + expect(state.kind).toBe('search-no-match') + expect(state.title).toBe('No automations match your search') + }) + + it('still renders rows for a group whose host answered with some', () => { + expect(resolveAutomationHostGroupEmptyState(groupInput({ visibleRowCount: 2 })).kind).toBe( + 'rows' + ) + }) + + it('calls a healthy, connected, authoritative group host empty', () => { + const state = resolveAutomationHostGroupEmptyState(groupInput()) + expect(state.kind).toBe('host-empty') + expect(state.title).toBe('No automations on web-01') + }) + + it('keeps each empty string exclusive to its own condition', () => { + const titles = [ + resolveAutomationListEmptyState(input()).title, + resolveAutomationListEmptyState(input({ resolution: ALL_HOSTS })).title, + resolveAutomationListEmptyState(input({ searchActive: true, hostRowCount: 2 })).title, + resolveAutomationListEmptyState( + input({ resolution: hostResolution({ entry: entry({ authorityHealth: 'stale-error' }) }) }) + ).title + ] + expect(new Set(titles).size).toBe(4) + }) +}) diff --git a/src/renderer/src/components/automations/automation-list-empty-state.ts b/src/renderer/src/components/automations/automation-list-empty-state.ts new file mode 100644 index 00000000000..5f23ea29b1a --- /dev/null +++ b/src/renderer/src/components/automations/automation-list-empty-state.ts @@ -0,0 +1,216 @@ +import { translate } from '@/i18n/i18n' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import type { + AutomationHostFilterResolution, + AutomationHostFilterStatus +} from './automation-host-filter-resolution' +import { + automationHostRecoveryActions, + type AutomationHostRecoveryAction +} from './automation-host-status-descriptors' + +/** + * Picks which empty/partial/failure state the list is actually in. + * + * The load-bearing rule: a host that is disconnected, unreachable, or not yet + * hydrated is never described as empty. "No automations" is a claim about + * storage, and none of those states have looked at storage. + */ + +export type AutomationListEmptyStateKind = + /** Rows exist; render the list, not a state. */ + | 'rows' + | 'search-no-match' + | 'host-empty' + | 'all-hosts-empty' + | 'host-error' + | 'host-loading' + | 'host-not-connected' + | 'host-unavailable' + +export type AutomationListEmptyState = { + kind: AutomationListEmptyStateKind + title: string + detail: string | null + recovery: AutomationHostRecoveryAction | null +} + +export type AutomationListEmptyStateInput = { + resolution: AutomationHostFilterResolution + /** Rows for the selected host before the search query or attribute filter is applied. */ + hostRowCount: number + /** Rows still visible after the search query and attribute filter. */ + visibleRowCount: number + searchActive: boolean + /** True while the status/last-run/agent/host filter menu narrows the list. */ + filterActive?: boolean +} + +function hostLabel(input: AutomationListEmptyStateInput): string { + return ( + input.resolution.entry?.label ?? + translate('auto.components.automations.emptyState.unknownHost', 'this host') + ) +} + +function state( + kind: AutomationListEmptyStateKind, + title: string, + detail: string | null, + _input: AutomationListEmptyStateInput, + recovery: AutomationHostRecoveryAction | null = null +): AutomationListEmptyState { + return { kind, title, detail, recovery } +} + +function resolveSelectedHostState(input: AutomationListEmptyStateInput): AutomationListEmptyState { + const label = hostLabel(input) + const entry = input.resolution.entry + const recovery = entry ? automationHostRecoveryActions(entry) : null + + if (input.resolution.status === 'loading' || entry?.catalogState === 'unhydrated') { + return state( + 'host-loading', + translate('auto.components.automations.emptyState.hostLoading', 'Loading host…'), + translate( + 'auto.components.automations.emptyState.hostLoadingDetail', + 'Waiting for {{hostLabel}} to report its automations.', + { hostLabel: label } + ), + input + ) + } + if (input.resolution.status === 'unavailable' || entry?.authorityHealth === 'unavailable') { + return state( + 'host-unavailable', + translate( + 'auto.components.automations.emptyState.hostError', + 'Automations could not be loaded from {{hostLabel}}', + { hostLabel: label } + ), + translate( + 'auto.components.automations.emptyState.hostUnavailableDetail', + 'The host could not be reached, so its automations are unknown.' + ), + input, + recovery?.authority ?? 'reconnect' + ) + } + if (entry?.authorityHealth === 'stale-error' || entry?.authorityHealth === 'incompatible') { + return state( + 'host-error', + translate( + 'auto.components.automations.emptyState.hostError', + 'Automations could not be loaded from {{hostLabel}}', + { hostLabel: label } + ), + entry.authorityHealth === 'incompatible' + ? translate( + 'auto.components.automations.emptyState.hostIncompatibleDetail', + 'This server is too old to scope automations by host.' + ) + : translate( + 'auto.components.automations.emptyState.hostStaleDetail', + 'The most recent refresh did not complete.' + ), + input, + recovery?.authority ?? 'retry' + ) + } + if (entry && entry.executionHealth !== 'connected') { + // Why: an unconnected target has told us nothing about storage, so "no automations" would overclaim. + return state( + 'host-not-connected', + translate( + 'auto.components.automations.emptyState.hostNotConnected', + '{{hostLabel}} is not connected', + { hostLabel: label } + ), + translate( + 'auto.components.automations.emptyState.hostNotConnectedDetail', + 'Connect to this host to see the automations stored for it.' + ), + input, + recovery?.execution ?? 'reconnect' + ) + } + return state( + 'host-empty', + translate( + 'auto.components.automations.emptyState.hostEmpty', + 'No automations on {{hostLabel}}', + { hostLabel: label } + ), + null, + input + ) +} + +export type AutomationListHostGroupEmptyStateInput = Omit< + AutomationListEmptyStateInput, + 'resolution' +> & { entry: AutomationHostCatalogEntry } + +function groupHostStatus(entry: AutomationHostCatalogEntry): AutomationHostFilterStatus { + if (entry.catalogState === 'unhydrated') { + return 'loading' + } + return entry.authorityHealth === 'unavailable' ? 'unavailable' : 'ready' +} + +/** + * The All-hosts group asks the same question about one host that the single-host + * view asks, so it asks it here. A second set of branches would drift, and the + * drift is what lets a host nobody could reach be described as empty. + */ +export function resolveAutomationHostGroupEmptyState( + input: AutomationListHostGroupEmptyStateInput +): AutomationListEmptyState { + const { entry, ...counts } = input + return resolveAutomationListEmptyState({ + ...counts, + resolution: { + effective: { kind: 'host', host: entry.stableRef }, + entry, + status: groupHostStatus(entry), + announceFallback: false + } + }) +} + +export function resolveAutomationListEmptyState( + input: AutomationListEmptyStateInput +): AutomationListEmptyState { + if (input.visibleRowCount > 0) { + return { kind: 'rows', title: '', detail: null, recovery: null } + } + if ((input.searchActive || input.filterActive) && input.hostRowCount > 0) { + // The host has rows; only the query or the attribute filter emptied the view. + return state( + 'search-no-match', + input.searchActive + ? translate( + 'auto.components.automations.emptyState.searchNoMatch', + 'No automations match your search' + ) + : translate( + 'auto.components.automations.emptyState.filtersNoMatch', + 'No automations match your filters' + ), + null, + input + ) + } + if (input.resolution.effective.kind === 'all') { + return state( + 'all-hosts-empty', + translate( + 'auto.components.automations.emptyState.allHostsEmpty', + 'No automations across loaded hosts' + ), + null, + input + ) + } + return resolveSelectedHostState(input) +} diff --git a/src/renderer/src/components/automations/automation-list-focus-recovery.test.ts b/src/renderer/src/components/automations/automation-list-focus-recovery.test.ts new file mode 100644 index 00000000000..06e0f6240ba --- /dev/null +++ b/src/renderer/src/components/automations/automation-list-focus-recovery.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { resolveAutomationListFocusRecovery } from './automation-list-focus-recovery' + +describe('automation list focus recovery', () => { + it('leaves focus alone when the row survived', () => { + expect( + resolveAutomationListFocusRecovery({ + previousRowKeys: ['a', 'b', 'c'], + nextRowKeys: ['a', 'b', 'c'], + focusedRowKey: 'b' + }) + ).toBeNull() + }) + + it('leaves focus alone when it was never on a row', () => { + expect( + resolveAutomationListFocusRecovery({ + previousRowKeys: ['a'], + nextRowKeys: [], + focusedRowKey: null + }) + ).toBeNull() + }) + + it('moves to the next surviving row', () => { + expect( + resolveAutomationListFocusRecovery({ + previousRowKeys: ['a', 'b', 'c'], + nextRowKeys: ['a', 'c'], + focusedRowKey: 'b' + }) + ).toEqual({ kind: 'row', rowKey: 'c' }) + }) + + it('falls back to the previous row when nothing follows survived', () => { + expect( + resolveAutomationListFocusRecovery({ + previousRowKeys: ['a', 'b', 'c'], + nextRowKeys: ['a'], + focusedRowKey: 'b' + }) + ).toEqual({ kind: 'row', rowKey: 'a' }) + }) + + it('falls back to the picker when the whole list went', () => { + expect( + resolveAutomationListFocusRecovery({ + previousRowKeys: ['a', 'b'], + nextRowKeys: [], + focusedRowKey: 'b' + }) + ).toEqual({ kind: 'picker' }) + }) + + it('goes to the picker when the lost row was never in this list', () => { + // A host switch replaces every row, so no neighbor here is the right one. + expect( + resolveAutomationListFocusRecovery({ + previousRowKeys: ['a', 'b'], + nextRowKeys: ['x', 'y'], + focusedRowKey: 'z' + }) + ).toEqual({ kind: 'picker' }) + }) +}) diff --git a/src/renderer/src/components/automations/automation-list-focus-recovery.ts b/src/renderer/src/components/automations/automation-list-focus-recovery.ts new file mode 100644 index 00000000000..ebf30db9a0b --- /dev/null +++ b/src/renderer/src/components/automations/automation-list-focus-recovery.ts @@ -0,0 +1,52 @@ +/** + * Where focus goes when the row the user was on disappears. + * + * A refresh, a host filter change, or a delete can remove the focused row while + * the keyboard is still in the list. Without this, focus falls back to the + * document body and the next Tab restarts from the top of the page. The order — + * next row, then previous row, then the host picker — keeps the user near where + * they were, and the picker is the last resort because it is the control that + * put them in this list to begin with. + */ + +export type AutomationListFocusTarget = { kind: 'row'; rowKey: string } | { kind: 'picker' } + +export type AutomationListFocusRecoveryInput = { + /** Row order before the change, including the row that is now gone. */ + previousRowKeys: readonly string[] + nextRowKeys: readonly string[] + /** The row that had focus; null when focus was elsewhere. */ + focusedRowKey: string | null +} + +/** Null means focus is fine where it is and must not be moved. */ +export function resolveAutomationListFocusRecovery( + input: AutomationListFocusRecoveryInput +): AutomationListFocusTarget | null { + const { focusedRowKey } = input + if (focusedRowKey === null) { + return null + } + const surviving = new Set(input.nextRowKeys) + if (surviving.has(focusedRowKey)) { + return null + } + const lostIndex = input.previousRowKeys.indexOf(focusedRowKey) + if (lostIndex === -1) { + // The row was never in this list, so nothing here can be the right neighbor. + return { kind: 'picker' } + } + for (let index = lostIndex + 1; index < input.previousRowKeys.length; index += 1) { + const candidate = input.previousRowKeys[index] + if (surviving.has(candidate)) { + return { kind: 'row', rowKey: candidate } + } + } + for (let index = lostIndex - 1; index >= 0; index -= 1) { + const candidate = input.previousRowKeys[index] + if (surviving.has(candidate)) { + return { kind: 'row', rowKey: candidate } + } + } + return { kind: 'picker' } +} diff --git a/src/renderer/src/components/automations/automation-list-last-run.ts b/src/renderer/src/components/automations/automation-list-last-run.ts index 7d5127337fa..b5f72275273 100644 --- a/src/renderer/src/components/automations/automation-list-last-run.ts +++ b/src/renderer/src/components/automations/automation-list-last-run.ts @@ -68,6 +68,26 @@ export function getToneForAutomationRunStatus(status: AutomationRunStatus): Auto return 'unknown' } +/** + * Last-run picture for a catalog row without any run-history fetch: the owning + * authority projects its newest retained run into the usage summary, and a row + * from an older server falls back to the stored `lastRunAt` timestamp alone. + */ +export function getAutomationRowLastRunSnapshot(row: { + automation: Automation + usageSummary: { lastRunStatus?: AutomationRunStatus | null; lastRunAt?: number | null } | null +}): AutomationLastRunSnapshot { + const status = row.usageSummary?.lastRunStatus + if (status) { + return { + at: row.usageSummary?.lastRunAt ?? row.automation.lastRunAt ?? null, + tone: getToneForAutomationRunStatus(status), + statusLabel: getAutomationRunStatusLabel(status) + } + } + return getLocalAutomationLastRunSnapshot(row.automation, undefined) +} + export function getLocalAutomationLastRunSnapshot( automation: Automation, lastRun: AutomationRun | undefined diff --git a/src/renderer/src/components/automations/automation-list-row-identity.test.ts b/src/renderer/src/components/automations/automation-list-row-identity.test.ts new file mode 100644 index 00000000000..4dbc19b6944 --- /dev/null +++ b/src/renderer/src/components/automations/automation-list-row-identity.test.ts @@ -0,0 +1,180 @@ +/** + * One automation ID under two authorities, followed from the cached host rows + * all the way into the search index. + * + * These are the joins the list makes between a record and its host. Each one + * used to be a bare automation ID, which is unique only inside one authority + * (doc:38) — so the desktop's `a-1` and a runtime SSH host's `a-1` shared a + * slot, and whichever was written last won it. + */ + +import { describe, expect, it } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import type { AutomationHostCacheEntry, AutomationHostRow } from './automation-host-cache-types' +import type { + AutomationHostCatalog, + AutomationHostCatalogEntry +} from './automation-host-catalog-types' +import type { AutomationHostFilterResolution } from './automation-host-filter-resolution' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { resolveAutomationHostListRows } from './automation-host-list-rows' +import { + buildAutomationListSearchRows, + buildAutomationSearchRowSources, + matchAutomationListSearchRowKeys +} from './automation-list-search-rows' +import { REPO_ID } from './automations-page-fixtures' +import { automationRepoForRow, automationWorktreeForRow } from './automation-list-row-identity' + +const repoMap = new Map([ + [REPO_ID, { id: REPO_ID, displayName: 'orca', path: '/src/orca' } as Repo] +]) + +const DESKTOP_SELF: AutomationHostCatalogEntry = { + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'self' } }, + owner: { authority: { kind: 'desktop' }, selector: { kind: 'self' } }, + stableKey: 'host:desktop:self', + label: 'This computer', + authorityLabel: 'This computer', + kind: 'self', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'connected', + querySupport: 'scoped' +} + +const RUNTIME_SSH: AutomationHostCatalogEntry = { + ...DESKTOP_SELF, + stableRef: { + authority: { kind: 'runtime', environmentId: 'gpu' }, + selector: { kind: 'ssh', targetId: 'web-01' } + }, + owner: { + authority: { kind: 'runtime', environmentId: 'gpu', pairingRevision: 4 }, + selector: { kind: 'ssh', targetId: 'web-01', targetGeneration: 2 } + }, + stableKey: 'host:runtime:gpu:ssh:web-01', + label: 'web-01', + authorityLabel: 'GPU box', + kind: 'ssh' +} + +const ALL_HOSTS: AutomationHostFilterResolution = { + effective: { kind: 'all' }, + entry: null, + status: 'all', + announceFallback: false +} + +function cached(rows: AutomationHostRow[]): AutomationHostCacheEntry { + return { + data: rows, + fetchedAt: 1, + attempt: 0, + requestGeneration: 0, + catalogGeneration: 0, + request: null, + error: null, + orphanCount: null + } +} + +function hostRow(entry: AutomationHostCatalogEntry, name: string): AutomationHostRow { + return { + automation: { + id: 'a-1', + name, + prompt: 'sweep', + projectId: REPO_ID, + workspaceMode: 'new_per_run', + baseBranch: null, + agentId: 'claude' + } as Automation, + owner: entry.owner, + selector: { kind: 'self' }, + usageSummary: null, + usageKnown: true + } +} + +/** The desktop and a runtime SSH host each holding a record called `a-1`. */ +function collidedListRows(): ReturnType { + const entries = [DESKTOP_SELF, RUNTIME_SSH] + const catalog: AutomationHostCatalog = { + entries, + byStableKey: new Map(entries.map((entry) => [entry.stableKey, entry])), + hydration: { + runtimeCatalogSettled: true, + desktopSshHydrated: true, + runtimeSshHydratedByEnvironmentId: new Map(), + savedRuntimeEnvironmentIds: new Set(), + orphanSettledAuthorityKeys: new Set(), + unavailableAuthorityKeys: new Set() + } + } + return resolveAutomationHostListRows({ + catalog, + resolution: ALL_HOSTS, + entry: (stableKey) => + cached([ + stableKey === DESKTOP_SELF.stableKey + ? hostRow(DESKTOP_SELF, 'Nightly desktop') + : hostRow(RUNTIME_SSH, 'Nightly web-01') + ]) + }) +} + +describe('automation list row identity across hosts', () => { + it('lets each host’s row be found by its own host label', () => { + const listRows = collidedListRows() + const searchRows = buildAutomationListSearchRows( + buildAutomationSearchRowSources(listRows.rows, { repoMap }) + ) + const nameByKey = new Map(listRows.rows.map((row) => [row.key, row.automation.name])) + const namesMatching = (query: string): (string | undefined)[] => + matchAutomationListSearchRowKeys(searchRows, query).map((key) => nameByKey.get(key)) + + expect(namesMatching('web-01')).toEqual(['Nightly web-01']) + expect(namesMatching('this computer')).toEqual(['Nightly desktop']) + }) + + it('resolves colliding projects and worktrees inside each row authority', () => { + const rows = collidedListRows().rows + const repos = [ + { ...repoMap.get(REPO_ID)!, displayName: 'Desktop repo', executionHostId: 'local' as const }, + { + ...repoMap.get(REPO_ID)!, + displayName: 'Runtime repo', + executionHostId: 'runtime:gpu' as const + } + ] + const worktrees = [ + { id: 'ws-1', repoId: REPO_ID, hostId: 'local', displayName: 'Desktop workspace' }, + { + id: 'ws-1', + repoId: REPO_ID, + hostId: 'runtime:gpu', + displayName: 'Runtime workspace' + } + ] as Worktree[] + const fallbackRepos = new Map([[REPO_ID, repos[1]]]) + const fallbackWorktrees = new Map([['ws-1', worktrees[1]]]) + + expect(rows.map((row) => automationRepoForRow(row, repos, fallbackRepos)?.displayName)).toEqual( + ['Desktop repo', 'Runtime repo'] + ) + expect( + rows.map((row) => { + const repo = automationRepoForRow(row, repos, fallbackRepos) + return automationWorktreeForRow( + row, + { [REPO_ID]: worktrees }, + repo, + fallbackWorktrees, + 'ws-1' + )?.displayName + }) + ).toEqual(['Desktop workspace', 'Runtime workspace']) + }) +}) diff --git a/src/renderer/src/components/automations/automation-list-row-identity.ts b/src/renderer/src/components/automations/automation-list-row-identity.ts new file mode 100644 index 00000000000..577f323319f --- /dev/null +++ b/src/renderer/src/components/automations/automation-list-row-identity.ts @@ -0,0 +1,111 @@ +/** + * Identity for a rendered automation list row. + * + * An automation ID is unique only inside its authority, so under All hosts two + * hosts can legitimately return `a-1` and every bare-ID map, React key, and + * selection check silently collapses them into one. A row is therefore keyed by + * the host it came from plus the record's ID. + * + * The host part is the incarnation-free `stableKey`, never `ownerKey`: this key + * decides React identity and which row is selected, and both must survive a + * re-pairing that bumps `pairingRevision` / `targetGeneration`. An + * incarnation-bearing key would remount every row and drop the selection each + * time a host reconnected. Freshness fences still use `ownerKey` — they are + * asking a different question about the same row. + */ + +import type { Automation } from '../../../../shared/automations-types' +import type { AutomationUsageSummary } from '../../../../shared/automation-usage-summary' +import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity' +import type { StableAutomationCatalogRef } from '../../../../shared/automation-owner-ref' +import { + getRepoExecutionHostId, + getWorktreeExecutionHostId +} from '../../../../shared/execution-host' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' + +export type AutomationListRow = { + /** Authority-qualified and incarnation-free; see the module note. */ + key: string + automation: Automation + /** Storage authority captured with the host query; absent only for the bootstrap list. */ + catalogRef?: StableAutomationCatalogRef | null + /** The host this row actually came from; empty for unscoped legacy rows. */ + hostLabel: string + /** What the owning authority reported; null means unknown, never zero. */ + usageSummary: AutomationUsageSummary | null +} + +const ROW_KEY_NAMESPACE = 'row' +/** The pre-catalog list has no host to qualify with, so it says so rather than guessing one. */ +const UNSCOPED_HOST_KEY = 'unscoped' + +export function automationListRowKey(hostStableKey: string | null, automationId: string): string { + return [ROW_KEY_NAMESPACE, hostStableKey ?? UNSCOPED_HOST_KEY, automationId] + .map(encodeURIComponent) + .join('|') +} + +/** + * Dedupe key for a record within one authority. Deliberately coarser than the + * row key: an authority's Self, SSH, and orphan scopes can each return the same + * record and only one should render, while two authorities holding that ID hold + * two different records. + */ +export function automationAuthorityRecordKey(authorityKey: string, automationId: string): string { + return [authorityKey, automationId].map(encodeURIComponent).join('|') +} + +/** The unscoped list the page shows before any host has answered. */ +export function unscopedAutomationListRows( + automations: readonly Automation[] +): AutomationListRow[] { + return automations.map((automation) => ({ + key: automationListRowKey(null, automation.id), + automation, + catalogRef: null, + hostLabel: '', + usageSummary: null + })) +} + +export function automationRepoForRow( + row: AutomationListRow, + repos: readonly Repo[], + fallback: ReadonlyMap +): Repo | undefined { + const repoId = getAutomationRunRepoId(row.automation) + const authority = row.catalogRef?.authority + if (!authority) { + return fallback.get(repoId) + } + return repos.find((repo) => { + const host = getRepoExecutionHostId(repo) + return ( + repo.id === repoId && + (authority.kind === 'runtime' + ? host === `runtime:${encodeURIComponent(authority.environmentId)}` + : !host.startsWith('runtime:')) + ) + }) +} + +export function automationWorktreeForRow( + row: AutomationListRow, + worktreesByRepo: Readonly>, + repo: Repo | undefined, + fallback: ReadonlyMap, + workspaceId: string | null | undefined = row.automation.workspaceId +): Worktree | undefined { + if (!workspaceId || !row.catalogRef || !repo) { + return workspaceId ? fallback.get(workspaceId) : undefined + } + const hostId = getRepoExecutionHostId(repo) + return Object.values(worktreesByRepo) + .flat() + .find( + (worktree) => + worktree.id === workspaceId && getWorktreeExecutionHostId(worktree, repo) === hostId + ) +} diff --git a/src/renderer/src/components/automations/automation-list-search-query.ts b/src/renderer/src/components/automations/automation-list-search-query.ts new file mode 100644 index 00000000000..85c6067daaa --- /dev/null +++ b/src/renderer/src/components/automations/automation-list-search-query.ts @@ -0,0 +1,56 @@ +import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' + +/** Pasted queries above this are rejected so filtering never runs on unbounded input. */ +export const AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES = 2 * 1024 + +export type AutomationListSearchQueryResolution = + | { status: 'inactive' } + | { status: 'too_large' } + | { status: 'active'; query: string } + +export function isAutomationListSearchQueryTooLarge( + rawQuery: string, + maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES +): boolean { + return isClipboardTextByteLengthOverLimit(rawQuery, maxBytes) +} + +/** + * Caps the controlled input value so a multi-MB paste cannot pin renderer + * memory. Keeping maxBytes+1 code units is enough for the over-limit check + * (`length > maxBytes`) while discarding the rest of the paste. + */ +export function clampAutomationListSearchQueryInput( + rawQuery: string, + maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES +): string { + const maxStoredCodeUnits = maxBytes + 1 + if (rawQuery.length <= maxStoredCodeUnits) { + return rawQuery + } + return rawQuery.slice(0, maxStoredCodeUnits) +} + +export function resolveAutomationListSearchQuery( + rawQuery: string, + maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES +): AutomationListSearchQueryResolution { + // Why: length pre-check short-circuits multi-MB pastes before UTF-8 scan. + if (isClipboardTextByteLengthOverLimit(rawQuery, maxBytes)) { + return { status: 'too_large' } + } + const query = rawQuery.trim().toLowerCase() + if (!query) { + return { status: 'inactive' } + } + return { status: 'active', query } +} + +/** Active lowercase query, or null when search must not run. */ +export function getActiveAutomationListSearchQuery( + rawQuery: string, + maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES +): string | null { + const resolved = resolveAutomationListSearchQuery(rawQuery, maxBytes) + return resolved.status === 'active' ? resolved.query : null +} diff --git a/src/renderer/src/components/automations/automation-list-search-rows.test.ts b/src/renderer/src/components/automations/automation-list-search-rows.test.ts new file mode 100644 index 00000000000..c81571fcb2f --- /dev/null +++ b/src/renderer/src/components/automations/automation-list-search-rows.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import { getAgentLabel } from './automation-draft-model' +import { + AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS, + AUTOMATION_LIST_SEARCH_UNKNOWN_PROJECT, + automationListSearchIndexMatches +} from './automation-list-search' +import { + buildAutomationListSearchRowFingerprint, + buildAutomationListSearchRows, + buildAutomationSearchFields, + buildAutomationSearchRowSources, + buildExternalAutomationSearchFields, + buildExternalAutomationSearchRowSources, + matchAutomationListSearchRowKeys +} from './automation-list-search-rows' +import { buildExternalAutomationListEntries } from './external-automation-list-entries' +import { + makeAutomation, + makeAutomationListRow, + makeScopedExternalManager, + REPO_ID +} from './automations-page-fixtures' + +const repo = { id: REPO_ID, displayName: 'orca', path: '/src/orca' } as Repo +const repoMap = new Map([[REPO_ID, repo]]) + +function fieldsFor( + row = makeAutomationListRow({ hostLabel: '' }), + context: Parameters[1] = { repoMap } +): ReturnType { + return buildAutomationSearchFields(row, context) +} + +describe('automation search row fields', () => { + it('indexes every axis the design doc requires for a local automation', () => { + const automation = makeAutomation({ + id: 'a-9', + name: 'Nightly sweep', + workspaceMode: 'existing', + workspaceId: 'ws-1', + agentId: 'claude' + }) + const fields = fieldsFor(makeAutomationListRow({ automation, hostLabel: 'build-box' }), { + repoMap, + worktreeMap: new Map([['ws-1', { displayName: 'feature/login-retry' }]]) + }) + + expect(fields).toEqual({ + name: 'Nightly sweep', + project: 'orca /src/orca', + workspace: 'feature/login-retry', + agent: getAgentLabel('claude'), + host: 'build-box', + prompt: automation.prompt + }) + }) + + it('falls back to the base ref for automations that create a workspace per run', () => { + const fields = fieldsFor( + makeAutomationListRow({ + automation: makeAutomation({ workspaceMode: 'new_per_run', baseBranch: 'main' }) + }) + ) + expect(fields.workspace).toBe('main') + }) + + it('keeps the unknown-project term when no repo resolves', () => { + const fields = fieldsFor( + makeAutomationListRow({ automation: makeAutomation({ projectId: 'missing' }) }), + { repoMap } + ) + expect(fields.project).toBe(AUTOMATION_LIST_SEARCH_UNKNOWN_PROJECT) + }) + + it('leaves the host axis empty for a row with no host of origin', () => { + expect(fieldsFor().host).toBe('') + }) + + it('indexes external jobs by provider, target host, and workdir', () => { + const [entry] = buildExternalAutomationListEntries([makeScopedExternalManager()]) + if (!entry) { + throw new Error('fixture produced no external entry') + } + const fields = buildExternalAutomationSearchFields(entry) + expect(fields.name).toBe('Hermes job') + expect(fields.agent).toBe('Hermes') + expect(fields.host).toBe('This computer') + expect(fields.prompt).toBe('Sweep') + }) +}) + +describe('automation search row index', () => { + it('builds one index per row and matches in a single pass', () => { + const listRows = [ + makeAutomationListRow({ automation: makeAutomation({ id: 'a-1', name: 'Nightly sweep' }) }), + makeAutomationListRow({ + automation: makeAutomation({ id: 'a-2', name: 'PR nudge', prompt: 'remind reviewers' }), + hostLabel: 'build-box' + }) + ] + const [first, second] = listRows.map((row) => row.key) + const rows = buildAutomationListSearchRows( + buildAutomationSearchRowSources(listRows, { repoMap }) + ) + + expect(rows.map((row) => row.key)).toEqual([first, second]) + expect(matchAutomationListSearchRowKeys(rows, 'reviewers')).toEqual([second]) + expect(matchAutomationListSearchRowKeys(rows, 'build-box')).toEqual([second]) + expect(matchAutomationListSearchRowKeys(rows, 'orca')).toEqual([first, second]) + expect(matchAutomationListSearchRowKeys(rows, 'nothing')).toEqual([]) + }) + + it('bounds prompt indexing per row regardless of prompt size', () => { + const prompt = `${'x'.repeat(AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS)}unique-tail` + const rows = buildAutomationListSearchRows( + buildAutomationSearchRowSources( + [makeAutomationListRow({ automation: makeAutomation({ prompt }) })], + { + repoMap + } + ) + ) + const index = rows[0]?.index + if (!index) { + throw new Error('no row built') + } + expect(index.prompt.length).toBe(AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS) + expect(automationListSearchIndexMatches(index, 'unique-tail')).toBe(false) + }) + + it('fingerprints identical content identically across a refresh tick', () => { + const build = (): ReturnType => + buildAutomationSearchRowSources( + [makeAutomationListRow({ automation: makeAutomation({ nextRunAt: Date.now() }) })], + { repoMap } + ) + // Why: nextRunAt churns on every tick and must not invalidate the index. + expect(buildAutomationListSearchRowFingerprint(build())).toBe( + buildAutomationListSearchRowFingerprint(build()) + ) + }) + + it('changes the fingerprint when a searchable axis changes', () => { + const base = buildAutomationSearchRowSources([makeAutomationListRow({ hostLabel: '' })], { + repoMap + }) + const renamedHost = buildAutomationSearchRowSources( + [makeAutomationListRow({ hostLabel: 'build-box' })], + { repoMap } + ) + expect(buildAutomationListSearchRowFingerprint(base)).not.toBe( + buildAutomationListSearchRowFingerprint(renamedHost) + ) + }) + + it('fingerprints external rows by key and content', () => { + const entries = buildExternalAutomationListEntries([makeScopedExternalManager()]) + const sources = buildExternalAutomationSearchRowSources(entries) + expect(buildAutomationListSearchRowFingerprint(sources)).toBe( + buildAutomationListSearchRowFingerprint( + buildExternalAutomationSearchRowSources( + buildExternalAutomationListEntries([makeScopedExternalManager()]) + ) + ) + ) + }) +}) diff --git a/src/renderer/src/components/automations/automation-list-search-rows.ts b/src/renderer/src/components/automations/automation-list-search-rows.ts new file mode 100644 index 00000000000..c8276415a94 --- /dev/null +++ b/src/renderer/src/components/automations/automation-list-search-rows.ts @@ -0,0 +1,136 @@ +import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity' +import type { Automation } from '../../../../shared/automations-types' +import type { Repo } from '../../../../shared/repo-types' +import type { AutomationListRow } from './automation-list-row-identity' +import { getAgentLabel } from './automation-draft-model' +import { + automationListSearchIndexMatches, + buildAutomationListSearchFingerprint, + buildAutomationListSearchIndex, + buildAutomationProjectSearchText, + type AutomationListSearchFields, + type AutomationListSearchIndex +} from './automation-list-search' +import { getExternalProviderLabel } from './external-automation-display' +import type { ExternalAutomationListEntry } from './external-automation-list-entries' + +/** Row identity is the authority-qualified row key / external key, so reorders invalidate the index. */ +export type AutomationListSearchRowSource = { + key: string + fields: AutomationListSearchFields +} + +export type AutomationListSearchRow = { + key: string + index: AutomationListSearchIndex +} + +/** Only display names are read; the map accepts the page's worktree map as-is. */ +export type AutomationWorkspaceNameLookup = ReadonlyMap + +export type AutomationSearchRowContext = { + repoMap: ReadonlyMap + worktreeMap?: AutomationWorkspaceNameLookup +} + +function buildAutomationWorkspaceSearchText( + automation: Automation, + worktreeMap: AutomationWorkspaceNameLookup | undefined +): string { + // Why: a per-run automation has no workspace yet, so its base ref is the only durable term. + if (automation.workspaceMode === 'new_per_run') { + return automation.baseBranch ?? '' + } + if (!automation.workspaceId) { + return '' + } + return worktreeMap?.get(automation.workspaceId)?.displayName ?? '' +} + +export function buildAutomationSearchFields( + row: AutomationListRow, + context: AutomationSearchRowContext +): AutomationListSearchFields { + const { automation } = row + const repo = context.repoMap.get(getAutomationRunRepoId(automation)) + return { + name: automation.name, + project: buildAutomationProjectSearchText({ + displayName: repo?.displayName, + path: repo?.path + }), + workspace: buildAutomationWorkspaceSearchText(automation, context.worktreeMap), + agent: getAgentLabel(automation.agentId), + // The row's own host: a lookup by automation id would give two hosts' rows + // one label, so a search for host B could only ever match host A's row. + host: row.hostLabel, + prompt: automation.prompt + } +} + +export function buildExternalAutomationSearchFields( + entry: ExternalAutomationListEntry +): AutomationListSearchFields { + return { + name: entry.job.name, + project: entry.job.workdir ?? '', + workspace: '', + // Why: an external job runs under its manager, so the provider is the closest agent term. + agent: getExternalProviderLabel(entry.manager), + host: entry.manager.targetLabel, + prompt: entry.job.prompt ?? entry.job.promptPreview ?? '' + } +} + +export function buildAutomationSearchRowSources( + rows: readonly AutomationListRow[], + context: AutomationSearchRowContext +): AutomationListSearchRowSource[] { + return rows.map((row) => ({ + key: row.key, + fields: buildAutomationSearchFields(row, context) + })) +} + +export function buildExternalAutomationSearchRowSources( + entries: readonly ExternalAutomationListEntry[] +): AutomationListSearchRowSource[] { + return entries.map((entry) => ({ + key: entry.key, + fields: buildExternalAutomationSearchFields(entry) + })) +} + +/** Single pass over the changed row set; the only place indexes are built. */ +export function buildAutomationListSearchRows( + sources: readonly AutomationListSearchRowSource[] +): AutomationListSearchRow[] { + return sources.map((source) => ({ + key: source.key, + index: buildAutomationListSearchIndex(source.fields) + })) +} + +/** Single pass over prebuilt indexes; no indexing work per keystroke. */ +export function matchAutomationListSearchRowKeys( + rows: readonly AutomationListSearchRow[], + activeQuery: string +): string[] { + const keys: string[] = [] + for (const row of rows) { + if (automationListSearchIndexMatches(row.index, activeQuery)) { + keys.push(row.key) + } + } + return keys +} + +/** Rebuild gate: identical content across a refresh tick keeps the existing indexes. */ +export function buildAutomationListSearchRowFingerprint( + sources: readonly AutomationListSearchRowSource[] +): string { + return buildAutomationListSearchFingerprint( + sources.map((source) => source.fields), + sources.map((source) => source.key) + ) +} diff --git a/src/renderer/src/components/automations/automation-list-search.test.ts b/src/renderer/src/components/automations/automation-list-search.test.ts index 47f59faaba6..5e9e880726f 100644 --- a/src/renderer/src/components/automations/automation-list-search.test.ts +++ b/src/renderer/src/components/automations/automation-list-search.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from 'vitest' import { + AUTOMATION_LIST_SEARCH_AGENT_MAX_CODE_UNITS, + AUTOMATION_LIST_SEARCH_HOST_MAX_CODE_UNITS, + AUTOMATION_LIST_SEARCH_NAME_MAX_CODE_UNITS, + AUTOMATION_LIST_SEARCH_PROJECT_MAX_CODE_UNITS, AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS, AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES, + AUTOMATION_LIST_SEARCH_WORKSPACE_MAX_CODE_UNITS, AUTOMATION_LIST_SEARCH_UNKNOWN_PROJECT, automationListSearchFieldsMatch, automationListSearchIndexMatches, @@ -118,6 +123,75 @@ describe('automation-list-search', () => { expect(automationListSearchIndexMatches(index, 'unknown')).toBe(true) }) + it('caps the searchable prompt at the first 2,048 characters', () => { + // Design doc: the bound is a hard requirement, not a tuning knob. + expect(AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS).toBe(2048) + }) + + it('matches workspace, agent, and host alongside name, project, and prompt', () => { + const fields = { + name: 'Auto PR assignment', + project: 'orca / main', + workspace: 'feature/login-retry', + agent: 'Claude Code', + host: 'build-box', + prompt: 'Assign reviewers for open PRs' + } + for (const query of ['assignment', 'ORCA', 'login-retry', 'claude', 'build-box', 'reviewers']) { + expect(automationListSearchFieldsMatch(fields, query)).toBe(true) + } + expect(automationListSearchFieldsMatch(fields, 'missing')).toBe(false) + }) + + it('bounds every indexed field, so no axis grows with its source', () => { + const index = buildAutomationListSearchIndex({ + name: 'n'.repeat(10_000), + project: 'p'.repeat(10_000), + workspace: 'w'.repeat(10_000), + agent: 'a'.repeat(10_000), + host: 'h'.repeat(10_000), + prompt: 'x'.repeat(10_000) + }) + expect(index.name.length).toBe(AUTOMATION_LIST_SEARCH_NAME_MAX_CODE_UNITS) + expect(index.project.length).toBe(AUTOMATION_LIST_SEARCH_PROJECT_MAX_CODE_UNITS) + expect(index.workspace.length).toBe(AUTOMATION_LIST_SEARCH_WORKSPACE_MAX_CODE_UNITS) + expect(index.agent.length).toBe(AUTOMATION_LIST_SEARCH_AGENT_MAX_CODE_UNITS) + expect(index.host.length).toBe(AUTOMATION_LIST_SEARCH_HOST_MAX_CODE_UNITS) + expect(index.prompt.length).toBe(AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS) + }) + + it('leaves absent workspace/agent/host axes empty rather than matching everything', () => { + const index = buildAutomationListSearchIndex({ name: 'Job', project: 'orca', prompt: 'hi' }) + expect(index.workspace).toBe('') + expect(index.agent).toBe('') + expect(index.host).toBe('') + expect(automationListSearchIndexMatches(index, 'anything')).toBe(false) + }) + + it('fingerprints the new axes and caps the prompt before hashing', () => { + const base = { name: 'A', project: 'p1', workspace: 'w1', agent: 'g1', host: 'h1', prompt: 'x' } + for (const field of ['workspace', 'agent', 'host'] as const) { + expect(buildAutomationListSearchFingerprint([base])).not.toBe( + buildAutomationListSearchFingerprint([{ ...base, [field]: 'changed' }]) + ) + } + // Two prompts differing only past the cap are one row set as far as search is concerned. + const prefix = 'y'.repeat(AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS) + expect(buildAutomationListSearchFingerprint([{ ...base, prompt: `${prefix}tail-a` }])).toBe( + buildAutomationListSearchFingerprint([{ ...base, prompt: `${prefix}tail-b` }]) + ) + }) + + it('separates row identity from content so a reorder changes the fingerprint', () => { + const rows = [ + { name: 'A', project: 'p', prompt: 'one' }, + { name: 'B', project: 'p', prompt: 'two' } + ] + expect(buildAutomationListSearchFingerprint(rows, ['a', 'b'])).not.toBe( + buildAutomationListSearchFingerprint(rows, ['b', 'a']) + ) + }) + it('matches name, project, or prompt', () => { const fields = { name: 'Auto PR assignment', diff --git a/src/renderer/src/components/automations/automation-list-search.ts b/src/renderer/src/components/automations/automation-list-search.ts index b27da8fe221..68df699c0c0 100644 --- a/src/renderer/src/components/automations/automation-list-search.ts +++ b/src/renderer/src/components/automations/automation-list-search.ts @@ -1,13 +1,26 @@ -import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' +import { + getActiveAutomationListSearchQuery, + resolveAutomationListSearchQuery +} from './automation-list-search-query' -/** Pasted queries above this are rejected so filtering never runs on unbounded input. */ -export const AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES = 2 * 1024 +export { + AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES, + clampAutomationListSearchQueryInput, + getActiveAutomationListSearchQuery, + isAutomationListSearchQueryTooLarge, + resolveAutomationListSearchQuery, + type AutomationListSearchQueryResolution +} from './automation-list-search-query' // Why: prompts can be multi-MB agent instructions. Index only a fixed prefix so // lowercasing/includes stay O(bound) per automation rather than O(prompt size). export const AUTOMATION_LIST_SEARCH_NAME_MAX_CODE_UNITS = 512 export const AUTOMATION_LIST_SEARCH_PROJECT_MAX_CODE_UNITS = 1_024 -export const AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS = 8 * 1024 +export const AUTOMATION_LIST_SEARCH_WORKSPACE_MAX_CODE_UNITS = 512 +export const AUTOMATION_LIST_SEARCH_AGENT_MAX_CODE_UNITS = 128 +export const AUTOMATION_LIST_SEARCH_HOST_MAX_CODE_UNITS = 256 +/** Design doc: at most the first 2,048 prompt characters are searchable. */ +export const AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS = 2_048 /** Indexed when a local automation has no resolved project so "unknown" still matches. */ export const AUTOMATION_LIST_SEARCH_UNKNOWN_PROJECT = 'unknown project' @@ -16,66 +29,30 @@ export type AutomationListSearchFields = { name: string project: string prompt: string + workspace?: string | null + agent?: string | null + host?: string | null } /** Lowercased, length-capped fields ready for substring match. */ export type AutomationListSearchIndex = { name: string project: string + workspace: string + agent: string + host: string prompt: string } -export type AutomationListSearchQueryResolution = - | { status: 'inactive' } - | { status: 'too_large' } - | { status: 'active'; query: string } - -export function isAutomationListSearchQueryTooLarge( - rawQuery: string, - maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES -): boolean { - return isClipboardTextByteLengthOverLimit(rawQuery, maxBytes) -} - -/** - * Caps the controlled input value so a multi-MB paste cannot pin renderer - * memory. Keeping maxBytes+1 code units is enough for the over-limit check - * (`length > maxBytes`) while discarding the rest of the paste. - */ -export function clampAutomationListSearchQueryInput( - rawQuery: string, - maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES -): string { - const maxStoredCodeUnits = maxBytes + 1 - if (rawQuery.length <= maxStoredCodeUnits) { - return rawQuery - } - return rawQuery.slice(0, maxStoredCodeUnits) -} - -export function resolveAutomationListSearchQuery( - rawQuery: string, - maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES -): AutomationListSearchQueryResolution { - // Why: length pre-check short-circuits multi-MB pastes before UTF-8 scan. - if (isClipboardTextByteLengthOverLimit(rawQuery, maxBytes)) { - return { status: 'too_large' } - } - const query = rawQuery.trim().toLowerCase() - if (!query) { - return { status: 'inactive' } - } - return { status: 'active', query } -} - -/** Active lowercase query, or null when search must not run. */ -export function getActiveAutomationListSearchQuery( - rawQuery: string, - maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES -): string | null { - const resolved = resolveAutomationListSearchQuery(rawQuery, maxBytes) - return resolved.status === 'active' ? resolved.query : null -} +/** Cheapest field first so the prompt prefix is scanned least often. */ +const SEARCH_FIELD_CAPS: readonly (readonly [keyof AutomationListSearchIndex, number])[] = [ + ['name', AUTOMATION_LIST_SEARCH_NAME_MAX_CODE_UNITS], + ['agent', AUTOMATION_LIST_SEARCH_AGENT_MAX_CODE_UNITS], + ['host', AUTOMATION_LIST_SEARCH_HOST_MAX_CODE_UNITS], + ['workspace', AUTOMATION_LIST_SEARCH_WORKSPACE_MAX_CODE_UNITS], + ['project', AUTOMATION_LIST_SEARCH_PROJECT_MAX_CODE_UNITS], + ['prompt', AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS] +] /** Avoid splitting a surrogate pair at the cap boundary. */ export function truncateAutomationListSearchField(value: string, maxCodeUnits: number): string { @@ -104,20 +81,11 @@ export function normalizeAutomationListSearchField( export function buildAutomationListSearchIndex( fields: AutomationListSearchFields ): AutomationListSearchIndex { - return { - name: normalizeAutomationListSearchField( - fields.name, - AUTOMATION_LIST_SEARCH_NAME_MAX_CODE_UNITS - ), - project: normalizeAutomationListSearchField( - fields.project, - AUTOMATION_LIST_SEARCH_PROJECT_MAX_CODE_UNITS - ), - prompt: normalizeAutomationListSearchField( - fields.prompt, - AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS - ) + const index = {} as AutomationListSearchIndex + for (const [field, maxCodeUnits] of SEARCH_FIELD_CAPS) { + index[field] = normalizeAutomationListSearchField(fields[field], maxCodeUnits) } + return index } export function buildAutomationProjectSearchText(parts: { @@ -135,12 +103,12 @@ export function automationListSearchIndexMatches( index: AutomationListSearchIndex, activeQuery: string ): boolean { - // Why: check short fields first so huge-prompt includes rarely run. - return ( - index.name.includes(activeQuery) || - index.project.includes(activeQuery) || - index.prompt.includes(activeQuery) - ) + for (const [field] of SEARCH_FIELD_CAPS) { + if (index[field].includes(activeQuery)) { + return true + } + } + return false } export function automationListSearchFieldsMatch( @@ -221,14 +189,13 @@ export function filterByAutomationListSearch( /** * Content fingerprint for search-relevant fields only. Used so list refresh * ticks that replace arrays with equivalent search content do not rebuild - * indexes or re-run filtering. + * indexes or re-run filtering. Fields are capped first so fingerprinting a + * multi-MB prompt costs the same as indexing it. */ export function buildAutomationListSearchFingerprint( - sources: readonly AutomationListSearchFields[] + sources: readonly AutomationListSearchFields[], + keys?: readonly string[] ): string { - if (sources.length === 0) { - return '' - } let fingerprint = '' for (let i = 0; i < sources.length; i += 1) { if (i > 0) { @@ -238,7 +205,10 @@ export function buildAutomationListSearchFingerprint( if (!source) { continue } - fingerprint += `${source.name}\u0001${source.project}\u0001${source.prompt}` + fingerprint += keys?.[i] ?? '' + for (const [field, maxCodeUnits] of SEARCH_FIELD_CAPS) { + fingerprint += `\u0001${truncateAutomationListSearchField(source[field] ?? '', maxCodeUnits)}` + } } return fingerprint } diff --git a/src/renderer/src/components/automations/automation-list-view.test.ts b/src/renderer/src/components/automations/automation-list-view.test.ts index ef46bc6e428..188c94f3db7 100644 --- a/src/renderer/src/components/automations/automation-list-view.test.ts +++ b/src/renderer/src/components/automations/automation-list-view.test.ts @@ -2,15 +2,22 @@ import { describe, expect, it } from 'vitest' import type { Automation, AutomationRun, + AutomationRunStatus, ExternalAutomationJob, ExternalAutomationManager } from '../../../../shared/automations-types' import { applyAutomationListView, countAutomationListFilters, + EMPTY_AUTOMATION_LIST_FILTER, + filterAutomationListRows, + filterExternalAutomationListEntries, isAutomationListFilterActive, - nextAutomationListSort + nextAutomationListSort, + type AutomationListFilter } from './automation-list-view' +import { hostStableKey } from '../../../../shared/automation-owner-key' +import type { AutomationListRow } from './automation-list-row-identity' import type { ExternalAutomationListEntry } from './external-automation-list-entries' function makeAutomation(overrides: Partial = {}): Automation { @@ -100,7 +107,15 @@ function makeExternalEntry( runs: [], ...overrides } - return { key: `${manager.id}:${job.id}`, manager, job } + return { + key: `${manager.id}:${job.id}`, + scope: { + owner: { authority: { kind: 'desktop' }, selector: { kind: 'self' } }, + provider: manager.provider + }, + manager, + job + } } describe('automation-list-view', () => { @@ -181,6 +196,98 @@ describe('automation-list-view', () => { expect(items.map((item) => item.name)).toEqual(['Alpha digest', 'Zebra job']) }) + it('filters catalog rows by status, agent, and the projected last-run status', () => { + function makeCatalogRow( + id: string, + overrides: Partial, + lastRunStatus?: AutomationRunStatus + ): AutomationListRow { + return { + key: `row|host|${id}`, + automation: makeAutomation({ id, ...overrides }), + hostLabel: 'This computer', + usageSummary: lastRunStatus + ? { + knownRuns: 1, + unavailableRuns: 0, + inputTokens: 0, + outputTokens: 0, + cacheTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + estimatedCostUsd: null, + lastRunStatus, + lastRunAt: 111 + } + : null + } + } + const rows = [ + makeCatalogRow('paused-codex', { enabled: false, agentId: 'codex' }), + makeCatalogRow('failed-claude', { agentId: 'claude' }, 'dispatch_failed'), + makeCatalogRow('succeeded-codex', { agentId: 'codex' }, 'completed'), + makeCatalogRow('never-codex', { agentId: 'codex' }) + ] + const ids = (filter: Partial) => + filterAutomationListRows(rows, { ...EMPTY_AUTOMATION_LIST_FILTER, ...filter }).map( + (row) => row.automation.id + ) + + expect(ids({ status: 'paused' })).toEqual(['paused-codex']) + expect(ids({ agentIds: ['claude'] })).toEqual(['failed-claude']) + expect(ids({ lastRun: 'failed' })).toEqual(['failed-claude']) + expect(ids({ lastRun: 'succeeded' })).toEqual(['succeeded-codex']) + expect(ids({ lastRun: 'never' })).toEqual(['paused-codex', 'never-codex']) + // Inactive filter keeps the input identity so nothing re-renders for it. + expect(filterAutomationListRows(rows, EMPTY_AUTOMATION_LIST_FILTER)).toBe(rows) + }) + + it('narrows rows to any of the selected hosts and drops pre-catalog rows', () => { + const hostRow = (id: string, targetId: string | null): AutomationListRow => ({ + key: `row|${targetId ?? 'unscoped'}|${id}`, + automation: makeAutomation({ id }), + catalogRef: + targetId === null + ? null + : { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId } }, + hostLabel: targetId ?? '', + usageSummary: null + }) + const rows = [hostRow('on-a', 'ssh-a'), hostRow('on-b', 'ssh-b'), hostRow('legacy', null)] + const keyOf = (row: AutomationListRow): string => + row.catalogRef ? hostStableKey(row.catalogRef) : '' + const ids = (hostStableKeys: readonly string[]) => + filterAutomationListRows(rows, { ...EMPTY_AUTOMATION_LIST_FILTER, hostStableKeys }).map( + (row) => row.automation.id + ) + + // Multi-select is any-of; a pre-catalog row names no host and is excluded. + expect(ids([keyOf(rows[0]), keyOf(rows[1])])).toEqual(['on-a', 'on-b']) + expect(ids([keyOf(rows[1])])).toEqual(['on-b']) + }) + + it('excludes external jobs from any agent filter and honors their status', () => { + const entries = [makeExternalEntry({ enabled: false, state: 'disabled' })] + expect( + filterExternalAutomationListEntries(entries, { + ...EMPTY_AUTOMATION_LIST_FILTER, + agentIds: ['codex'] + }) + ).toEqual([]) + expect( + filterExternalAutomationListEntries(entries, { + ...EMPTY_AUTOMATION_LIST_FILTER, + status: 'paused' + }) + ).toHaveLength(1) + expect( + filterExternalAutomationListEntries(entries, { + ...EMPTY_AUTOMATION_LIST_FILTER, + status: 'enabled' + }) + ).toEqual([]) + }) + it('sorts by last run newest first and keeps never-run rows last', () => { const items = applyAutomationListView({ automations: [ diff --git a/src/renderer/src/components/automations/automation-list-view.ts b/src/renderer/src/components/automations/automation-list-view.ts index ae22a4bc74d..7ccff6a4cb4 100644 --- a/src/renderer/src/components/automations/automation-list-view.ts +++ b/src/renderer/src/components/automations/automation-list-view.ts @@ -1,8 +1,11 @@ import { getIntlLocale } from '@/i18n/i18n' import type { Automation, AutomationRun } from '../../../../shared/automations-types' import type { TuiAgent } from '../../../../shared/tui-agent' +import { hostStableKey } from '../../../../shared/automation-owner-key' +import type { AutomationListRow } from './automation-list-row-identity' import type { ExternalAutomationListEntry } from './external-automation-list-entries' import { + getAutomationRowLastRunSnapshot, getExternalAutomationLastRunSnapshot, getLocalAutomationLastRunSnapshot, indexLatestAutomationRuns, @@ -45,23 +48,36 @@ export type AutomationListFilter = { status: AutomationListStatusFilter lastRun: AutomationListLastRunFilter agentIds: readonly TuiAgent[] + /** Catalog stable keys; empty (or absent, on older callers) means every host. */ + hostStableKeys?: readonly string[] } export const EMPTY_AUTOMATION_LIST_FILTER: AutomationListFilter = { status: 'all', lastRun: 'all', - agentIds: [] + agentIds: [], + hostStableKeys: [] +} + +function selectedHostKeys(filter: AutomationListFilter): readonly string[] { + return filter.hostStableKeys ?? [] } export function isAutomationListFilterActive(filter: AutomationListFilter): boolean { - return filter.status !== 'all' || filter.lastRun !== 'all' || filter.agentIds.length > 0 + return ( + filter.status !== 'all' || + filter.lastRun !== 'all' || + filter.agentIds.length > 0 || + selectedHostKeys(filter).length > 0 + ) } export function countAutomationListFilters(filter: AutomationListFilter): number { return ( (filter.status !== 'all' ? 1 : 0) + (filter.lastRun !== 'all' ? 1 : 0) + - (filter.agentIds.length > 0 ? 1 : 0) + (filter.agentIds.length > 0 ? 1 : 0) + + (selectedHostKeys(filter).length > 0 ? 1 : 0) ) } @@ -143,6 +159,64 @@ export function buildAutomationListViewItems({ return [...locals, ...externals] } +/** A pre-catalog row names no host, so a host filter (which implies a hydrated catalog) excludes it. */ +function matchesHostFilter(hostKey: string | null, keys: readonly string[]): boolean { + return keys.length === 0 || (hostKey !== null && keys.includes(hostKey)) +} + +/** External jobs live on their listing scope's host, mirrored into the same stable-key space. */ +function externalEntryHostStableKey(entry: ExternalAutomationListEntry): string { + const owner = entry.scope.owner + return hostStableKey({ + authority: + owner.authority.kind === 'runtime' + ? { kind: 'runtime', environmentId: owner.authority.environmentId } + : { kind: 'desktop' }, + selector: + owner.selector.kind === 'ssh' + ? { kind: 'ssh', targetId: owner.selector.targetId } + : { kind: 'self' } + }) +} + +/** The attribute filter over catalog rows; identity-stable when the filter is inactive. */ +export function filterAutomationListRows( + rows: readonly AutomationListRow[], + filter: AutomationListFilter +): readonly AutomationListRow[] { + if (!isAutomationListFilterActive(filter)) { + return rows + } + const hostKeys = selectedHostKeys(filter) + return rows.filter( + (row) => + matchesHostFilter(row.catalogRef ? hostStableKey(row.catalogRef) : null, hostKeys) && + matchesStatusFilter(row.automation.enabled, filter.status) && + matchesLastRunFilter(getAutomationRowLastRunSnapshot(row), filter.lastRun) && + (filter.agentIds.length === 0 || filter.agentIds.includes(row.automation.agentId)) + ) +} + +/** External jobs have no Orca agent, so any agent filter excludes them — matching the old view. */ +export function filterExternalAutomationListEntries( + entries: readonly ExternalAutomationListEntry[], + filter: AutomationListFilter +): readonly ExternalAutomationListEntry[] { + if (!isAutomationListFilterActive(filter)) { + return entries + } + if (filter.agentIds.length > 0) { + return [] + } + const hostKeys = selectedHostKeys(filter) + return entries.filter( + (entry) => + matchesHostFilter(externalEntryHostStableKey(entry), hostKeys) && + matchesStatusFilter(entry.job.enabled, filter.status) && + matchesLastRunFilter(getExternalAutomationLastRunSnapshot(entry.job), filter.lastRun) + ) +} + export function filterAutomationListViewItems( items: readonly AutomationListViewItem[], filter: AutomationListFilter diff --git a/src/renderer/src/components/automations/automation-notice-recovery-host.ts b/src/renderer/src/components/automations/automation-notice-recovery-host.ts new file mode 100644 index 00000000000..a82fc4ef6f5 --- /dev/null +++ b/src/renderer/src/components/automations/automation-notice-recovery-host.ts @@ -0,0 +1,27 @@ +/** + * The host a refused action's recovery verb has to act on. + * + * A notice is raised where the user was working, which is not where the host + * filter points: a row action addresses that row's captured owner, and a create + * addresses the destination the dialog captured. Without this, Reconnect dials + * and Update server opens settings for whichever host the list happens to be + * scoped to — or, under All hosts, for nothing at all. + */ + +import { hostStableKey } from '../../../../shared/automation-owner-key' +import type { AutomationAuthorityRef } from '../../../../shared/automation-owner-ref' +import type { AutomationCapturedOwner } from './automation-captured-owner' +import type { + AutomationHostCatalog, + AutomationHostCatalogEntry +} from './automation-host-catalog-types' +import { automationRowCatalogRef } from './automation-write-invalidation' + +export function automationRowRecoveryHost( + catalog: AutomationHostCatalog, + captured: AutomationCapturedOwner, + authority: AutomationAuthorityRef +): AutomationHostCatalogEntry | null { + const ref = automationRowCatalogRef(captured, authority) + return ref ? (catalog.byStableKey.get(hostStableKey(ref)) ?? null) : null +} diff --git a/src/renderer/src/components/automations/automation-owner-action-runner.ts b/src/renderer/src/components/automations/automation-owner-action-runner.ts new file mode 100644 index 00000000000..b25baa16d08 --- /dev/null +++ b/src/renderer/src/components/automations/automation-owner-action-runner.ts @@ -0,0 +1,230 @@ +/** + * Runs one action against a row's captured owner and reports the result as + * data. + * + * Owner conflicts are ordinary outcomes here, not exceptions: the host changed, + * the target is gone, the server is too old to fence, or the chosen destination + * no longer resolves. Each one has something the user can do next, so each is + * returned with the recovery action the host-status vocabulary already defines + * rather than thrown into a generic failure toast. + * + * Nothing in this module reads ambient state. The availability it is handed was + * computed from the row the user acted on, and every request is fenced with it. + */ + +import type { + Automation, + AutomationCreateInput, + AutomationRun, + AutomationUpdateInput +} from '../../../../shared/automations-types' +import { + automationOwnerConflictMessage, + stripAutomationOwnerConflictCode, + type AutomationOwnerConflictCode +} from '../../../../shared/automation-owner-conflict' +import type { + AutomationAuthorityRef, + AutomationOwnerRef +} from '../../../../shared/automation-owner-ref' +import type { AutomationDestination } from '../../../../shared/automation-owner-precondition' +import { translate } from '@/i18n/i18n' +import type { + AutomationActionAvailability, + AutomationActionBlock +} from './automation-captured-owner' +import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors' +import { + AutomationHostScopeUnsupportedError, + createAutomationForDestination, + deleteAutomationForOwner, + deleteOrphanAutomation, + listAutomationRunsForOwner, + listAutomationsForOwner, + listOrphanAutomationRuns, + matchAutomationOwnerConflict, + runAutomationNowForOwner, + updateAutomationForOwner, + updateOrphanAutomation +} from './automation-scoped-list-client' + +export type AutomationOwnerConflict = { + code: AutomationOwnerConflictCode | 'unsupported_host_scope' + message: string + /** Null when no offered action would help — re-adding a removed host is manual. */ + recovery: AutomationHostRecoveryAction | null +} + +export type AutomationActionOutcome = + | { status: 'ok'; value: TValue } + | { status: 'blocked'; block: AutomationActionBlock } + | { status: 'conflict'; conflict: AutomationOwnerConflict } + | { status: 'failed'; message: string } + +const CONFLICT_RECOVERY: Record = + { + automation_owner_changed: 'retry', + // The host was deregistered; nothing the page can retry or reconnect brings it back. + automation_target_removed: null, + automation_owner_fencing_required: 'update-server', + automation_destination_invalid: 'retry' + } + +function conflictOutcome(code: AutomationOwnerConflictCode): AutomationActionOutcome { + return { + status: 'conflict', + conflict: { + code, + message: automationOwnerConflictMessage(code), + recovery: CONFLICT_RECOVERY[code] + } + } +} + +function classify(error: unknown): AutomationActionOutcome { + const conflict = matchAutomationOwnerConflict(error) + if (conflict) { + return conflictOutcome(conflict) + } + if (error instanceof AutomationHostScopeUnsupportedError) { + return { + status: 'conflict', + conflict: { + code: 'unsupported_host_scope', + message: stripAutomationOwnerConflictCode(error.message), + recovery: 'update-server' + } + } + } + return { + status: 'failed', + message: + error instanceof Error + ? stripAutomationOwnerConflictCode(error.message) + : translate( + 'auto.components.automations.ownerAction.failed', + 'That automation action did not complete.' + ) + } +} + +/** + * The captured owner decides whether the request is made at all, so a blocked + * row never reaches the transport and an uncaptured one is handed back to the + * caller's legacy path instead of being run against a guessed owner. + */ +async function attempt( + availability: AutomationActionAvailability, + owned: (owner: AutomationOwnerRef) => Promise, + orphanFenced?: () => Promise +): Promise | { status: 'uncaptured' }> { + if (availability.kind === 'blocked') { + return { status: 'blocked', block: availability.block } + } + if (availability.kind === 'uncaptured') { + return { status: 'uncaptured' } + } + try { + if (availability.kind === 'owned') { + return { status: 'ok', value: await owned(availability.owner) } + } + if (!orphanFenced) { + return { status: 'blocked', block: orphanUnsupported() } + } + return { status: 'ok', value: await orphanFenced() } + } catch (error) { + return classify(error) + } +} + +function orphanUnsupported(): AutomationActionBlock { + return { + reason: 'orphan', + message: translate( + 'auto.components.automations.capturedOwner.orphan', + 'This automation has no host to run on. Delete it, or re-add the host it belongs to.' + ), + recovery: null + } +} + +export type AutomationActionResult = + | AutomationActionOutcome + /** The caller must fall back to its unfenced path; Step 9 deletes this arm. */ + | { status: 'uncaptured' } + +export async function updateOwnedAutomation( + availability: AutomationActionAvailability, + authority: AutomationAuthorityRef, + id: string, + updates: AutomationUpdateInput, + destination?: AutomationDestination +): Promise> { + return await attempt( + availability, + (owner) => updateAutomationForOwner(owner, id, updates, destination), + () => updateOrphanAutomation(authority, id, updates) + ) +} + +export async function deleteOwnedAutomation( + availability: AutomationActionAvailability, + authority: AutomationAuthorityRef, + id: string +): Promise> { + return await attempt( + availability, + (owner) => deleteAutomationForOwner(owner, id), + () => deleteOrphanAutomation(authority, id) + ) +} + +export async function runOwnedAutomationNow( + availability: AutomationActionAvailability, + id: string +): Promise> { + return await attempt(availability, (owner) => runAutomationNowForOwner(owner, id)) +} + +/** + * Edit hydration re-reads the record inside its captured scope, so a row that + * moved hosts since the list is reported as gone rather than silently replaced + * by another authority's record with the same ID. + */ +export async function showOwnedAutomation( + availability: AutomationActionAvailability, + id: string +): Promise> { + return await attempt(availability, async (owner) => { + const result = await listAutomationsForOwner(owner) + return result.automations.find((automation) => automation.id === id) ?? null + }) +} + +export async function listOwnedAutomationRuns( + availability: AutomationActionAvailability, + authority: AutomationAuthorityRef, + automationId: string +): Promise> { + return await attempt( + availability, + (owner) => listAutomationRunsForOwner(owner, automationId), + () => listOrphanAutomationRuns(authority, automationId) + ) +} + +/** Create is destination-keyed rather than owner-keyed: the row does not exist yet. */ +export async function createAutomationAtDestination( + authority: AutomationAuthorityRef, + input: AutomationCreateInput, + destination: AutomationDestination +): Promise> { + try { + return { + status: 'ok', + value: await createAutomationForDestination(authority, input, destination) + } + } catch (error) { + return classify(error) + } +} diff --git a/src/renderer/src/components/automations/automation-row-action-dispatch.ts b/src/renderer/src/components/automations/automation-row-action-dispatch.ts new file mode 100644 index 00000000000..564f6cd5d74 --- /dev/null +++ b/src/renderer/src/components/automations/automation-row-action-dispatch.ts @@ -0,0 +1,199 @@ +/** + * One place where a row action decides between its fenced path and its legacy + * one, so the page's handlers stay three lines each. + * + * The order is deliberate. A captured owner always wins. A row the authority + * qualified but could not make executable is refused with the reason. Only a + * row with no owner metadata at all falls back to the unfenced call, and that + * arm exists solely so mixed-version clients keep working until the inference + * is removed — it is the legacy path, not a default. + */ + +import type { + Automation, + AutomationRun, + AutomationUpdateInput +} from '../../../../shared/automations-types' +import type { AutomationAuthorityRef } from '../../../../shared/automation-owner-ref' +import type { AutomationDestination } from '../../../../shared/automation-owner-precondition' +import { + automationActionAvailability, + capturedAutomationOwner, + type AutomationCapturedOwner, + type AutomationRowAction +} from './automation-captured-owner' +import { + deleteOwnedAutomation, + listOwnedAutomationRuns, + runOwnedAutomationNow, + showOwnedAutomation, + updateOwnedAutomation, + type AutomationActionResult +} from './automation-owner-action-runner' +import { actionBlockNotice, ownerConflictNotice } from './AutomationOwnerConflictNotice' +import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors' + +export type AutomationActionNotice = { + message: string + recovery: AutomationHostRecoveryAction | null + /** + * `owner` means the request was refused because of who owns the record, so + * the caller must not proceed with a stale copy; `failure` is an ordinary + * error the caller may still degrade around. + */ + severity: 'owner' | 'failure' +} + +export type AutomationDispatchContext = { + /** Keyed by `AutomationListRow.key`; see {@link AutomationDispatchRow}. */ + capturedOwners: ReadonlyMap + authority: AutomationAuthorityRef +} + +/** + * Which copy of a record an action addresses. Both halves are load-bearing and + * neither derives the other: the row key picks the owner out of a list that may + * hold the same ID twice, and the ID is what that owner's authority stores it + * under. + */ +export type AutomationDispatchRow = { + rowKey: string + automationId: string +} + +export type AutomationDispatchResult = + | { ok: true; value: TValue } + | { ok: false; notice: AutomationActionNotice } + +export function dispatched(value: TValue): AutomationDispatchResult { + return { ok: true, value } +} + +export function toDispatchResult( + outcome: Exclude, { status: 'uncaptured' }> +): AutomationDispatchResult { + switch (outcome.status) { + case 'ok': + return { ok: true, value: outcome.value } + case 'blocked': + return { ok: false, notice: { ...actionBlockNotice(outcome.block), severity: 'owner' } } + case 'conflict': + return { ok: false, notice: { ...ownerConflictNotice(outcome.conflict), severity: 'owner' } } + case 'failed': + return { + ok: false, + notice: { message: outcome.message, recovery: 'retry', severity: 'failure' } + } + } +} + +async function dispatch( + context: AutomationDispatchContext, + row: AutomationDispatchRow, + action: AutomationRowAction, + fenced: ( + availability: ReturnType + ) => Promise>, + legacy: () => Promise +): Promise> { + const availability = automationActionAvailability( + capturedAutomationOwner(context.capturedOwners, row.rowKey), + action + ) + const outcome = await fenced(availability) + if (outcome.status !== 'uncaptured') { + return toDispatchResult(outcome) + } + try { + return { ok: true, value: await legacy() } + } catch (error) { + return { + ok: false, + notice: { + message: error instanceof Error ? error.message : String(error), + recovery: 'retry', + severity: 'failure' + } + } + } +} + +export async function dispatchAutomationUpdate( + context: AutomationDispatchContext, + row: AutomationDispatchRow, + updates: AutomationUpdateInput, + legacy: () => Promise, + action: AutomationRowAction = 'toggle', + destination?: AutomationDestination +): Promise> { + return await dispatch( + context, + row, + action, + (availability) => + updateOwnedAutomation( + availability, + context.authority, + row.automationId, + updates, + destination + ), + legacy + ) +} + +export async function dispatchAutomationDelete( + context: AutomationDispatchContext, + row: AutomationDispatchRow, + legacy: () => Promise +): Promise> { + return await dispatch( + context, + row, + 'delete', + (availability) => deleteOwnedAutomation(availability, context.authority, row.automationId), + legacy + ) +} + +export async function dispatchAutomationRunNow( + context: AutomationDispatchContext, + row: AutomationDispatchRow, + legacy: () => Promise +): Promise> { + return await dispatch( + context, + row, + 'run', + (availability) => runOwnedAutomationNow(availability, row.automationId), + legacy + ) +} + +export async function dispatchAutomationReread( + context: AutomationDispatchContext, + row: AutomationDispatchRow, + legacy: () => Promise +): Promise> { + return await dispatch( + context, + row, + 'edit', + (availability) => showOwnedAutomation(availability, row.automationId), + legacy + ) +} + +export async function dispatchAutomationRunHistory( + context: AutomationDispatchContext, + row: AutomationDispatchRow, + legacy: () => Promise +): Promise> { + return await dispatch( + context, + row, + 'history', + (availability) => listOwnedAutomationRuns(availability, context.authority, row.automationId), + legacy + ) +} diff --git a/src/renderer/src/components/automations/automation-run-completion-evidence.test.ts b/src/renderer/src/components/automations/automation-run-completion-evidence.test.ts deleted file mode 100644 index f46426337a8..00000000000 --- a/src/renderer/src/components/automations/automation-run-completion-evidence.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { AutomationRun } from '../../../../shared/automations-types' -import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import type { RetainedAgentEntry } from '@/store/slices/agent-status' -import { hasAutomationRunCompletionEvidence } from './automation-run-completion-evidence' - -const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' - -function run(): AutomationRun { - return { - id: 'run-1', - automationId: 'automation-1', - title: 'Run 1', - scheduledFor: 1, - status: 'dispatched', - trigger: 'manual', - workspaceId: 'wt-1', - sessionKind: 'terminal', - chatSessionId: null, - terminalSessionId: 'tab-1', - terminalPaneKey: PANE, - terminalPtyId: 'pty-1', - outputSnapshot: null, - precheckResult: null, - usage: null, - error: null, - startedAt: 1_000, - dispatchedAt: 1_000, - createdAt: 1_000 - } -} - -function entry(overrides: Partial = {}): AgentStatusEntry { - return { - paneKey: PANE, - state: 'done', - prompt: '', - updatedAt: 2_000, - stateStartedAt: 2_000, - stateHistory: [], - agentType: 'claude', - ...overrides - } -} - -function retained(status: AgentStatusEntry): RetainedAgentEntry { - return { - entry: status, - worktreeId: 'wt-1', - tab: { - id: 'tab-1', - ptyId: 'pty-1', - worktreeId: 'wt-1', - title: 'Agent', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1_000 - }, - agentType: 'claude', - startedAt: status.stateStartedAt - } -} - -describe('automation run completion evidence (STA-3386)', () => { - it('ignores live and retained session-boundary done rows', () => { - const boundary = entry({ sessionBoundary: true }) - expect( - hasAutomationRunCompletionEvidence({ - run: run(), - dispatchedAt: 1_000, - agentStatusByPaneKey: { [PANE]: boundary }, - retainedAgentsByPaneKey: { [PANE]: retained(boundary) } - }) - ).toBe(false) - }) - - it('accepts a real done after dispatch', () => { - expect( - hasAutomationRunCompletionEvidence({ - run: run(), - dispatchedAt: 1_000, - agentStatusByPaneKey: { [PANE]: entry() }, - retainedAgentsByPaneKey: {} - }) - ).toBe(true) - }) -}) diff --git a/src/renderer/src/components/automations/automation-run-completion-evidence.ts b/src/renderer/src/components/automations/automation-run-completion-evidence.ts deleted file mode 100644 index 26c0b0ffc93..00000000000 --- a/src/renderer/src/components/automations/automation-run-completion-evidence.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { AutomationRun } from '../../../../shared/automations-types' -import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import type { RetainedAgentEntry } from '@/store/slices/agent-status' -import { automationRunMatchesPaneKey } from './automation-run-open-target' - -function isCompletionAfterDispatch(entry: AgentStatusEntry, dispatchedAt: number): boolean { - return entry.state === 'done' && entry.sessionBoundary !== true && entry.updatedAt >= dispatchedAt -} - -export function hasAutomationRunCompletionEvidence({ - run, - dispatchedAt, - agentStatusByPaneKey, - retainedAgentsByPaneKey -}: { - run: AutomationRun - dispatchedAt: number - agentStatusByPaneKey: Record - retainedAgentsByPaneKey: Record -}): boolean { - for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) { - if ( - automationRunMatchesPaneKey(run, paneKey) && - isCompletionAfterDispatch(entry, dispatchedAt) - ) { - return true - } - } - for (const [paneKey, retained] of Object.entries(retainedAgentsByPaneKey)) { - if ( - automationRunMatchesPaneKey(run, paneKey) && - isCompletionAfterDispatch(retained.entry, dispatchedAt) - ) { - return true - } - } - return false -} diff --git a/src/renderer/src/components/automations/automation-run-occurrences.ts b/src/renderer/src/components/automations/automation-run-occurrences.ts new file mode 100644 index 00000000000..71af9043eb8 --- /dev/null +++ b/src/renderer/src/components/automations/automation-run-occurrences.ts @@ -0,0 +1,35 @@ +/** + * Copy for a run row that stands for more than one occurrence. + * + * Coalescing replaced a hundred identical refusals with one row, but that row is + * stamped with the *first* occurrence — so a failure that is still happening + * every hour reads as one thing that happened once, days ago. The count answers + * how bad; the latest timestamp answers whether it is over. + */ + +import type { AutomationRun } from '../../../../shared/automations-types' +import { formatAutomationDateTime } from './automation-page-parts' +import { translate } from '@/i18n/i18n' + +type AutomationRunOccurrences = Pick + +/** Null for the single-occurrence rows, which is every row written before folding. */ +export function automationRunOccurrenceLabel(run: AutomationRunOccurrences): string | null { + const count = run.occurrenceCount ?? 1 + if (count <= 1) { + return null + } + // Not named `count`: i18next reserves it for plural selection, which would send + // these keys looking for `_one`/`_other` variants the catalog does not carry. + // The label only renders above 1, so the plural is always right. + const times = String(count) + // The writer stamps both fields together; a count without a timestamp can only + // come from an older host, and the count alone still beats saying nothing. + return run.lastOccurrenceAt + ? translate( + 'auto.components.automations.runHistory.occurrencesWithLatest', + '{{times}} times, most recently {{date}}', + { times, date: formatAutomationDateTime(run.lastOccurrenceAt) } + ) + : translate('auto.components.automations.runHistory.occurrences', '{{times}} times', { times }) +} diff --git a/src/renderer/src/components/automations/automation-scoped-list-client.test.ts b/src/renderer/src/components/automations/automation-scoped-list-client.test.ts new file mode 100644 index 00000000000..494e0f706cc --- /dev/null +++ b/src/renderer/src/components/automations/automation-scoped-list-client.test.ts @@ -0,0 +1,323 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AUTOMATION_OWNER_CONFLICT_CODES } from '../../../../shared/automation-owner-conflict' +import { + AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' + +const callRuntimeRpc = vi.fn() +const getRuntimeEnvironmentStatus = vi.fn() + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: (...args: unknown[]) => callRuntimeRpc(...args), + getRuntimeEnvironmentStatus: (...args: unknown[]) => getRuntimeEnvironmentStatus(...args), + // Why: the real matcher is exercised in runtime-rpc-result's own tests; here it only needs to be honest about the tail token. + hasRuntimeRpcErrorCode: (error: unknown, code: string) => + typeof (error as { message?: unknown })?.message === 'string' && + (error as { message: string }).message.trimEnd().endsWith(`: ${code}`) +})) + +const DESKTOP = { kind: 'desktop' } as const +const RUNTIME = { kind: 'runtime', environmentId: 'env-1', pairingRevision: 4 } as const +const SSH_OWNER = { + authority: RUNTIME, + selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } +} as const + +const ALL_CAPABILITIES = { + capabilities: [ + AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY + ] +} + +beforeEach(() => { + callRuntimeRpc.mockReset() + getRuntimeEnvironmentStatus.mockReset() +}) + +async function client() { + return await import('./automation-scoped-list-client') +} + +describe('listScopedAutomations', () => { + it('rejects a legacy-shaped payload instead of committing it as one host', async () => { + const { listScopedAutomations, AutomationHostScopeUnsupportedError } = await client() + callRuntimeRpc.mockResolvedValue({ automations: [{ id: 'a1' }] }) + await expect(listScopedAutomations(DESKTOP, { kind: 'self' })).rejects.toBeInstanceOf( + AutomationHostScopeUnsupportedError + ) + }) + + it('rejects a structurally broken payload', async () => { + const { listScopedAutomations, AutomationListResponseError } = await client() + callRuntimeRpc.mockResolvedValue({ automations: 'nope' }) + await expect(listScopedAutomations(DESKTOP, { kind: 'self' })).rejects.toBeInstanceOf( + AutomationListResponseError + ) + }) + + it('drops rows the host scoped elsewhere and keeps the rest', async () => { + const { listScopedAutomations } = await client() + callRuntimeRpc.mockResolvedValue({ + automations: [{ id: 'a1' }, { id: 'a2' }], + items: [ + { automationId: 'a1', selector: { kind: 'ssh', targetId: 'other', targetGeneration: 1 } }, + { automationId: 'a2', selector: { kind: 'self' } } + ], + orphanCount: 2 + }) + const result = await listScopedAutomations(DESKTOP, { kind: 'self' }) + expect(result.automations.map((entry) => entry.id)).toEqual(['a2']) + expect(result.invalidRows).toBe(1) + expect(result.orphanCount).toBe(2) + }) + + it('addresses the desktop authority as the local runtime target, unprobed', async () => { + const { listScopedAutomations } = await client() + callRuntimeRpc.mockResolvedValue({ automations: [], items: [], orphanCount: 0 }) + await listScopedAutomations(DESKTOP, { kind: 'self' }) + expect(getRuntimeEnvironmentStatus).not.toHaveBeenCalled() + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'local' }, + 'automation.list', + { selector: { kind: 'self' } }, + // Why: the in-process runtime has no pairing revision to pin. + expect.not.objectContaining({ expectedEnvironmentPairingRevision: expect.anything() }) + ) + }) + + it('negotiates host scope and pins the request to the captured pairing revision', async () => { + const { listScopedAutomations } = await client() + getRuntimeEnvironmentStatus.mockResolvedValue(ALL_CAPABILITIES) + callRuntimeRpc.mockResolvedValue({ automations: [], items: [], orphanCount: 0 }) + await listScopedAutomations(RUNTIME, { + kind: 'ssh', + targetId: 'ssh-1', + expectedTargetGeneration: 7 + }) + expect(getRuntimeEnvironmentStatus).toHaveBeenCalledWith('env-1', expect.any(Number)) + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'automation.list', + { selector: { kind: 'ssh', targetId: 'ssh-1', expectedTargetGeneration: 7 } }, + expect.objectContaining({ expectedEnvironmentPairingRevision: 4 }) + ) + }) + + it('surfaces a stale pairing revision instead of retrying unfenced', async () => { + const { listScopedAutomations } = await client() + getRuntimeEnvironmentStatus.mockResolvedValue(ALL_CAPABILITIES) + callRuntimeRpc.mockRejectedValue(new Error('runtime_environment_revision_changed')) + await expect(listScopedAutomations(RUNTIME, { kind: 'self' })).rejects.toThrow( + 'runtime_environment_revision_changed' + ) + }) + + it('does not query a host that never advertised host scope', async () => { + const { listScopedAutomations, AutomationHostScopeUnsupportedError } = await client() + getRuntimeEnvironmentStatus.mockResolvedValue({ capabilities: [] }) + await expect(listScopedAutomations(RUNTIME, { kind: 'self' })).rejects.toBeInstanceOf( + AutomationHostScopeUnsupportedError + ) + expect(callRuntimeRpc).not.toHaveBeenCalled() + }) + + // An unreachable host proves nothing about its version, so it must not be reported as too old. + it('propagates an unreachable authority instead of calling it incompatible', async () => { + const { listScopedAutomations, AutomationHostScopeUnsupportedError } = await client() + getRuntimeEnvironmentStatus.mockRejectedValue(new Error('runtime_unavailable')) + await expect(listScopedAutomations(RUNTIME, { kind: 'self' })).rejects.not.toBeInstanceOf( + AutomationHostScopeUnsupportedError + ) + }) +}) + +describe('owner-fenced mutations', () => { + it('sends the captured owner with the mutation', async () => { + const { updateAutomationForOwner } = await client() + getRuntimeEnvironmentStatus.mockResolvedValue(ALL_CAPABILITIES) + callRuntimeRpc.mockResolvedValue({ automation: { id: 'a1' } }) + await updateAutomationForOwner(SSH_OWNER, 'a1', { enabled: false }) + expect(callRuntimeRpc.mock.calls[0]?.[2]).toEqual({ + id: 'a1', + updates: { enabled: false }, + expectedOwner: { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } }, + destination: undefined + }) + }) + + it('sends the captured destination with a selector-moving mutation', async () => { + const { updateAutomationForOwner } = await client() + getRuntimeEnvironmentStatus.mockResolvedValue(ALL_CAPABILITIES) + callRuntimeRpc.mockResolvedValue({ automation: { id: 'a1' } }) + await updateAutomationForOwner( + { authority: DESKTOP, selector: { kind: 'self' } }, + 'a1', + { projectId: 'repo-ssh' }, + { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } } + ) + expect(callRuntimeRpc.mock.calls[0]?.[2]).toEqual({ + id: 'a1', + updates: { repo: 'repo-ssh' }, + expectedOwner: { selector: { kind: 'self' } }, + destination: { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } } + }) + }) + + it('stays view-only against a host without owner fencing', async () => { + const { updateAutomationForOwner, AutomationHostScopeUnsupportedError } = await client() + getRuntimeEnvironmentStatus.mockResolvedValue({ + capabilities: [AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY] + }) + await expect( + updateAutomationForOwner(SSH_OWNER, 'a1', { enabled: false }) + ).rejects.toBeInstanceOf(AutomationHostScopeUnsupportedError) + expect(callRuntimeRpc).not.toHaveBeenCalled() + }) + + it('fences a desktop mutation over the local runtime target with the same precondition', async () => { + const { deleteAutomationForOwner, updateAutomationForOwner } = await client() + callRuntimeRpc.mockResolvedValue({ automation: { id: 'a1' } }) + await updateAutomationForOwner({ authority: DESKTOP, selector: { kind: 'self' } }, 'a1', { + enabled: true + }) + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'local' }, + 'automation.update', + expect.objectContaining({ expectedOwner: { selector: { kind: 'self' } } }), + expect.anything() + ) + expect(getRuntimeEnvironmentStatus).not.toHaveBeenCalled() + expect(typeof deleteAutomationForOwner).toBe('function') + }) +}) + +// The orphan arm used to live in the caller with its own transport choice and no +// capability probe. These pin it to the owned arm's behaviour so the two cannot +// drift apart again. +describe('orphan-fenced mutations', () => { + it('fences on the orphan precondition over the same runtime transport', async () => { + const { deleteOrphanAutomation } = await client() + getRuntimeEnvironmentStatus.mockResolvedValue(ALL_CAPABILITIES) + callRuntimeRpc.mockResolvedValue(undefined) + await deleteOrphanAutomation(RUNTIME, 'a1') + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'automation.delete', + { id: 'a1', expectedOwner: { selector: { kind: 'orphan' } } }, + expect.objectContaining({ expectedEnvironmentPairingRevision: 4 }) + ) + }) + + it('probes the host before an orphan mutation, exactly as an owned row does', async () => { + const { updateOrphanAutomation, AutomationHostScopeUnsupportedError } = await client() + getRuntimeEnvironmentStatus.mockResolvedValue({ + capabilities: [AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY] + }) + await expect(updateOrphanAutomation(RUNTIME, 'a1', { enabled: false })).rejects.toBeInstanceOf( + AutomationHostScopeUnsupportedError + ) + expect(callRuntimeRpc).not.toHaveBeenCalled() + }) + + // An unreachable host is a retry, not an upgrade prompt — the orphan arm inherits this. + it('propagates an unreachable authority instead of calling it incompatible', async () => { + const { deleteOrphanAutomation, AutomationHostScopeUnsupportedError } = await client() + getRuntimeEnvironmentStatus.mockRejectedValue(new Error('runtime_unavailable')) + await expect(deleteOrphanAutomation(RUNTIME, 'a1')).rejects.not.toBeInstanceOf( + AutomationHostScopeUnsupportedError + ) + }) + + it('routes a desktop orphan over the local runtime target, unprobed', async () => { + const { deleteOrphanAutomation, updateOrphanAutomation } = await client() + callRuntimeRpc.mockResolvedValue({ automation: { id: 'a1' } }) + await updateOrphanAutomation(DESKTOP, 'a1', { enabled: false }) + await deleteOrphanAutomation(DESKTOP, 'a1') + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'local' }, + 'automation.update', + { + id: 'a1', + updates: { enabled: false }, + expectedOwner: { selector: { kind: 'orphan' } }, + // An orphan has no host to be moved to, so no destination is ever sent. + destination: undefined + }, + expect.anything() + ) + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'local' }, + 'automation.delete', + { id: 'a1', expectedOwner: { selector: { kind: 'orphan' } } }, + expect.anything() + ) + expect(getRuntimeEnvironmentStatus).not.toHaveBeenCalled() + }) + + // History is the one action an orphan keeps (ORPHAN_ACTIONS), so it must reach + // the transport rather than resolving as "no host to run on". + it('reads an orphan run history over the runtime transport', async () => { + const { listOrphanAutomationRuns } = await client() + callRuntimeRpc.mockResolvedValue({ runs: [{ id: 'r1' }] }) + const runs = await listOrphanAutomationRuns(RUNTIME, 'a1') + expect(runs).toEqual([{ id: 'r1' }]) + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'automation.runs', + { automationId: 'a1', expectedOwner: { selector: { kind: 'orphan' } } }, + expect.objectContaining({ expectedEnvironmentPairingRevision: 4 }) + ) + }) + + it('reads a desktop orphan history over the local runtime target', async () => { + const { listOrphanAutomationRuns } = await client() + callRuntimeRpc.mockResolvedValue({ runs: [] }) + await listOrphanAutomationRuns(DESKTOP, 'a1') + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'local' }, + 'automation.runs', + { automationId: 'a1', expectedOwner: { selector: { kind: 'orphan' } } }, + expect.anything() + ) + }) + + // Read-only, so it deliberately takes the owned read's probe behaviour — which + // today is no probe. Pinned so the two arms cannot diverge silently. + it('probes on the orphan history read exactly as often as the owned read does', async () => { + const { listAutomationRunsForOwner, listOrphanAutomationRuns } = await client() + callRuntimeRpc.mockResolvedValue({ runs: [] }) + await listAutomationRunsForOwner(SSH_OWNER, 'a1') + const ownedProbes = getRuntimeEnvironmentStatus.mock.calls.length + getRuntimeEnvironmentStatus.mockClear() + await listOrphanAutomationRuns(RUNTIME, 'a1') + expect(getRuntimeEnvironmentStatus.mock.calls.length).toBe(ownedProbes) + }) + + it('cannot be handed an owner precondition — the fence is not a parameter', async () => { + const { updateOrphanAutomation, ORPHAN_OWNER_PRECONDITION } = await client() + callRuntimeRpc.mockResolvedValue({ automation: { id: 'a1' } }) + await updateOrphanAutomation(DESKTOP, 'a1', { enabled: false }) + expect(callRuntimeRpc.mock.calls[0]?.[2]?.expectedOwner).toEqual(ORPHAN_OWNER_PRECONDITION) + expect(updateOrphanAutomation.length).toBe(3) + }) +}) + +describe('matchAutomationOwnerConflict', () => { + it('classifies a conflict rewrapped by Electron IPC', async () => { + const { matchAutomationOwnerConflict } = await client() + const wrapped = new Error( + `Error invoking remote method 'automations:update': Error: This automation's host changed. Reload it before continuing.: ${AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged}` + ) + expect(matchAutomationOwnerConflict(wrapped)).toBe(AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged) + }) + + it('classifies a structured error code and ignores unrelated failures', async () => { + const { matchAutomationOwnerConflict } = await client() + expect( + matchAutomationOwnerConflict({ code: AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved }) + ).toBe(AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved) + expect(matchAutomationOwnerConflict(new Error('timeout'))).toBeNull() + }) +}) diff --git a/src/renderer/src/components/automations/automation-scoped-list-client.ts b/src/renderer/src/components/automations/automation-scoped-list-client.ts new file mode 100644 index 00000000000..3f649dcbdc0 --- /dev/null +++ b/src/renderer/src/components/automations/automation-scoped-list-client.ts @@ -0,0 +1,339 @@ +/** + * Owner-qualified automation requests for one authority. + * + * Every call carries the incarnation the caller captured: a runtime request is + * pinned to its `pairingRevision` through the existing environment revision + * guard, and an SSH-scoped request carries the registration generation the row + * was fetched under. Responses are validated, never cast — an older host that + * silently drops the selector answers with its whole authority, and committing + * that would attribute other hosts' automations to the selected one. + */ + +import { + callRuntimeRpc, + getRuntimeEnvironmentStatus, + type RuntimeClientTarget +} from '@/runtime/runtime-rpc-client' +import type { + Automation, + AutomationCreateInput, + AutomationRun, + AutomationUpdateInput +} from '../../../../shared/automations-types' +import type { + AutomationListResult, + AutomationListScopeSelector +} from '../../../../shared/automation-list-scope' +import { validateAutomationListResponse } from '../../../../shared/automation-list-response' +import type { + AutomationAuthorityRef, + AutomationOwnerRef +} from '../../../../shared/automation-owner-ref' +/** The one classifier every client shares; re-exported so call sites keep importing it from here. */ +export { matchAutomationOwnerConflict } from '../../../../shared/automation-owner-conflict' +import type { + AutomationDestination, + AutomationOwnerPrecondition +} from '../../../../shared/automation-owner-precondition' +import { + AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, + AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE, + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, + AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE, + type RuntimeCapability +} from '../../../../shared/protocol-version' +import { automationAuthorityCatalogKey } from './automation-host-catalog-types' +import { toRuntimeAutomationUpdateInput } from './automation-host-client' +import { automationHostDiagnostics } from './automation-host-diagnostics' + +const REQUEST_TIMEOUT_MS = 15_000 + +export class AutomationHostScopeUnsupportedError extends Error { + readonly code = 'unsupported_host_scope' + + constructor(message: string) { + super(message) + this.name = 'AutomationHostScopeUnsupportedError' + } +} + +export class AutomationListResponseError extends Error { + readonly code = 'invalid_response' + + constructor(message: string) { + super(message) + this.name = 'AutomationListResponseError' + } +} + +export type ScopedAutomationList = AutomationListResult & { + /** Rows dropped because their metadata was missing, duplicated, or scoped elsewhere. */ + invalidRows: number +} + +function runtimeTarget(authority: AutomationAuthorityRef): RuntimeClientTarget { + return authority.kind === 'desktop' + ? { kind: 'local' } + : { kind: 'environment', environmentId: authority.environmentId } +} + +function scopeSelector(owner: AutomationOwnerRef): AutomationListScopeSelector { + return owner.selector.kind === 'ssh' + ? { + kind: 'ssh', + targetId: owner.selector.targetId, + expectedTargetGeneration: owner.selector.targetGeneration + } + : { kind: 'self' } +} + +/** Orphan rows have a known authority and no executable owner, so delete/pause fence on that instead. */ +export const ORPHAN_OWNER_PRECONDITION: AutomationOwnerPrecondition = { + selector: { kind: 'orphan' } +} + +export function ownerPrecondition(owner: AutomationOwnerRef): AutomationOwnerPrecondition { + return { + selector: + owner.selector.kind === 'ssh' + ? { + kind: 'ssh', + targetId: owner.selector.targetId, + targetGeneration: owner.selector.targetGeneration + } + : { kind: 'self' } + } +} + +async function callAuthority( + authority: AutomationAuthorityRef, + method: string, + params: unknown +): Promise { + return await callRuntimeRpc(runtimeTarget(authority), method, params, { + timeoutMs: REQUEST_TIMEOUT_MS, + // Why: a same-id re-pair must invalidate this request instead of retargeting it. + ...(authority.kind === 'runtime' + ? { expectedEnvironmentPairingRevision: authority.pairingRevision } + : {}) + }) +} + +/** + * Fails closed on a missing capability, but only on a *known* absence: an + * unreachable authority must classify as unavailable and retry, not as an old + * server the user is told to upgrade. + * + * The probe is counted here because it is counted nowhere else: it deliberately + * re-fetches on every call and rides outside the scheduler's four-slot pool, so + * an instrument that saw only pooled work would report half the relay traffic a + * 50-host refresh actually costs. + */ +async function assertAuthorityCapability( + authority: AutomationAuthorityRef, + capability: RuntimeCapability, + message: string +): Promise { + if (authority.kind !== 'runtime') { + return + } + automationHostDiagnostics.recordCapabilityProbe({ + authorityKey: automationAuthorityCatalogKey(authority) + }) + const status = await getRuntimeEnvironmentStatus(authority.environmentId, REQUEST_TIMEOUT_MS) + if (!status.capabilities?.includes(capability)) { + throw new AutomationHostScopeUnsupportedError(message) + } +} + +async function assertOwnerFencingSupported(authority: AutomationAuthorityRef): Promise { + await assertAuthorityCapability( + authority, + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, + AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE + ) +} + +function validated(raw: unknown, selector: AutomationListScopeSelector): ScopedAutomationList { + const validation = validateAutomationListResponse(raw, selector) + if (!validation.ok) { + throw validation.error.code === 'unsupported_host_scope' + ? new AutomationHostScopeUnsupportedError(validation.error.message) + : new AutomationListResponseError(validation.error.message) + } + return { ...validation.result, invalidRows: validation.invalidRows } +} + +export async function listScopedAutomations( + authority: AutomationAuthorityRef, + selector: AutomationListScopeSelector +): Promise { + // Why: the desktop authority is the in-process runtime, so the probe no-ops + // there — its scoped contract ships with the client and can never lag it. + await assertAuthorityCapability( + authority, + AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, + AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE + ) + return validated(await callAuthority(authority, 'automation.list', { selector }), selector) +} + +/** + * The one unscoped request an old runtime gets per refresh cycle. Its result is + * partitioned client-side into every requested entry; it carries no owners and + * no usage projection, so those rows stay view-only. + */ +export async function listLegacyAutomations( + authority: AutomationAuthorityRef +): Promise { + if (authority.kind === 'desktop') { + // Desktop storage ships the scoped contract in-process, so it never degrades. + throw new AutomationListResponseError('The desktop authority always supports scoped lists.') + } + const raw = await callAuthority(authority, 'automation.list', null) + const automations = (raw as { automations?: unknown } | null)?.automations + if (!Array.isArray(automations)) { + throw new AutomationListResponseError('The host returned an unreadable automation list.') + } + return automations as Automation[] +} + +/** Convenience wrapper for a row's own host; orphan scopes are requested with the selector form. */ +export async function listAutomationsForOwner( + owner: AutomationOwnerRef +): Promise { + return await listScopedAutomations(owner.authority, scopeSelector(owner)) +} + +/** + * Deliberately unprobed, matching the mutation arms' *absence* of a probe here: + * history is read-only, and an older host that ignores the precondition answers + * with the rows it has rather than acting on a fence it never honoured. + */ +async function listRunsFenced( + authority: AutomationAuthorityRef, + automationId: string, + expectedOwner: AutomationOwnerPrecondition +): Promise { + const result = await callAuthority<{ runs: AutomationRun[] }>(authority, 'automation.runs', { + automationId, + expectedOwner + }) + return result.runs +} + +export async function listAutomationRunsForOwner( + owner: AutomationOwnerRef, + automationId: string +): Promise { + return await listRunsFenced(owner.authority, automationId, ownerPrecondition(owner)) +} + +/** + * The one fenced-mutation path every authority shares. Owned and orphan rows + * differ in the precondition they fence with and in nothing else, so they + * share the transport, the capability probe, and any check either later gains. + */ +async function updateFenced( + authority: AutomationAuthorityRef, + id: string, + updates: AutomationUpdateInput, + expectedOwner: AutomationOwnerPrecondition, + destination?: AutomationDestination +): Promise { + await assertOwnerFencingSupported(authority) + const result = await callAuthority<{ automation: Automation }>(authority, 'automation.update', { + id, + updates: toRuntimeAutomationUpdateInput(updates), + expectedOwner, + destination + }) + return result.automation +} + +async function deleteFenced( + authority: AutomationAuthorityRef, + id: string, + expectedOwner: AutomationOwnerPrecondition +): Promise { + await assertOwnerFencingSupported(authority) + await callAuthority(authority, 'automation.delete', { id, expectedOwner }) +} + +export async function updateAutomationForOwner( + owner: AutomationOwnerRef, + id: string, + updates: AutomationUpdateInput, + destination?: AutomationDestination +): Promise { + return await updateFenced(owner.authority, id, updates, ownerPrecondition(owner), destination) +} + +export async function deleteAutomationForOwner( + owner: AutomationOwnerRef, + id: string +): Promise { + await deleteFenced(owner.authority, id, ownerPrecondition(owner)) +} + +/** + * Orphan rows have no owner to key by, so the orphan precondition is the fence. + * The authority is still known and still probed: an orphan on an out-of-date + * host is refused for the same reason an owned row there is. + * + * No destination is accepted — moving a row needs a host it can move to. + */ +export async function updateOrphanAutomation( + authority: AutomationAuthorityRef, + id: string, + updates: AutomationUpdateInput +): Promise { + return await updateFenced(authority, id, updates, ORPHAN_OWNER_PRECONDITION) +} + +export async function deleteOrphanAutomation( + authority: AutomationAuthorityRef, + id: string +): Promise { + await deleteFenced(authority, id, ORPHAN_OWNER_PRECONDITION) +} + +/** + * An orphan keeps its history: reading past runs needs no host to run on, which + * is why `ORPHAN_ACTIONS` allows it where execution is blocked. + */ +export async function listOrphanAutomationRuns( + authority: AutomationAuthorityRef, + automationId: string +): Promise { + return await listRunsFenced(authority, automationId, ORPHAN_OWNER_PRECONDITION) +} + +export async function runAutomationNowForOwner( + owner: AutomationOwnerRef, + id: string +): Promise { + await assertOwnerFencingSupported(owner.authority) + const expectedOwner = ownerPrecondition(owner) + const result = await callAuthority<{ run: AutomationRun }>(owner.authority, 'automation.runNow', { + id, + expectedOwner + }) + return result.run +} + +export async function createAutomationForDestination( + authority: AutomationAuthorityRef, + input: AutomationCreateInput, + destination: AutomationDestination +): Promise { + await assertOwnerFencingSupported(authority) + const { projectId, workspaceId, ...rest } = input + const result = await callAuthority<{ automation: Automation }>(authority, 'automation.create', { + ...rest, + repo: projectId, + workspace: input.workspaceMode === 'existing' ? (workspaceId ?? undefined) : undefined, + destination + }) + return result.automation +} diff --git a/src/renderer/src/components/automations/automation-usage-model.test.ts b/src/renderer/src/components/automations/automation-usage-model.test.ts index 665249ebf08..09c063e1360 100644 --- a/src/renderer/src/components/automations/automation-usage-model.test.ts +++ b/src/renderer/src/components/automations/automation-usage-model.test.ts @@ -85,7 +85,9 @@ describe('automation usage model', () => { cacheTokens: 400, reasoningOutputTokens: 50, totalTokens: 1350, - estimatedCostUsd: 0.0042 + estimatedCostUsd: 0.0042, + lastRunStatus: 'completed', + lastRunAt: 1 }) }) diff --git a/src/renderer/src/components/automations/automation-usage-model.ts b/src/renderer/src/components/automations/automation-usage-model.ts index fcc43e0703e..19118884ed7 100644 --- a/src/renderer/src/components/automations/automation-usage-model.ts +++ b/src/renderer/src/components/automations/automation-usage-model.ts @@ -1,62 +1,9 @@ -import type { AutomationRun, AutomationRunUsage } from '../../../../shared/automations-types' +import type { AutomationRunUsage } from '../../../../shared/automations-types' -export type AutomationUsageSummary = { - knownRuns: number - unavailableRuns: number - inputTokens: number - outputTokens: number - cacheTokens: number - reasoningOutputTokens: number - totalTokens: number - estimatedCostUsd: number | null -} - -export function summarizeAutomationRunUsage( - runs: readonly AutomationRun[] -): AutomationUsageSummary { - let knownRuns = 0 - let unavailableRuns = 0 - let inputTokens = 0 - let outputTokens = 0 - let cacheTokens = 0 - let reasoningOutputTokens = 0 - let totalTokens = 0 - let estimatedCostUsd = 0 - let hasKnownCost = false - - for (const run of runs) { - const usage = run.usage - if (!usage) { - unavailableRuns++ - continue - } - if (usage.status !== 'known') { - unavailableRuns++ - continue - } - knownRuns++ - inputTokens += usage.inputTokens ?? 0 - outputTokens += usage.outputTokens ?? 0 - cacheTokens += (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0) - reasoningOutputTokens += usage.reasoningOutputTokens ?? 0 - totalTokens += usage.totalTokens ?? 0 - if (usage.estimatedCostUsd !== null) { - estimatedCostUsd += usage.estimatedCostUsd - hasKnownCost = true - } - } - - return { - knownRuns, - unavailableRuns, - inputTokens, - outputTokens, - cacheTokens, - reasoningOutputTokens, - totalTokens, - estimatedCostUsd: hasKnownCost ? estimatedCostUsd : null - } -} +// Why: the summary is computed by whichever authority owns the retained runs, so +// it lives in shared code; only the display formatting below is renderer-only. +export type { AutomationUsageSummary } from '../../../../shared/automation-usage-summary' +export { summarizeAutomationRunUsage } from '../../../../shared/automation-usage-summary' export function formatAutomationTokens(value: number | null | undefined): string { if (!value) { diff --git a/src/renderer/src/components/automations/automation-write-invalidation.test.ts b/src/renderer/src/components/automations/automation-write-invalidation.test.ts new file mode 100644 index 00000000000..f1e3d80255a --- /dev/null +++ b/src/renderer/src/components/automations/automation-write-invalidation.test.ts @@ -0,0 +1,81 @@ +/** + * A write knows the host it addressed; these pin that it never widens that into + * another host's rows, and never narrows an unknown host into a guessed one. + */ + +import { describe, expect, it } from 'vitest' +import type { AutomationAuthorityRef } from '../../../../shared/automation-owner-ref' +import type { AutomationCapturedOwner } from './automation-captured-owner' +import { + automationRowCatalogRef, + automationWriteChangeEvent, + stableAutomationAuthorityRef +} from './automation-write-invalidation' + +const DESKTOP: AutomationAuthorityRef = { kind: 'desktop' } +const RUNTIME: AutomationAuthorityRef = { + kind: 'runtime', + environmentId: 'env-1', + pairingRevision: 7 +} + +describe('automation write invalidation', () => { + it('drops the incarnation from the authority', () => { + expect(stableAutomationAuthorityRef(RUNTIME)).toEqual({ + kind: 'runtime', + environmentId: 'env-1' + }) + expect(stableAutomationAuthorityRef(DESKTOP)).toEqual({ kind: 'desktop' }) + }) + + it('takes the host from the row owner, not the active authority', () => { + const captured: AutomationCapturedOwner = { + owner: { + authority: RUNTIME, + selector: { kind: 'ssh', targetId: 'target-1', targetGeneration: 3 } + }, + selector: { kind: 'ssh', targetId: 'target-1' } + } + + expect(automationRowCatalogRef(captured, DESKTOP)).toEqual({ + authority: { kind: 'runtime', environmentId: 'env-1' }, + selector: { kind: 'ssh', targetId: 'target-1' } + }) + }) + + it('falls back to the captured selector under the acting authority', () => { + const captured: AutomationCapturedOwner = { + owner: null, + selector: { kind: 'orphan', issue: 'target-missing' } + } + + expect(automationRowCatalogRef(captured, DESKTOP)).toEqual({ + authority: { kind: 'desktop' }, + selector: { kind: 'orphan' } + }) + }) + + it('names no host for a row that carried no metadata', () => { + expect(automationRowCatalogRef({ owner: null, selector: null }, DESKTOP)).toBeNull() + }) + + it('widens to the whole authority rather than guessing a selector', () => { + expect(automationWriteChangeEvent(null, RUNTIME)).toEqual({ + authority: { kind: 'runtime', environmentId: 'env-1' }, + reason: 'definition' + }) + }) + + it('scopes the event to the host the write named', () => { + expect( + automationWriteChangeEvent( + { authority: { kind: 'desktop' }, selector: { kind: 'self' } }, + DESKTOP + ) + ).toEqual({ + authority: { kind: 'desktop' }, + selector: { kind: 'self' }, + reason: 'definition' + }) + }) +}) diff --git a/src/renderer/src/components/automations/automation-write-invalidation.ts b/src/renderer/src/components/automations/automation-write-invalidation.ts new file mode 100644 index 00000000000..751f237dcf2 --- /dev/null +++ b/src/renderer/src/components/automations/automation-write-invalidation.ts @@ -0,0 +1,77 @@ +/** + * Which host cache entry a local automation write must invalidate. + * + * The authority publishes its own `automationsChanged` event, but that is an IPC + * round trip the user spends looking at the list they just changed — and it is + * droppable, because the host a create lands on may have no cache entry yet. The + * write already knows the host it addressed, so it feeds the same invalidation + * in directly. A row whose owner was never captured names no host, and the whole + * authority is invalidated rather than a guessed selector. + */ + +import { toStableCatalogRef } from '../../../../shared/automation-owner-key' +import type { + AutomationAuthorityRef, + StableAutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import type { AutomationCapturedOwner } from './automation-captured-owner' +import type { + AutomationAuthorityChangeEvent, + AutomationAuthorityChangeReason +} from './automation-host-invalidation' + +export function stableAutomationAuthorityRef( + authority: AutomationAuthorityRef +): StableAutomationAuthorityRef { + return authority.kind === 'desktop' + ? { kind: 'desktop' } + : { kind: 'runtime', environmentId: authority.environmentId } +} + +/** + * The host a listed row belongs to. Null when the row carried no owner metadata + * at all: an authority that never qualified its rows cannot be asked which of + * its hosts changed. + */ +export function automationRowCatalogRef( + captured: AutomationCapturedOwner, + authority: AutomationAuthorityRef +): StableAutomationCatalogRef | null { + if (captured.owner) { + return toStableCatalogRef(captured.owner) + } + if (!captured.selector) { + return null + } + const stable = stableAutomationAuthorityRef(authority) + // Rebuilt per variant rather than spread: the stable ref drops the generation and the + // orphan issue, and a widened `kind` would not discriminate. + switch (captured.selector.kind) { + case 'ssh': + return { + authority: stable, + selector: { kind: 'ssh', targetId: captured.selector.targetId } + } + case 'orphan': + return { authority: stable, selector: { kind: 'orphan' } } + case 'self': + return { authority: stable, selector: { kind: 'self' } } + } +} + +/** + * The invalidation a completed write owes, ready to feed to the host controller. + * The reason is the caller's: a run rewrites history and the next-run projection + * without touching the definition, and a subscriber that filters on it must not + * be told otherwise. + */ +export function automationWriteChangeEvent( + ref: StableAutomationCatalogRef | null, + authority: AutomationAuthorityRef, + reason: AutomationAuthorityChangeReason = 'definition' +): AutomationAuthorityChangeEvent { + return ref + ? { authority: ref.authority, selector: ref.selector, reason } + : { authority: stableAutomationAuthorityRef(authority), reason } +} diff --git a/src/renderer/src/components/automations/automations-page-fixtures.ts b/src/renderer/src/components/automations/automations-page-fixtures.ts new file mode 100644 index 00000000000..7dfe4058ad4 --- /dev/null +++ b/src/renderer/src/components/automations/automations-page-fixtures.ts @@ -0,0 +1,249 @@ +/** + * Fixtures for the automations page characterization tests. + * + * The automation defaults are deliberately the *runnable* shape — a local record + * whose repo has no SSH connection — so Run Now reaches its handler instead of + * being refused by the availability gate. Tests that need an unrunnable record + * override the fields they care about. + */ + +import type { + Automation, + AutomationRun, + ExternalAutomationManager +} from '../../../../shared/automations-types' +import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' +import type { + ExternalAutomationScope, + ScopedExternalAutomationManager +} from './external-automation-scope-client' +import { automationListRowKey, type AutomationListRow } from './automation-list-row-identity' +import { ALL_AUTOMATION_HOSTS_FILTER } from '../../../../shared/automation-host-filter' +import { getDefaultSettings } from '../../../../shared/constants' + +export const REPO_ID = 'repo-1' +export const WORKSPACE_ID = 'workspace-1' + +export function makeAutomation(overrides: Partial = {}): Automation { + return { + id: 'a-1', + name: 'Nightly', + prompt: 'Run the nightly sweep', + precheck: null, + agentId: 'claude', + runContext: null, + projectId: REPO_ID, + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'new_per_run', + workspaceId: null, + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: 1, + enabled: true, + nextRunAt: 2, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +/** A list row for a record on a given host; the host defaults to the desktop. */ +export function makeAutomationListRow( + overrides: Partial & { hostStableKey?: string } = {} +): AutomationListRow { + const automation = overrides.automation ?? makeAutomation() + const hostStableKey = overrides.hostStableKey ?? 'host:desktop:self' + return { + key: overrides.key ?? automationListRowKey(hostStableKey, automation.id), + automation, + hostLabel: overrides.hostLabel ?? 'This computer', + usageSummary: overrides.usageSummary ?? null + } +} + +export function makeRun(overrides: Partial = {}): AutomationRun { + return { + id: 'run-1', + automationId: 'a-1', + title: 'Nightly #1', + scheduledFor: 10, + status: 'completed', + trigger: 'scheduled', + workspaceId: null, + sessionKind: 'terminal', + chatSessionId: null, + terminalSessionId: null, + terminalPaneKey: null, + terminalPtyId: null, + outputSnapshot: null, + precheckResult: null, + usage: null, + error: null, + startedAt: 10, + dispatchedAt: 10, + createdAt: 10, + ...overrides + } +} + +export function makeExternalManager( + overrides: Partial = {} +): ExternalAutomationManager { + return { + id: 'hermes:local', + provider: 'hermes', + label: 'Hermes', + targetLabel: 'This computer', + target: { type: 'local' }, + status: 'available', + error: null, + canManage: true, + jobs: [ + { + id: 'job-1', + managerId: 'hermes:local', + provider: 'hermes', + name: 'Hermes job', + schedule: 'Daily at 09:00', + rawSchedule: '0 9 * * *', + enabled: true, + state: 'active', + prompt: null, + promptPreview: 'Sweep', + nextRunAt: null, + lastRunAt: null, + lastStatus: null, + lastError: null, + workdir: null, + runCount: 0, + runs: [] + } + ], + ...overrides + } +} + +/** The desktop-self scope every manager fixture is discovered under by default. */ +export function makeExternalAutomationScope( + overrides: Partial = {} +): ExternalAutomationScope { + return { + owner: { + authority: { kind: 'desktop' }, + selector: { kind: 'self' } + }, + provider: 'hermes', + ...overrides + } +} + +export function makeScopedExternalManager( + overrides: Partial = {}, + scope: ExternalAutomationScope = makeExternalAutomationScope() +): ScopedExternalAutomationManager { + return { scope, manager: makeExternalManager(overrides) } +} + +function makeRepo(): Repo { + return { + id: REPO_ID, + displayName: 'orca', + path: '/repos/orca', + badgeColor: '#000000', + addedAt: 1, + worktreeBaseRef: 'main' + } +} + +/** Ready and local, so the editor can build a run context and state a Self destination. */ +function makeProjectHostSetup(): ProjectHostSetup { + return { + id: 'setup-1', + projectId: 'project-1', + hostId: 'local', + repoId: REPO_ID, + path: '/repos/orca', + displayName: 'orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } +} + +function makeWorktree(): Worktree { + return { + id: WORKSPACE_ID, + repoId: REPO_ID, + displayName: 'main', + path: '/repos/orca', + branch: 'main' + } as Worktree +} + +export type AutomationsPageStoreFixtures = { + state: Record + repoMap: Map + worktreeMap: Map +} + +/** A store shaped like the page's reads; actions are spies so nothing escapes the test. */ +export function makeStoreState(): AutomationsPageStoreFixtures { + const repo = makeRepo() + const worktree = makeWorktree() + const noop = (): void => undefined + return { + state: { + repos: [repo], + projectHostSetups: [makeProjectHostSetup()], + worktreesByRepo: { [REPO_ID]: [worktree] }, + unifiedTabsByWorktree: {}, + terminalLayoutsByTabId: {}, + ptyIdsByTabId: {}, + activeWorktreeId: null, + fetchWorktrees: noop, + fetchAllWorktrees: noop, + fetchRuntimeEnvironmentRepos: async () => [], + startupWorktreeRefreshCompleted: true, + updateSettings: noop, + openSettingsPage: noop, + openSettingsTarget: noop, + closeAutomationsPage: noop, + sshConnectionStates: new Map(), + sshTargetLabels: new Map(), + removedSshTargetLabels: new Map(), + // Hydrated with no targets: the desktop authority has looked and has none, + // so the catalog projects Desktop + Self and nothing else. + sshTargetsHydrated: true, + sshStateByEnvironment: new Map(), + runtimeEnvironments: [], + runtimeEnvironmentCatalogSettled: true, + runtimeStatusByEnvironmentId: new Map(), + automationHostFilter: ALL_AUTOMATION_HOSTS_FILTER, + setAutomationHostFilter: noop, + settings: getDefaultSettings('/tmp'), + preflightStatus: null, + preflightStatusChecked: true, + preflightStatusContextKey: null, + refreshPreflightStatus: noop, + selectedAutomationId: null, + setSelectedAutomationId: noop, + pendingAutomationRunNavigation: null, + setPendingAutomationRunNavigation: noop, + hydratePersistedUI: noop, + recordFeatureInteraction: noop, + allWorktrees: () => [worktree], + getKnownWorktreeById: () => worktree + }, + repoMap: new Map([[REPO_ID, repo]]), + worktreeMap: new Map([[WORKSPACE_ID, worktree]]) + } +} diff --git a/src/renderer/src/components/automations/automations-page-runtime-fixtures.ts b/src/renderer/src/components/automations/automations-page-runtime-fixtures.ts new file mode 100644 index 00000000000..6b71a2364e2 --- /dev/null +++ b/src/renderer/src/components/automations/automations-page-runtime-fixtures.ts @@ -0,0 +1,52 @@ +import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' + +export const RUNTIME_REPO_ID = 'repo-2' +export const RUNTIME_WORKSPACE_ID = 'workspace-2' + +type RuntimeFixtureMocks = { + state: Record + repoMap: Map + worktreeMap: Map +} + +/** Adds a runtime-owned project whose id is ambiguous in a flat repo lookup. */ +export function addRuntimeProject(mocks: RuntimeFixtureMocks, runtimeId: string): void { + const repo = { + id: RUNTIME_REPO_ID, + displayName: 'gpu-orca', + path: '/repos/gpu-orca', + badgeColor: '#111111', + addedAt: 1, + worktreeBaseRef: 'main', + executionHostId: `runtime:${runtimeId}` + } as Repo + const worktree = { + id: RUNTIME_WORKSPACE_ID, + repoId: RUNTIME_REPO_ID, + displayName: 'main', + path: '/repos/gpu-orca', + branch: 'main' + } as Worktree + const setup: ProjectHostSetup = { + id: 'setup-2', + projectId: 'project-2', + hostId: `runtime:${runtimeId}`, + repoId: RUNTIME_REPO_ID, + path: '/repos/gpu-orca', + displayName: 'gpu-orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + const repos = mocks.state.repos as Repo[] + const setups = mocks.state.projectHostSetups as ProjectHostSetup[] + const worktreesByRepo = mocks.state.worktreesByRepo as Record + mocks.state.repos = [...repos, repo] + mocks.state.projectHostSetups = [...setups, setup] + mocks.state.worktreesByRepo = { ...worktreesByRepo, [RUNTIME_REPO_ID]: [worktree] } + mocks.repoMap.set(RUNTIME_REPO_ID, repo) + mocks.worktreeMap.set(RUNTIME_WORKSPACE_ID, worktree) +} diff --git a/src/renderer/src/components/automations/automations-page-test-harness.tsx b/src/renderer/src/components/automations/automations-page-test-harness.tsx new file mode 100644 index 00000000000..c7d3b8ac158 --- /dev/null +++ b/src/renderer/src/components/automations/automations-page-test-harness.tsx @@ -0,0 +1,473 @@ +/** + * The mount rig for AutomationsPage tests: child stand-ins, the preload API + * double, and the per-test store reset. + * + * The presentational children are stood in for because what these tests check + * is the data flow into them, not their markup — asserting on the real picker, + * badges, and empty states would test the wrong layer. The stand-ins record the + * props they were handed so a test can read them back. + * + * The `vi.mock` calls live here rather than in each test file: they must run + * before `./AutomationsPage` is imported, which importing this module first + * guarantees. + */ + +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, type Mock, vi } from 'vitest' +import type { Automation, AutomationRun } from '../../../../shared/automations-types' +import { + AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import type { AppState } from '@/store' +import type { AutomationHostCatalogView } from './use-automation-host-catalog' +import type { AutomationCreateDestinationControl } from './use-automation-create-destination' +import type { ExternalAutomationListEntry } from './external-automation-list-entries' +import type { AutomationListRow } from './automation-list-row-identity' +import { + addRuntimeProject as addRuntimeProjectFixture, + RUNTIME_REPO_ID as RUNTIME_REPO_ID_FIXTURE, + RUNTIME_WORKSPACE_ID as RUNTIME_WORKSPACE_ID_FIXTURE +} from './automations-page-runtime-fixtures' + +export const RUNTIME_REPO_ID = RUNTIME_REPO_ID_FIXTURE +export const RUNTIME_WORKSPACE_ID = RUNTIME_WORKSPACE_ID_FIXTURE + +export type ListPanelProps = { + filteredAutomations: Automation[] + filteredExternalAutomationEntries: ExternalAutomationListEntry[] + selectedExternal: ExternalAutomationListEntry | null + openEditExternalDialog: ( + manager: ExternalAutomationListEntry['manager'], + job: ExternalAutomationListEntry['job'], + scope: ExternalAutomationListEntry['scope'] + ) => void + externalActionKey: string | null + requestExternalAction: ( + manager: ExternalAutomationListEntry['manager'], + job: ExternalAutomationListEntry['job'], + action: 'run' | 'pause' | 'resume' | 'delete', + scope: ExternalAutomationListEntry['scope'] + ) => void + hasListItems: boolean + hasFilteredListItems: boolean + filteredRows: readonly AutomationListRow[] + selectedRowKey: string | null + selectedExternalKey: string | null + hostCatalog: AutomationHostCatalogView + searchCounts: { hostRowCount: number; visibleRowCount: number; searchActive: boolean } + externalManagersUncheckedNotice: string | null + isActionEnabled: (row: AutomationListRow, action: string) => boolean + onSelectHost: (filter: unknown) => void + selectAutomationRow: (rowKey: string | null) => void + selectExternalKey: (entryKey: string | null) => void + onOpenDetail: () => void + onRefresh: () => void + runNow: (row: AutomationListRow) => void + openEditDialog: (row: AutomationListRow) => void + toggleAutomation: (row: AutomationListRow) => void + requestDeleteAutomation: (row: AutomationListRow) => void + openCreateDialog: () => void +} + +export type DetailPaneProps = { + selected: Automation | null + selectedRuns: AutomationRun[] + selectedRunsNotice: { message: string } | null + runNow: (automation: Automation) => void + toggleAutomation: (automation: Automation) => void + requestDeleteAutomation: (automation: Automation) => void + openEditDialog: (automation: Automation) => void + fetchExternalAutomationRuns: (input: { + scope: ExternalAutomationListEntry['scope'] + manager: ExternalAutomationListEntry['manager'] + job: ExternalAutomationListEntry['job'] + page: number + pageSize: number + }) => Promise +} + +export type EditorDialogProps = { + open: boolean + isEditing: boolean + createDestination?: AutomationCreateDestinationControl + notice?: { message: string; recovery: string | null } | null + onNoticeRecover?: (action: string) => void + repos?: { id: string }[] + draft?: { projectId: string; workspaceId: string } + onSave: () => void + onDraftChange: (updater: (current: unknown) => unknown) => void +} + +export type DeleteDialogProps = { + deleteTarget: Automation | null + onConfirm: () => void +} + +type AutomationsPageMocks = { + state: Record + repoMap: Map + worktreeMap: Map + listPanel: ListPanelProps | null + detailPane: DetailPaneProps | null + editorDialog: EditorDialogProps | null + deleteDialog: DeleteDialogProps | null + callRuntimeRpc: Mock + getRuntimeEnvironmentStatus: Mock + setAutomationHostFilter: Mock + toastError: Mock + toastSuccess: Mock + toastMessage: Mock + setPendingRunNavigation: Mock +} + +// Not `vi.hoisted`: the mock factories below only close over this, and a hoisted +// binding cannot be exported. +export const mocks: AutomationsPageMocks = { + state: {}, + repoMap: new Map(), + worktreeMap: new Map(), + listPanel: null, + detailPane: null, + editorDialog: null, + deleteDialog: null, + callRuntimeRpc: vi.fn(), + getRuntimeEnvironmentStatus: vi.fn(), + setAutomationHostFilter: vi.fn(), + toastError: vi.fn(), + toastSuccess: vi.fn(), + toastMessage: vi.fn(), + setPendingRunNavigation: vi.fn() +} + +vi.mock('@/store', () => { + const useAppStore = (selector: (state: Partial) => unknown): unknown => + selector(mocks.state as Partial) + useAppStore.getState = (): Partial => mocks.state as Partial + return { useAppStore } +}) + +vi.mock('@/store/selectors', () => ({ + useRepoMap: () => mocks.repoMap, + useWorktreeMap: () => mocks.worktreeMap +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: (...args: unknown[]) => mocks.callRuntimeRpc(...args), + getRuntimeEnvironmentStatus: (...args: unknown[]) => mocks.getRuntimeEnvironmentStatus(...args), + // Matches the real matcher's contract: the code is a `: ` message tail. + hasRuntimeRpcErrorCode: (error: unknown, code: string) => + error instanceof Error && error.message.trimEnd().endsWith(`: ${code}`) +})) + +vi.mock('sonner', () => ({ + toast: { + error: (...args: unknown[]) => mocks.toastError(...args), + success: (...args: unknown[]) => mocks.toastSuccess(...args), + message: (...args: unknown[]) => mocks.toastMessage(...args) + } +})) + +vi.mock('@/lib/window-visibility-interval', () => ({ + installWindowVisibilityInterval: () => () => undefined +})) + +vi.mock('@/components/contextual-tours/use-contextual-tour', () => ({ + useContextualTour: () => undefined +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: () => null +})) + +vi.mock('@/runtime/runtime-hooks-client', () => ({ + checkRuntimeHooks: vi.fn(async () => ({ hooks: null, ok: true })) +})) + +vi.mock('@/lib/ensure-hooks-confirmed', () => ({ + ensureHooksConfirmed: vi.fn(async () => 'inherit') +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: vi.fn() +})) + +vi.mock('./AutomationsListPanel', () => ({ + AutomationsListPanel: (props: ListPanelProps) => { + const selectAutomationRow = (rowKey: string | null): void => { + props.selectAutomationRow(rowKey) + props.onOpenDetail() + } + mocks.listPanel = { ...props, selectAutomationRow } + return ( +
+ + ))} + {props.filteredExternalAutomationEntries.map((entry) => ( + + ))} + {props.hasListItems ? null :
} +
+ ) + } +})) + +vi.mock('./AutomationsDetailPane', () => ({ + AutomationsDetailPane: (props: DetailPaneProps) => { + mocks.detailPane = props + return ( +
+ {props.selected?.name ?? 'none'} + {props.selectedRuns.length} +
+ ) + } +})) + +vi.mock('./AutomationEditorDialog', () => ({ + AutomationEditorDialog: (props: EditorDialogProps) => { + mocks.editorDialog = props + return
{props.open ? 'open' : 'closed'}
+ } +})) + +vi.mock('./AutomationDeleteDialogs', () => ({ + AutomationDeleteDialog: (props: DeleteDialogProps) => { + mocks.deleteDialog = props + return null + }, + ExternalAutomationDeleteDialog: () => null +})) + +import AutomationsPage from './AutomationsPage' +import { makeAutomation, makeRun, makeStoreState } from './automations-page-fixtures' + +function mockGroup(...names: K[]): Record { + return Object.fromEntries(names.map((name) => [name, vi.fn()])) as Record +} + +export const api = { + automations: mockGroup( + 'list', + 'listRuns', + 'create', + 'update', + 'delete', + 'runNow', + 'listScoped', + 'listExternalManagerForOwner', + 'listExternalRunsForOwner', + 'createExternalForOwner', + 'updateExternalForOwner', + 'runExternalActionForOwner', + 'retainExternalScopes' + ), + ssh: mockGroup('listTargets', 'connect'), + runtimeEnvironments: mockGroup('connect'), + ui: mockGroup('get') +} + +/** The precondition a Desktop + Self row captures; every fenced request repeats it. */ +export const SELF_PRECONDITION = { selector: { kind: 'self' } } +/** The owner the catalog projects for Desktop + Self, which every scoped call names. */ +export const DESKTOP_SELF_OWNER = { authority: { kind: 'desktop' }, selector: { kind: 'self' } } + +export const RUNTIME_ID = 'gpu' +/** A runtime that advertises both automation capabilities, so its rows carry owners. */ +const RUNTIME_CAPABILITIES = [ + AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY +] +export const RUNTIME_SELF_FILTER = { + kind: 'host' as const, + host: { + authority: { kind: 'runtime' as const, environmentId: RUNTIME_ID }, + selector: { kind: 'self' as const } + } +} + +/** The scoped-list shape every self-owned host answers with. */ +function selfScopedList(automations: Automation[]): Record { + return { + automations, + items: automations.map((automation) => ({ + automationId: automation.id, + selector: { kind: 'self' } + })), + orphanCount: 0 + } +} + +/** The local authority's automation RPC surface, served from the same programmable + * `api.automations` mocks; an environment target answers from `runtimeHost()` state. */ +async function answerAutomationRpc( + target: unknown, + method: string, + params?: unknown +): Promise { + if ((target as { kind?: string } | null)?.kind === 'environment') { + const answers = mocks.state.runtimeAnswers as + | { automations: Automation[]; runs: AutomationRun[] } + | undefined + if (method === 'automation.list') { + return selfScopedList(answers?.automations ?? []) + } + return method === 'automation.runs' ? { runs: answers?.runs ?? [] } : {} + } + const args = params as Record | undefined + switch (method) { + case 'automation.list': + return args?.selector + ? await api.automations.listScoped({ selector: args.selector }) + : { automations: await api.automations.list() } + case 'automation.runs': + return { runs: await api.automations.listRuns(args) } + case 'automation.create': + return { automation: await api.automations.create(args) } + case 'automation.update': + return { automation: await api.automations.update(args) } + case 'automation.delete': + return await api.automations.delete(args).then(() => ({})) + case 'automation.runNow': + return { run: await api.automations.runNow(args) } + default: + return {} + } +} + +/** Puts a reachable, capable second authority in the catalog and answers for it. */ +export function runtimeHost(automations: Automation[], runs: AutomationRun[]): void { + mocks.state.runtimeEnvironments = [ + { id: RUNTIME_ID, name: 'GPU box', createdAt: 1, pairingRevision: 4 } + ] + mocks.state.runtimeStatusByEnvironmentId = new Map([ + [RUNTIME_ID, { status: { capabilities: RUNTIME_CAPABILITIES } }] + ]) + mocks.getRuntimeEnvironmentStatus.mockResolvedValue({ capabilities: RUNTIME_CAPABILITIES }) + mocks.state.runtimeAnswers = { automations, runs } +} + +export const addRuntimeProject = (): void => addRuntimeProjectFixture(mocks, RUNTIME_ID) + +/** Answers the host-scoped read, which is where the list's rows and owners come from. */ +export function scopedList(automations: Automation[]): void { + api.automations.listScoped.mockResolvedValue(selfScopedList(automations)) +} + +const roots: Root[] = [] + +export async function renderPage(): Promise<{ + container: HTMLDivElement + rerender: () => Promise +}> { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + const rerender = async (): Promise => { + await act(async () => { + root.render() + }) + } + await rerender() + return { container, rerender } +} + +/** Lets the per-host queries, their capability probes, and the history read settle. */ +export async function settleHostQueries(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) +} + +/** The alt-tab-back trigger: one of the two events bound to the page refresh. */ +export async function refreshOnFocus(): Promise { + await act(async () => { + window.dispatchEvent(new Event('focus')) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) +} + +/** + * The row the page actually listed for an ID, so tests act through the same + * authority-qualified key the user's click carries rather than a synthesized one. + */ +export function listedRow(automationId: string): AutomationListRow { + const row = mocks.listPanel?.filteredRows.find((entry) => entry.automation.id === automationId) + if (!row) { + throw new Error(`no listed row for ${automationId}`) + } + return row +} + +export function rows(container: HTMLElement, testId: string): string[] { + return [...container.querySelectorAll(`[data-testid="${testId}"]`)].map( + (node) => node.textContent ?? '' + ) +} + +/** Registers the reset both page test files depend on; call once per file. */ +export function installAutomationsPageHarness(): void { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + vi.clearAllMocks() + // A prior test's wholesale mockImplementation must not leak forward. + mocks.callRuntimeRpc.mockReset() + mocks.callRuntimeRpc.mockImplementation(answerAutomationRpc) + mocks.listPanel = null + mocks.detailPane = null + mocks.editorDialog = null + mocks.deleteDialog = null + const fixtures = makeStoreState() + mocks.state = fixtures.state + mocks.state.setAutomationHostFilter = mocks.setAutomationHostFilter + mocks.repoMap = fixtures.repoMap + mocks.worktreeMap = fixtures.worktreeMap + api.automations.list.mockResolvedValue([makeAutomation()]) + api.automations.listRuns.mockResolvedValue([]) + api.automations.listExternalManagerForOwner.mockResolvedValue({ + manager: null, + error: null, + updatedAt: 0 + }) + api.automations.retainExternalScopes.mockResolvedValue(undefined) + api.automations.listScoped.mockResolvedValue(selfScopedList([makeAutomation()])) + api.automations.create.mockResolvedValue(makeAutomation()) + api.automations.update.mockResolvedValue(makeAutomation()) + api.automations.delete.mockResolvedValue(undefined) + api.automations.runNow.mockResolvedValue(makeRun()) + api.ui.get.mockResolvedValue({}) + // @ts-expect-error test window mock + globalThis.window.api = api + }) + + afterEach(async () => { + await act(async () => { + roots.splice(0).forEach((root) => root.unmount()) + }) + document.body.innerHTML = '' + }) +} diff --git a/src/renderer/src/components/automations/automations-table-layout.ts b/src/renderer/src/components/automations/automations-table-layout.ts index 0b0db4d5229..718a090e85b 100644 --- a/src/renderer/src/components/automations/automations-table-layout.ts +++ b/src/renderer/src/components/automations/automations-table-layout.ts @@ -1,4 +1,3 @@ -/** Column template for the automations list table; shared chrome lives in @/lib/list-table-layout. */ -// Name | Schedule | Project | Next run | Last run | Status | Agent | Actions +// Name | Schedule | Project | Host | Next run | Last run | Status | Agent | Actions export const AUTOMATIONS_TABLE_GRID_CLASS = - 'grid grid-cols-[minmax(0,1.4fr)_minmax(7.5rem,10rem)_minmax(4.5rem,8rem)_minmax(8.5rem,1fr)_minmax(8rem,11rem)_minmax(4.5rem,6rem)_2.5rem_2.5rem]' + 'grid grid-cols-[minmax(0,1.4fr)_minmax(6.5rem,9.5rem)_minmax(4.5rem,8rem)_minmax(4.5rem,7.5rem)_minmax(8rem,1fr)_minmax(7.5rem,10rem)_minmax(4.5rem,5.5rem)_2.5rem_2.5rem]' diff --git a/src/renderer/src/components/automations/external-automation-display.test.ts b/src/renderer/src/components/automations/external-automation-display.test.ts new file mode 100644 index 00000000000..7614352e377 --- /dev/null +++ b/src/renderer/src/components/automations/external-automation-display.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { isMissingExternalRunsApiError } from './external-automation-display' +import { EXTERNAL_AUTOMATION_SCOPE_CODES } from '../../../../shared/external-automation-scope' + +describe('isMissingExternalRunsApiError', () => { + it('recognizes the relay code carried in the message', () => { + // Electron IPC keeps only the message, so the code rides inside it. + expect( + isMissingExternalRunsApiError( + new Error( + `Error invoking remote method: This host does not report external automation run history.: ${EXTERNAL_AUTOMATION_SCOPE_CODES.runsUnsupported}` + ) + ) + ).toBe(true) + }) + + it('still recognizes hosts that answer with no code at all', () => { + expect( + isMissingExternalRunsApiError(new Error("No handler registered for 'automations:listRuns'")) + ).toBe(true) + expect(isMissingExternalRunsApiError(new Error('listExternalRuns is not a function'))).toBe( + true + ) + }) + + it('does not treat an ordinary relay failure as a missing endpoint', () => { + // Falling back here would render a truncated run list as if it were complete. + expect(isMissingExternalRunsApiError(new Error('SSH target "ssh-1" is not connected.'))).toBe( + false + ) + expect(isMissingExternalRunsApiError(new Error('socket hang up'))).toBe(false) + }) +}) diff --git a/src/renderer/src/components/automations/external-automation-display.ts b/src/renderer/src/components/automations/external-automation-display.ts index beb7fc20082..388598269ea 100644 --- a/src/renderer/src/components/automations/external-automation-display.ts +++ b/src/renderer/src/components/automations/external-automation-display.ts @@ -1,19 +1,12 @@ import React from 'react' import type { Badge } from '@/components/ui/badge' import type { - ExternalAutomationJob, ExternalAutomationManager, ExternalAutomationRun } from '../../../../shared/automations-types' +import { EXTERNAL_AUTOMATION_SCOPE_CODES } from '../../../../shared/external-automation-scope' import { formatAutomationDateTimeWithRelative } from './automation-page-parts' -export function getExternalAutomationKey( - manager: ExternalAutomationManager, - job: ExternalAutomationJob -): string { - return `${manager.id}:${job.id}` -} - export function formatExternalDate(value: string | null, now: number): string { if (!value) { return 'Never' @@ -63,5 +56,11 @@ export function getExternalRunContent(run: ExternalAutomationRun): string { export function isMissingExternalRunsApiError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error) - return /listExternalRuns|automations:listExternalRuns|No handler registered/i.test(message) + // The relay path now reports a code, which is exact. The patterns stay for the + // hosts that answer with no code at all: an unrouted IPC channel, and relays + // predating the code. Neither can be tightened without losing those hosts. + return ( + message.includes(EXTERNAL_AUTOMATION_SCOPE_CODES.runsUnsupported) || + /listExternalRuns|automations:listExternalRuns|No handler registered/i.test(message) + ) } diff --git a/src/renderer/src/components/automations/external-automation-list-entries.test.ts b/src/renderer/src/components/automations/external-automation-list-entries.test.ts index 8db60dd1c05..d2609ebb834 100644 --- a/src/renderer/src/components/automations/external-automation-list-entries.test.ts +++ b/src/renderer/src/components/automations/external-automation-list-entries.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { ExternalAutomationManager } from '../../../../shared/automations-types' import { buildExternalAutomationListEntries } from './external-automation-list-entries' +import type { ScopedExternalAutomationManager } from './external-automation-scope-client' function manager(overrides: Partial = {}): ExternalAutomationManager { return { @@ -17,42 +18,82 @@ function manager(overrides: Partial = {}): ExternalAu } } +function job(id = 'job-1'): ExternalAutomationManager['jobs'][number] { + return { + id, + managerId: 'hermes:ssh:openclaw', + provider: 'hermes', + name: 'Nightly', + schedule: '0 9 * * *', + rawSchedule: '0 9 * * *', + enabled: true, + state: 'active', + prompt: 'do work', + promptPreview: 'do work', + nextRunAt: null, + lastRunAt: null, + lastStatus: null, + lastError: null, + workdir: null, + runCount: 1, + runs: [] + } +} + +function scoped( + environmentId: string | null, + overrides: Partial = {} +): ScopedExternalAutomationManager { + return { + scope: { + owner: { + authority: + environmentId === null + ? { kind: 'desktop' } + : { kind: 'runtime', environmentId, pairingRevision: 1 }, + selector: { kind: 'self' } + }, + provider: 'hermes' + }, + manager: manager(overrides) + } +} + describe('buildExternalAutomationListEntries', () => { it('omits empty host probes', () => { - expect(buildExternalAutomationListEntries([manager()])).toEqual([]) + expect(buildExternalAutomationListEntries([scoped(null)])).toEqual([]) }) it('lists jobs from an unavailable manager', () => { - expect( - buildExternalAutomationListEntries([ - manager({ - jobs: [ - { - id: 'job-1', - managerId: 'hermes:ssh:openclaw', - provider: 'hermes', - name: 'Nightly', - schedule: '0 9 * * *', - rawSchedule: '0 9 * * *', - enabled: true, - state: 'active', - prompt: 'do work', - promptPreview: 'do work', - nextRunAt: null, - lastRunAt: null, - lastStatus: null, - lastError: null, - workdir: null, - runCount: 1, - runs: [] - } - ] - }) - ]) - ).toEqual([ + expect(buildExternalAutomationListEntries([scoped(null, { jobs: [job()] })])).toEqual([ expect.objectContaining({ job: expect.objectContaining({ id: 'job-1' }) }) ]) }) + + it('separates identical manager and job IDs reported by two authorities', () => { + // `hermes:local` names no authority, so both hosts hand back the same manager + // ID. An unqualified key would make the two rows one, and the second would + // shadow the first in selection, dialogs, and React's reconciliation. + const entries = buildExternalAutomationListEntries([ + scoped(null, { id: 'hermes:local', target: { type: 'local' }, jobs: [job()] }), + scoped('env-7', { id: 'hermes:local', target: { type: 'local' }, jobs: [job()] }) + ]) + + expect(entries).toHaveLength(2) + expect(entries[0]?.key).not.toBe(entries[1]?.key) + }) + + it('carries the scope it keyed each entry with', () => { + const [entry] = buildExternalAutomationListEntries([scoped('env-7', { jobs: [job()] })]) + + // The entry is what the edit dialog captures; re-deriving the scope from the + // manager ID at save time is the lookup this field exists to avoid. + expect(entry?.scope.owner.authority).toEqual({ + kind: 'runtime', + environmentId: 'env-7', + pairingRevision: 1 + }) + }) }) diff --git a/src/renderer/src/components/automations/external-automation-list-entries.ts b/src/renderer/src/components/automations/external-automation-list-entries.ts index 5e89684132a..cd27acea580 100644 --- a/src/renderer/src/components/automations/external-automation-list-entries.ts +++ b/src/renderer/src/components/automations/external-automation-list-entries.ts @@ -2,21 +2,29 @@ import type { ExternalAutomationJob, ExternalAutomationManager } from '../../../../shared/automations-types' -import { getExternalAutomationKey } from './external-automation-display' +import type { + ExternalAutomationScope, + ScopedExternalAutomationManager +} from './external-automation-scope-client' +import { externalAutomationJobKey } from './external-automation-scope-keys' export type ExternalAutomationListEntry = { key: string + /** The scope the manager was listed under; carried so the key never has to be re-derived. */ + scope: ExternalAutomationScope manager: ExternalAutomationManager job: ExternalAutomationJob } export function buildExternalAutomationListEntries( - managers: readonly ExternalAutomationManager[] + scopedManagers: readonly ScopedExternalAutomationManager[] ): ExternalAutomationListEntry[] { // Why: empty managers are host probes, not automations — omit them from the list. - return managers.flatMap((manager) => + return scopedManagers.flatMap(({ scope, manager }) => manager.jobs.map((job) => ({ - key: getExternalAutomationKey(manager, job), + // A scope holds at most one manager, so {scope, job} names the row exactly. + key: externalAutomationJobKey(scope, job.id), + scope, manager, job })) diff --git a/src/renderer/src/components/automations/external-automation-scope-client.ts b/src/renderer/src/components/automations/external-automation-scope-client.ts new file mode 100644 index 00000000000..7b50d45636c --- /dev/null +++ b/src/renderer/src/components/automations/external-automation-scope-client.ts @@ -0,0 +1,181 @@ +/** + * The renderer's scoped external-manager surface. + * + * Every call names the owner the host was captured under, so a manager ID — + * which is only unique inside one host — can never route a mutation to a + * different machine. The owner is recorded when a manager is discovered and + * replayed on every later action, rather than re-derived from the manager's own + * target at action time. + */ + +import type { + ExternalAutomationAction, + ExternalAutomationJob, + ExternalAutomationManager, + ExternalAutomationProvider, + ExternalAutomationRun +} from '../../../../shared/automations-types' +import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref' +import { + EXTERNAL_AUTOMATION_PROVIDERS, + type ScopedExternalManagerMutationFields +} from '../../../../shared/external-automation-scope' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import { resolveExternalAutomationScopeGate } from './external-automation-scope-gating' + +export type ExternalAutomationScope = { + owner: AutomationOwnerRef + provider: ExternalAutomationProvider +} + +/** + * A discovered manager still carrying the scope it was listed under. + * + * Why paired rather than looked up: a manager ID is `hermes:local` on every host + * that has no SSH target, so it does not name an authority. Recovering the scope + * from the ID would collide for exactly the hosts a scope is meant to separate. + */ +export type ScopedExternalAutomationManager = { + scope: ExternalAutomationScope + manager: ExternalAutomationManager +} + +/** A scope that answered with neither a manager nor a denial: it was not checked. */ +export type ScopedExternalAutomationFailure = { + scope: ExternalAutomationScope + message: string +} + +export type ScopedExternalAutomationManagers = { + managers: readonly ScopedExternalAutomationManager[] + /** Carries the owner, not just a key, so a failed host can be named to the user. */ + failures: readonly ScopedExternalAutomationFailure[] +} + +const EMPTY_MANAGERS: ScopedExternalAutomationManagers = { + managers: [], + failures: [] +} + +/** Every `{host, provider}` pair the gate says may actually be probed. */ +export function externalAutomationScopes( + entries: readonly AutomationHostCatalogEntry[] +): ExternalAutomationScope[] { + const scopes: ExternalAutomationScope[] = [] + for (const entry of entries) { + const owner = resolveExternalAutomationScopeGate(entry).probeOwner + if (!owner) { + continue + } + for (const provider of EXTERNAL_AUTOMATION_PROVIDERS) { + scopes.push({ owner, provider }) + } + } + return scopes +} + +/** Takes scopes, not the catalog: the caller decides which hosts are in view. */ +export async function listScopedExternalAutomationManagers( + scopes: readonly ExternalAutomationScope[] +): Promise { + if (scopes.length === 0) { + return EMPTY_MANAGERS + } + const managers: ScopedExternalAutomationManager[] = [] + const failures: ScopedExternalAutomationFailure[] = [] + const results = await Promise.all( + scopes.map(async (scope) => { + try { + return { scope, result: await window.api.automations.listExternalManagerForOwner(scope) } + } catch (error) { + // One provider's failure is a fact about that provider, never about the host. + return { + scope, + result: { + manager: null, + error: error instanceof Error ? error.message : String(error), + updatedAt: 0 + } + } + } + }) + ) + for (const { scope, result } of results) { + if (result.error) { + failures.push({ scope, message: result.error }) + } + if (!result.manager) { + continue + } + managers.push({ scope, manager: result.manager }) + } + return { managers, failures } +} + +/** + * The desktop host that owns a repo's external managers. Null when no owner + * could be captured for it, which disables creation rather than defaulting one. + */ +export function desktopExternalAutomationOwner( + entries: readonly AutomationHostCatalogEntry[], + connectionId: string | null +): AutomationOwnerRef | null { + for (const entry of entries) { + if (entry.stableRef.authority.kind !== 'desktop' || !entry.owner) { + continue + } + const selector = entry.stableRef.selector + const matches = + connectionId === null + ? selector.kind === 'self' + : selector.kind === 'ssh' && selector.targetId === connectionId + if (matches) { + return entry.owner + } + } + return null +} + +export async function createScopedExternalAutomation( + scope: ExternalAutomationScope, + fields: ScopedExternalManagerMutationFields +): Promise { + await window.api.automations.createExternalForOwner({ ...scope, ...fields }) +} + +export async function updateScopedExternalAutomation( + scope: ExternalAutomationScope, + jobId: string, + fields: ScopedExternalManagerMutationFields +): Promise { + await window.api.automations.updateExternalForOwner({ ...scope, ...fields, jobId }) +} + +export async function runScopedExternalAutomationAction( + scope: ExternalAutomationScope, + jobId: string, + action: ExternalAutomationAction +): Promise { + await window.api.automations.runExternalActionForOwner({ ...scope, jobId, action }) +} + +export type ScopedExternalAutomationRunsPage = { + runs: readonly ExternalAutomationRun[] + totalCount: number +} + +export async function listScopedExternalAutomationRuns( + scope: ExternalAutomationScope, + job: ExternalAutomationJob, + page: number, + pageSize: number +): Promise { + const result = await window.api.automations.listExternalRunsForOwner({ + ...scope, + jobId: job.id, + // The engine pages from one; the table's page index is zero-based. + page: page + 1, + pageSize + }) + return { runs: result.runs, totalCount: result.total } +} diff --git a/src/renderer/src/components/automations/external-automation-scope-gating.test.ts b/src/renderer/src/components/automations/external-automation-scope-gating.test.ts new file mode 100644 index 00000000000..c147895b47e --- /dev/null +++ b/src/renderer/src/components/automations/external-automation-scope-gating.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import { + EXTERNAL_AUTOMATION_SCOPE_CODES, + ExternalAutomationScopeError +} from '../../../../shared/external-automation-scope' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import { + externalAutomationProbeOwners, + externalAutomationScopeGateFromError, + parseExternalAutomationScopeCode, + resolveExternalAutomationScopeGate +} from './external-automation-scope-gating' + +function entry(overrides: Partial = {}): AutomationHostCatalogEntry { + return { + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'self' } }, + owner: { authority: { kind: 'desktop' }, selector: { kind: 'self' } }, + stableKey: 'host:desktop:self', + label: 'This computer', + authorityLabel: 'This computer', + kind: 'self', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'connected', + querySupport: 'scoped', + ...overrides + } +} + +const runtimeEntry = entry({ + stableRef: { authority: { kind: 'runtime', environmentId: 'env-1' }, selector: { kind: 'self' } }, + owner: { + authority: { kind: 'runtime', environmentId: 'env-1', pairingRevision: 1 }, + selector: { kind: 'self' } + }, + stableKey: 'host:runtime:env-1:self', + label: 'Build box', + authorityLabel: 'Build box' +}) + +describe('external automation scope gating', () => { + it('lists managers only for a desktop host with a captured owner', () => { + const gate = resolveExternalAutomationScopeGate(entry()) + expect(gate.status).toBe('listed') + expect(gate.probeOwner).toEqual({ + authority: { kind: 'desktop' }, + selector: { kind: 'self' } + }) + }) + + it('never presents a runtime-owned host as clean', () => { + const gate = resolveExternalAutomationScopeGate(runtimeEntry) + expect(gate.status).toBe('not-listed') + expect(gate.code).toBe(EXTERNAL_AUTOMATION_SCOPE_CODES.authorityNotSupported) + expect(gate.probeOwner).toBeNull() + }) + + it('fails closed when no owner was captured, so nothing is probed', () => { + const gate = resolveExternalAutomationScopeGate(entry({ owner: null })) + expect(gate.status).toBe('not-listed') + expect(gate.probeOwner).toBeNull() + }) + + it('treats the orphan bucket as scope limited rather than empty', () => { + const orphan = entry({ + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'orphan' } }, + kind: 'orphan', + owner: null, + stableKey: 'host:desktop:orphan' + }) + expect(resolveExternalAutomationScopeGate(orphan).status).toBe('not-listed') + }) + + it('makes no claim for an unresolved host', () => { + const gate = resolveExternalAutomationScopeGate(null) + expect(gate.status).toBe('unknown') + expect(gate.probeOwner).toBeNull() + // Why: 'unknown' is not a limitation, so it must not print the scope note. + }) + + it('retains probe owners only for hosts that are actually listed', () => { + expect(externalAutomationProbeOwners([entry(), runtimeEntry, entry({ owner: null })])).toEqual([ + { authority: { kind: 'desktop' }, selector: { kind: 'self' } } + ]) + }) +}) + +describe('engine scope errors', () => { + it('reads the code the engine appends to its message', () => { + const hidden = new ExternalAutomationScopeError(EXTERNAL_AUTOMATION_SCOPE_CODES.targetHidden) + expect(parseExternalAutomationScopeCode(hidden)).toBe( + EXTERNAL_AUTOMATION_SCOPE_CODES.targetHidden + ) + // Electron rewraps the message; the trailing token still terminates it. + expect( + parseExternalAutomationScopeCode( + new Error(`Error invoking remote method 'x': ${hidden.message}`) + ) + ).toBe(EXTERNAL_AUTOMATION_SCOPE_CODES.targetHidden) + }) + + it('turns a hidden-target rejection into its own copy, not the generic note', () => { + const gate = externalAutomationScopeGateFromError( + new ExternalAutomationScopeError(EXTERNAL_AUTOMATION_SCOPE_CODES.targetHidden) + ) + expect(gate?.status).toBe('not-listed') + }) + + it('leaves unrelated failures alone so they are not relabelled as scope limits', () => { + expect(externalAutomationScopeGateFromError(new Error('socket hang up'))).toBeNull() + expect(parseExternalAutomationScopeCode(null)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/automations/external-automation-scope-gating.ts b/src/renderer/src/components/automations/external-automation-scope-gating.ts new file mode 100644 index 00000000000..986172de10b --- /dev/null +++ b/src/renderer/src/components/automations/external-automation-scope-gating.ts @@ -0,0 +1,107 @@ +/** + * Whether a host's external automation managers are actually listed. + * + * External managers are a desktop-authority surface this release, so most hosts + * are *scope limited* rather than empty. The distinction is load-bearing: a + * runtime-owned host with no rows has not been probed, and presenting it as + * clean would tell the user something we did not check. + */ + +import { + EXTERNAL_AUTOMATION_SCOPE_CODES, + type ExternalAutomationScopeCode +} from '../../../../shared/external-automation-scope' +import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import type { AutomationHostFilterResolution } from './automation-host-filter-resolution' + +export type ExternalAutomationScopeStatus = 'listed' | 'not-listed' | 'unknown' + +export type ExternalAutomationScopeGate = { + status: ExternalAutomationScopeStatus + /** Non-null only when `listed`; every other status must not probe. */ + probeOwner: AutomationOwnerRef | null + /** Engine code when one explains the limit; null when no owner was captured. */ + code: ExternalAutomationScopeCode | null +} + +const UNKNOWN_GATE: ExternalAutomationScopeGate = { + status: 'unknown', + probeOwner: null, + code: null +} + +function notListed(code: ExternalAutomationScopeCode | null): ExternalAutomationScopeGate { + return { status: 'not-listed', probeOwner: null, code } +} + +/** + * Resolves one host. A null entry is the unresolved/All-hosts case and makes no + * claim either way; compose per-host gates for All hosts instead. + */ +export function resolveExternalAutomationScopeGate( + entry: AutomationHostCatalogEntry | null +): ExternalAutomationScopeGate { + if (!entry) { + return UNKNOWN_GATE + } + if (entry.stableRef.authority.kind !== 'desktop') { + return notListed(EXTERNAL_AUTOMATION_SCOPE_CODES.authorityNotSupported) + } + if (entry.kind === 'orphan') { + return notListed(null) + } + // Fail closed: an uncaptured owner cannot be probed, so nothing is listed for it. + return entry.owner ? { status: 'listed', probeOwner: entry.owner, code: null } : notListed(null) +} + +/** + * The hosts whose external managers are in view. Probing and retention both key + * off this: a host the user filtered away is not a host we may go ask about. + */ +export function externalAutomationScopeEntries( + entries: readonly AutomationHostCatalogEntry[], + resolution: AutomationHostFilterResolution +): readonly AutomationHostCatalogEntry[] { + if (resolution.effective.kind === 'all') { + return entries + } + return resolution.entry ? [resolution.entry] : [] +} + +/** Owners the probe pool may keep working on; everything else is cancelled. */ +export function externalAutomationProbeOwners( + entries: readonly AutomationHostCatalogEntry[] +): AutomationOwnerRef[] { + const owners: AutomationOwnerRef[] = [] + for (const entry of entries) { + const gate = resolveExternalAutomationScopeGate(entry) + if (gate.probeOwner) { + owners.push(gate.probeOwner) + } + } + return owners +} + +/** + * Maps a rejected scoped call onto a gate. The engine carries its code as a + * trailing `: ` token because IPC preserves nothing but the message. + */ +export function externalAutomationScopeGateFromError( + error: unknown +): ExternalAutomationScopeGate | null { + const code = parseExternalAutomationScopeCode(error) + return code ? notListed(code) : null +} + +export function parseExternalAutomationScopeCode( + error: unknown +): ExternalAutomationScopeCode | null { + const message = (error instanceof Error ? error.message : String(error ?? '')).trimEnd() + for (const code of Object.values(EXTERNAL_AUTOMATION_SCOPE_CODES)) { + if (message.endsWith(`: ${code}`)) { + return code + } + } + return null +} diff --git a/src/renderer/src/components/automations/external-automation-scope-keys.test.ts b/src/renderer/src/components/automations/external-automation-scope-keys.test.ts new file mode 100644 index 00000000000..7209af24a1a --- /dev/null +++ b/src/renderer/src/components/automations/external-automation-scope-keys.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref' +import { + externalAutomationActionKey, + externalAutomationDialogKey, + externalAutomationJobKey, + externalAutomationManagerKey, + externalAutomationRunKey, + externalAutomationScopeKey, + type ExternalAutomationScopeRef +} from './external-automation-scope-keys' + +const localOwner: AutomationOwnerRef = { + authority: { kind: 'desktop' }, + selector: { kind: 'self' } +} +const sshOwner = (targetId = 't1', targetGeneration = 3): AutomationOwnerRef => ({ + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId, targetGeneration } +}) + +const local: ExternalAutomationScopeRef = { owner: localOwner, provider: 'hermes' } +const remote: ExternalAutomationScopeRef = { owner: sshOwner(), provider: 'hermes' } + +describe('external automation scope keys', () => { + it('separates the same provider ID across hosts', () => { + expect(externalAutomationJobKey(local, 'job-1')).not.toBe( + externalAutomationJobKey(remote, 'job-1') + ) + expect(externalAutomationManagerKey(local, 'hermes:local')).not.toBe( + externalAutomationManagerKey(remote, 'hermes:local') + ) + }) + + it('separates providers on one host', () => { + expect(externalAutomationJobKey(local, 'job-1')).not.toBe( + externalAutomationJobKey({ ...local, provider: 'openclaw' }, 'job-1') + ) + }) + + it('separates host incarnations, so a re-added target does not adopt old keys', () => { + expect(externalAutomationScopeKey(remote)).not.toBe( + externalAutomationScopeKey({ owner: sshOwner('t1', 4), provider: 'hermes' }) + ) + }) + + it('keeps key kinds in separate namespaces', () => { + const kinds = new Set([ + externalAutomationScopeKey(local), + externalAutomationManagerKey(local, 'x'), + externalAutomationJobKey(local, 'x'), + externalAutomationRunKey(local, 'x', 'x'), + externalAutomationActionKey(local, 'x', 'run'), + externalAutomationDialogKey(local, 'x') + ]) + expect(kinds.size).toBe(6) + }) + + it('distinguishes a create dialog from a job literally named new', () => { + expect(externalAutomationDialogKey(local, null)).not.toBe( + externalAutomationDialogKey(local, 'new') + ) + }) + + it('escapes components so a hostile ID cannot forge another key', () => { + const forged = externalAutomationJobKey(local, 'job|extra') + expect(forged).not.toBe(externalAutomationRunKey(local, 'job', 'extra')) + expect(forged.split('|')).toHaveLength(4) + }) + + it('distinguishes actions on the same job', () => { + expect(externalAutomationActionKey(local, 'job-1', 'run')).not.toBe( + externalAutomationActionKey(local, 'job-1', 'delete') + ) + }) +}) diff --git a/src/renderer/src/components/automations/external-automation-scope-keys.ts b/src/renderer/src/components/automations/external-automation-scope-keys.ts new file mode 100644 index 00000000000..64362e803ef --- /dev/null +++ b/src/renderer/src/components/automations/external-automation-scope-keys.ts @@ -0,0 +1,70 @@ +/** + * Keys for external manager rows, jobs, runs, dialogs, and in-flight actions. + * + * A provider's manager ID is unique only inside one host, so a key built from it + * alone collides the moment two hosts run the same provider — the selected row, + * the open dialog, and the disabled action button would all follow the wrong + * host. Every key is therefore prefixed with its kind, the captured owner, and + * the provider. + */ + +import { ownerKey } from '../../../../shared/automation-owner-key' +import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref' +import type { + ExternalAutomationAction, + ExternalAutomationProvider +} from '../../../../shared/automations-types' + +const SEPARATOR = '|' + +export type ExternalAutomationScopeRef = { + owner: AutomationOwnerRef + provider: ExternalAutomationProvider +} + +// Why: components are escaped so a provider-supplied ID cannot forge another key. +function key(kind: string, scope: ExternalAutomationScopeRef, ...rest: string[]): string { + return [kind, ownerKey(scope.owner), scope.provider, ...rest] + .map((part) => encodeURIComponent(part)) + .join(SEPARATOR) +} + +/** Cache, retention, and in-flight identity for one `{owner, provider}`. */ +export function externalAutomationScopeKey(scope: ExternalAutomationScopeRef): string { + return key('scope', scope) +} + +export function externalAutomationManagerKey( + scope: ExternalAutomationScopeRef, + managerId: string +): string { + return key('manager', scope, managerId) +} + +export function externalAutomationJobKey(scope: ExternalAutomationScopeRef, jobId: string): string { + return key('job', scope, jobId) +} + +export function externalAutomationRunKey( + scope: ExternalAutomationScopeRef, + jobId: string, + runId: string +): string { + return key('run', scope, jobId, runId) +} + +export function externalAutomationActionKey( + scope: ExternalAutomationScopeRef, + jobId: string, + action: ExternalAutomationAction +): string { + return key('action', scope, jobId, action) +} + +/** A create dialog has no job yet, and 'new' is a segment of its own so a job literally named "new" cannot alias it. */ +export function externalAutomationDialogKey( + scope: ExternalAutomationScopeRef, + jobId: string | null +): string { + return jobId === null ? key('dialog', scope, 'new') : key('dialog', scope, 'job', jobId) +} diff --git a/src/renderer/src/components/automations/external-automation-unchecked-hosts.ts b/src/renderer/src/components/automations/external-automation-unchecked-hosts.ts new file mode 100644 index 00000000000..2a2b94783ed --- /dev/null +++ b/src/renderer/src/components/automations/external-automation-unchecked-hosts.ts @@ -0,0 +1,62 @@ +/** + * Hosts whose external managers a probe never answered for. + * + * A probe that failed is not a host with nothing on it. Without this line the + * list would render an unreachable host exactly like a clean one, which is the + * one claim the page has no evidence for. + */ + +import { translate } from '@/i18n/i18n' +import { ownerKey } from '../../../../shared/automation-owner-key' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import type { ScopedExternalAutomationFailure } from './external-automation-scope-client' + +export function externalAutomationUncheckedHostLabels( + failures: readonly ScopedExternalAutomationFailure[], + entries: readonly AutomationHostCatalogEntry[] +): string[] { + const labelByOwnerKey = new Map() + for (const entry of entries) { + if (entry.owner) { + labelByOwnerKey.set(ownerKey(entry.owner), entry.label) + } + } + const labels: string[] = [] + const seen = new Set() + // Per host, not per provider: two failed providers are still one unchecked host. + for (const failure of failures) { + const key = ownerKey(failure.scope.owner) + if (seen.has(key)) { + continue + } + seen.add(key) + labels.push( + labelByOwnerKey.get(key) ?? + translate('auto.components.automations.emptyState.unknownHost', 'this host') + ) + } + return labels +} + +/** Distinct from the empty state on purpose: unchecked is not the same as clean. */ +export function externalAutomationUncheckedNotice( + failures: readonly ScopedExternalAutomationFailure[], + entries: readonly AutomationHostCatalogEntry[] +): string | null { + const labels = externalAutomationUncheckedHostLabels(failures, entries) + if (labels.length === 0) { + return null + } + if (labels.length === 1) { + return translate( + 'auto.components.automations.externalScope.uncheckedHost', + 'External automation managers on {{hostLabel}} could not be checked.', + { hostLabel: labels[0] } + ) + } + return translate( + 'auto.components.automations.externalScope.uncheckedHosts', + 'External automation managers on {{count}} hosts could not be checked.', + { count: labels.length } + ) +} diff --git a/src/renderer/src/components/automations/use-automation-create-destination.ts b/src/renderer/src/components/automations/use-automation-create-destination.ts new file mode 100644 index 00000000000..e6d83e9491a --- /dev/null +++ b/src/renderer/src/components/automations/use-automation-create-destination.ts @@ -0,0 +1,199 @@ +/** + * The create dialog's destination: chosen once, shown, and re-checked at submit. + * + * The captured destination is deliberately state rather than a derivation of the + * live catalog. A dialog keeps the owner it captured for its whole lifetime, so + * a host that re-pairs or re-registers while the form is open is reported as + * moved instead of silently followed — which is the only way the user's "create + * it on that machine" survives the machine changing identity underneath it. + */ + +import { useCallback, useMemo, useRef, useState } from 'react' +import { translate } from '@/i18n/i18n' +import type { Repo } from '../../../../shared/repo-types' +import type { + AutomationHostCatalog, + AutomationHostCatalogEntry +} from './automation-host-catalog-types' +import type { AutomationAuthorityRepoTables } from './automation-authority-identity' +import { + automationCreateEligibleProjects, + automationCreateProjectMismatch, + preselectAutomationCreateHost, + resolveAutomationCreateDestination, + revalidateAutomationCreateDestination, + soleAutomationCreateHost, + type AutomationCreateDestination, + type AutomationCreateDestinationChoiceReason, + type AutomationCreateDestinationResolution +} from './automation-create-destination' +import type { AutomationActionNotice } from './automation-row-action-dispatch' + +export type AutomationCreateDestinationControl = { + entries: readonly AutomationHostCatalogEntry[] + resolution: AutomationCreateDestinationResolution + onSelect: (stableKey: string) => void + /** Projects the resolved destination can hold; the whole list until one resolves. */ + projects: readonly Repo[] +} + +export type AutomationCreateDestinationCheck = + | { ok: true; destination: AutomationCreateDestination } + | { ok: false; notice: AutomationActionNotice } + +export type AutomationCreateDestinationInput = { + /** False whenever the dialog is closed or is not creating an Orca automation. */ + open: boolean + catalog: AutomationHostCatalog + entries: readonly AutomationHostCatalogEntry[] + /** Non-null only when the list is filtered to one concrete host. */ + filterStableKey: string | null + activeWorkspaceStableKey: string | null + repoTables: AutomationAuthorityRepoTables + projects: readonly Repo[] +} + +export type AutomationCreateDestinationState = { + control: AutomationCreateDestinationControl + check: (projectId: string) => AutomationCreateDestinationCheck +} + +function choiceMessage(reason: AutomationCreateDestinationChoiceReason): string { + if (reason === 'orphan') { + return translate( + 'auto.components.automations.createDestination.orphan', + 'Automations with no host cannot hold new ones. Choose a host to create this automation on.' + ) + } + if (reason === 'unavailable') { + return translate( + 'auto.components.automations.createDestination.unavailable', + 'That host cannot hold a new automation yet. Choose another host to create this one on.' + ) + } + return translate( + 'auto.components.automations.createDestination.unselected', + 'Choose the host this automation will be created on.' + ) +} + +function ownerNotice(message: string): AutomationActionNotice { + return { message, recovery: null, severity: 'owner' } +} + +export function useAutomationCreateDestination( + input: AutomationCreateDestinationInput +): AutomationCreateDestinationState { + const { + open, + catalog, + entries, + filterStableKey, + activeWorkspaceStableKey, + repoTables, + projects + } = input + const [captured, setCaptured] = useState(null) + const [reason, setReason] = useState('unselected') + + // Adjusted during render rather than in an effect, so no frame paints the + // previous dialog's capture. Each branch is guarded on an actual change, so + // the re-render this schedules converges immediately. + if (!open && (captured !== null || reason !== 'unselected')) { + setCaptured(null) + setReason('unselected') + } + if (open && !captured) { + // Re-resolves until it first captures: a catalog still hydrating must not lock + // the form into "choose a host" once the host it would have picked arrives. + const entry = + preselectAutomationCreateHost(entries, filterStableKey, activeWorkspaceStableKey) ?? + soleAutomationCreateHost(entries, catalog.hydration) + const resolved = resolveAutomationCreateDestination(entry) + if (resolved.status === 'ready') { + setCaptured(resolved) + } else if (resolved.reason !== reason) { + setReason(resolved.reason) + } + } + + const onSelect = useCallback( + (stableKey: string) => { + const resolved = resolveAutomationCreateDestination( + entries.find((entry) => entry.stableKey === stableKey) + ) + setCaptured(resolved.status === 'ready' ? resolved : null) + if (resolved.status !== 'ready') { + setReason(resolved.reason) + } + }, + [entries] + ) + + const resolution = useMemo( + (): AutomationCreateDestinationResolution => + captured ? { status: 'ready', ...captured } : { status: 'choice-required', reason }, + [captured, reason] + ) + + // Why refs: the check runs inside an async submit, where a value closed over + // when the handler was created could be an incarnation old. + const entriesRef = useRef(entries) + entriesRef.current = entries + const repoTablesRef = useRef(repoTables) + repoTablesRef.current = repoTables + + const check = useCallback( + (projectId: string): AutomationCreateDestinationCheck => { + if (!captured) { + return { ok: false, notice: ownerNotice(choiceMessage(reason)) } + } + const revalidated = revalidateAutomationCreateDestination(captured, entriesRef.current) + if (revalidated.status === 'stale') { + return { + ok: false, + notice: { + message: translate( + 'auto.components.automations.createDestination.stale', + '{host} changed while this form was open. Choose it again before saving.' + ).replace('{host}', revalidated.entry.label), + recovery: 'retry', + severity: 'owner' + } + } + } + if (revalidated.status !== 'ready') { + return { ok: false, notice: ownerNotice(choiceMessage(revalidated.reason)) } + } + if (automationCreateProjectMismatch(repoTablesRef.current, revalidated, projectId)) { + return { + ok: false, + notice: ownerNotice( + translate( + 'auto.components.automations.createDestination.projectMismatch', + 'That project is not on {host}. Choose a project on that host, or another host.' + ).replace('{host}', revalidated.entry.label) + ) + } + } + return { ok: true, destination: revalidated } + }, + [captured, reason] + ) + + const eligibleProjects = useMemo( + () => + resolution.status === 'ready' + ? automationCreateEligibleProjects(repoTables, resolution, projects) + : projects, + [projects, repoTables, resolution] + ) + + return { + control: useMemo( + () => ({ entries, resolution, onSelect, projects: eligibleProjects }), + [eligibleProjects, entries, onSelect, resolution] + ), + check + } +} diff --git a/src/renderer/src/components/automations/use-automation-desktop-ssh-registrations.ts b/src/renderer/src/components/automations/use-automation-desktop-ssh-registrations.ts new file mode 100644 index 00000000000..54924f3791c --- /dev/null +++ b/src/renderer/src/components/automations/use-automation-desktop-ssh-registrations.ts @@ -0,0 +1,17 @@ +import { useAppStore } from '../../store' + +/** + * Desktop SSH *registration* generations, mirrored by the store slice. + * + * Only the generation makes an SSH host fenceable, so without it every desktop + * SSH entry projects as view-only. A target whose generation is unknown is + * absent from the map rather than defaulted, which degrades that one host to + * view-only instead of fencing it against a guessed registration. + * + * Reads the mirror rather than `ssh.listTargets()`: the store is already fed by + * every path that changes the target set, so a per-mount IPC round trip bought + * nothing and went stale the moment a host was added or removed behind it. + */ +export function useAutomationDesktopSshRegistrations(): ReadonlyMap { + return useAppStore((s) => s.sshTargetGenerations) +} diff --git a/src/renderer/src/components/automations/use-automation-host-catalog-readoption.test.tsx b/src/renderer/src/components/automations/use-automation-host-catalog-readoption.test.tsx new file mode 100644 index 00000000000..3c64eb45922 --- /dev/null +++ b/src/renderer/src/components/automations/use-automation-host-catalog-readoption.test.tsx @@ -0,0 +1,207 @@ +// @vitest-environment happy-dom + +/** + * Same-id SSH re-adoption on the desktop authority. + * + * This is the one catalog change that alters nothing the apply policy used to + * look at: the stable key is incarnation-free, the state stays authoritative, + * the query support stays scoped, the owner stays present and the host stays up + * — only the registration generation moves. The desktop's connection generation + * is a constant and can never reject anything either, so if the apply does not + * run, rows fetched against the previous registration both render and become the + * captured owners that fence later mutations. + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import type { AutomationListScopeSelector } from '../../../../shared/automation-list-scope' +import type { AppState } from '@/store' +import type { AutomationHostCatalogView } from './use-automation-host-catalog' +import { resetAutomationHostCatalogGenerationsForTests } from './automation-host-catalog-generation' + +const mocks = vi.hoisted(() => ({ state: {} as Record })) + +vi.mock('@/store', () => { + const useAppStore = (selector: (state: Partial) => unknown): unknown => + selector(mocks.state as Partial) + useAppStore.getState = (): Partial => mocks.state as Partial + return { useAppStore } +}) + +// Desktop-only file: every request is a local-target scoped list, so the +// transport routes straight into the generation-aware fake below. +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: (_target: unknown, _method: string, params: unknown) => + listScoped(params as { selector: AutomationListScopeSelector }), + getRuntimeEnvironmentStatus: vi.fn(), + hasRuntimeRpcErrorCode: () => false +})) + +import { useAutomationHostCatalog } from './use-automation-host-catalog' + +const TARGET_ID = 'ssh-1' +const SSH_STABLE_KEY = `host:desktop:ssh:${TARGET_ID}` +const DESKTOP_AUTHORITY_KEY = 'authority:desktop' + +function storeState(targetGeneration: number): Record { + const noop = (): void => undefined + return { + repos: [], + settings: null, + sshConnectionStates: new Map([[TARGET_ID, { status: 'connected' }]]), + sshTargetLabels: new Map([[TARGET_ID, 'Build box']]), + sshTargetGenerations: new Map([[TARGET_ID, targetGeneration]]), + removedSshTargetLabels: new Map(), + sshTargetsHydrated: true, + sshStateByEnvironment: new Map(), + runtimeEnvironments: [], + runtimeEnvironmentCatalogSettled: true, + runtimeStatusByEnvironmentId: new Map(), + automationHostFilter: { kind: 'all' }, + setAutomationHostFilter: noop, + openSettingsPage: noop, + openSettingsTarget: noop + } +} + +/** One row per registration generation, so a displayed row names the incarnation it came from. */ +function scopedResponse(selector: AutomationListScopeSelector): unknown { + if (selector.kind !== 'ssh') { + return { automations: [], items: [], orphanCount: 0 } + } + const id = `a-gen${selector.expectedTargetGeneration}` + return { + automations: [{ id, name: id } as Automation], + items: [ + { + automationId: id, + selector: { + kind: 'ssh', + targetId: selector.targetId, + targetGeneration: selector.expectedTargetGeneration + } + } + ], + orphanCount: 0 + } +} + +const scopeRequests: AutomationListScopeSelector[] = [] +/** Generation whose answer is held in flight; null lets every request answer at once. */ +let heldGeneration: number | null = null +let releaseHeld: (() => void) | null = null + +function listScoped({ selector }: { selector: AutomationListScopeSelector }): Promise { + scopeRequests.push(selector) + if (selector.kind === 'ssh' && selector.expectedTargetGeneration === heldGeneration) { + return new Promise((resolve) => { + releaseHeld = () => resolve(scopedResponse(selector)) + }) + } + return Promise.resolve(scopedResponse(selector)) +} + +const roots: Root[] = [] +let root: Root | null = null +let view: AutomationHostCatalogView | null = null + +function Harness(): null { + view = useAutomationHostCatalog() + return null +} + +async function flush(): Promise { + await act(async () => { + for (let index = 0; index < 8; index++) { + await Promise.resolve() + } + }) +} + +async function renderWithGeneration(targetGeneration: number): Promise { + mocks.state = storeState(targetGeneration) + if (!root) { + const container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + roots.push(root) + } + const current = root + await act(async () => { + current.render() + }) + await flush() +} + +function sshRequestGenerations(): number[] { + return scopeRequests + .filter((selector) => selector.kind === 'ssh') + .map((selector) => (selector as { expectedTargetGeneration: number }).expectedTargetGeneration) +} + +function sshHostAutomationIds(): string[] { + const group = view?.rows.groups.find((entry) => entry.authorityKey === DESKTOP_AUTHORITY_KEY) + const host = group?.hosts.find((candidate) => candidate.entry.stableKey === SSH_STABLE_KEY) + return (host?.rows ?? []).map((row) => row.automation.id) +} + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + resetAutomationHostCatalogGenerationsForTests() + view = null + root = null + scopeRequests.length = 0 + heldGeneration = null + releaseHeld = null + Object.assign(window, { + api: { + ssh: { connect: vi.fn() }, + runtimeEnvironments: { connect: vi.fn() } + } + }) +}) + +afterEach(async () => { + await act(async () => { + roots.splice(0).forEach((item) => item.unmount()) + }) + document.body.innerHTML = '' +}) + +describe('useAutomationHostCatalog same-id SSH re-adoption', () => { + it('discards a scoped answer captured before the re-adoption', async () => { + heldGeneration = 4 + await renderWithGeneration(4) + expect(sshRequestGenerations()).toEqual([4]) + + await renderWithGeneration(5) + heldGeneration = null + releaseHeld?.() + await flush() + + expect(sshHostAutomationIds()).not.toContain('a-gen4') + expect(sshHostAutomationIds()).toEqual(['a-gen5']) + }) + + it('re-fetches the re-adopted host inside the cache TTL', async () => { + await renderWithGeneration(4) + expect(sshHostAutomationIds()).toEqual(['a-gen4']) + + await renderWithGeneration(5) + + expect(sshRequestGenerations()).toEqual([4, 5]) + expect(sshHostAutomationIds()).toEqual(['a-gen5']) + }) + + it('does not keep the previous registration’s rows on screen while the re-fetch runs', async () => { + await renderWithGeneration(4) + expect(sshHostAutomationIds()).toEqual(['a-gen4']) + + heldGeneration = 5 + await renderWithGeneration(5) + + expect(sshHostAutomationIds()).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/automations/use-automation-host-catalog-source-state.ts b/src/renderer/src/components/automations/use-automation-host-catalog-source-state.ts new file mode 100644 index 00000000000..21cbd45a4db --- /dev/null +++ b/src/renderer/src/components/automations/use-automation-host-catalog-source-state.ts @@ -0,0 +1,89 @@ +import { useMemo } from 'react' +import { useAppStore } from '@/store' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import type { + AutomationCatalogRuntimeSource, + AutomationCatalogSshSource, + AutomationHostCatalogSource +} from './automation-host-catalog-source' + +/** + * Reads the mirrored SSH and runtime state the catalog projects from. + * + * Split out of `use-automation-host-catalog` so the store subscriptions stay in + * one place: every map here is a live store reference whose identity changes on + * each write, and the catalog memo below depends on all of them. + */ + +type AutomationHostCatalogSourceStateInput = { + /** Desktop SSH registration generations, mirrored by the store alongside the maps below. */ + desktopSshGenerations: ReadonlyMap + runtimeEnvironments: readonly PublicKnownRuntimeEnvironment[] +} + +export type AutomationHostCatalogSourceState = Pick< + AutomationHostCatalogSource, + 'desktopSsh' | 'runtimes' | 'runtimeCatalogSettled' +> + +export function useAutomationHostCatalogSourceState({ + desktopSshGenerations, + runtimeEnvironments +}: AutomationHostCatalogSourceStateInput): AutomationHostCatalogSourceState { + const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) + const removedSshTargetLabels = useAppStore((s) => s.removedSshTargetLabels) + const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) + const sshTargetsHydrated = useAppStore((s) => s.sshTargetsHydrated) + const sshStateByEnvironment = useAppStore((s) => s.sshStateByEnvironment) + const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) + const runtimeCatalogSettled = useAppStore((s) => s.runtimeEnvironmentCatalogSettled) + + const desktopSsh = useMemo( + (): AutomationCatalogSshSource => ({ + targetsHydrated: sshTargetsHydrated, + targetLabels: sshTargetLabels, + targetGenerations: desktopSshGenerations, + removedTargetLabels: removedSshTargetLabels, + connectionStates: sshConnectionStates + }), + [ + desktopSshGenerations, + removedSshTargetLabels, + sshConnectionStates, + sshTargetLabels, + sshTargetsHydrated + ] + ) + + const runtimes = useMemo( + (): AutomationCatalogRuntimeSource[] => + runtimeEnvironments.map((environment) => { + const bucket = sshStateByEnvironment.get(environment.id) + const status = runtimeStatusByEnvironmentId.get(environment.id) + return { + environmentId: environment.id, + label: environment.name, + // Mirrors the owner ref's own rule: creation time stands in until the + // server reports a revision, so a re-pair still changes the value. + pairingRevision: environment.pairingRevision ?? environment.createdAt, + status: status ? { status: status.status } : undefined, + ssh: bucket + ? { + targetsHydrated: bucket.targetsHydrated, + targetLabels: bucket.targetLabels, + targetGenerations: bucket.targetGenerations, + removedTargetLabels: bucket.removedTargetLabels, + connectionStates: bucket.connectionStates + } + : undefined + } + }), + [runtimeEnvironments, runtimeStatusByEnvironmentId, sshStateByEnvironment] + ) + + // Memoized: consumers key whole-catalog rebuilds on this object's identity. + return useMemo( + () => ({ desktopSsh, runtimes, runtimeCatalogSettled }), + [desktopSsh, runtimes, runtimeCatalogSettled] + ) +} diff --git a/src/renderer/src/components/automations/use-automation-host-catalog.test.tsx b/src/renderer/src/components/automations/use-automation-host-catalog.test.tsx new file mode 100644 index 00000000000..5e8e789b794 --- /dev/null +++ b/src/renderer/src/components/automations/use-automation-host-catalog.test.tsx @@ -0,0 +1,203 @@ +// @vitest-environment happy-dom + +/** + * Drives the real partition context the hook builds, not a stand-in for it. + * + * The legacy path is where an old runtime's unscoped list is classified on this + * client, so the evidence that classifies it has to be the answering authority's + * own project table. A test that supplied its own context would pass while the + * hook kept handing the desktop's table to every authority, so the only mocks + * here are the transports. + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import type { AppState } from '@/store' +import type { Repo } from '../../../../shared/repo-types' +import type { AutomationHostCatalogView } from './use-automation-host-catalog' + +const mocks = vi.hoisted(() => ({ + state: {} as Record, + callRuntimeRpc: vi.fn(), + getRuntimeEnvironmentStatus: vi.fn() +})) + +vi.mock('@/store', () => { + const useAppStore = (selector: (state: Partial) => unknown): unknown => + selector(mocks.state as Partial) + useAppStore.getState = (): Partial => mocks.state as Partial + return { useAppStore } +}) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: (...args: unknown[]) => mocks.callRuntimeRpc(...args), + getRuntimeEnvironmentStatus: (...args: unknown[]) => mocks.getRuntimeEnvironmentStatus(...args), + hasRuntimeRpcErrorCode: () => false +})) + +import { useAutomationHostCatalog } from './use-automation-host-catalog' + +const ENVIRONMENT_ID = 'env-1' +const PROJECT_ID = 'shared-project' +const RUNTIME_AUTHORITY_KEY = `authority:runtime:${ENVIRONMENT_ID}` + +function repo(overrides: Partial & { id: string }): Repo { + return { + path: `/repos/${overrides.id}`, + displayName: overrides.id, + badgeColor: 'blue', + addedAt: 0, + ...overrides + } as Repo +} + +function legacyAutomation(): Automation { + return { + id: 'a-legacy', + name: 'Nightly on the build box', + prompt: 'go', + precheck: null, + agentId: 'claude', + projectId: PROJECT_ID, + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'new_per_run', + workspaceId: null, + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: 0, + enabled: true, + nextRunAt: 0, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 0, + updatedAt: 0 + } as Automation +} + +function storeState(repos: readonly Repo[]): Record { + const noop = (): void => undefined + return { + repos, + settings: null, + sshConnectionStates: new Map(), + sshTargetLabels: new Map(), + sshTargetGenerations: new Map(), + removedSshTargetLabels: new Map(), + sshTargetsHydrated: true, + sshStateByEnvironment: new Map(), + runtimeEnvironments: [ + { id: ENVIRONMENT_ID, name: 'Build box', createdAt: 1, pairingRevision: 1 } + ], + runtimeEnvironmentCatalogSettled: true, + // No host-scope capability: this is the New-desktop/Old-runtime matrix row. + runtimeStatusByEnvironmentId: new Map([[ENVIRONMENT_ID, { status: { capabilities: [] } }]]), + automationHostFilter: { kind: 'all' }, + setAutomationHostFilter: noop, + openSettingsPage: noop, + openSettingsTarget: noop + } +} + +const roots: Root[] = [] +let view: AutomationHostCatalogView | null = null + +function Harness(): null { + view = useAutomationHostCatalog() + return null +} + +async function renderCatalog(repos: readonly Repo[]): Promise { + mocks.state = storeState(repos) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render() + }) + // The legacy answer commits through the cache and re-renders the view. + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +/** What the Runtime + Self row group actually shows for the old runtime. */ +function runtimeSelfAutomationIds(): string[] { + const group = view?.rows.groups.find((entry) => entry.authorityKey === RUNTIME_AUTHORITY_KEY) + const host = group?.hosts.find((candidate) => candidate.entry.kind === 'self') + return (host?.rows ?? []).map((row) => row.automation.id) +} + +/** The desktop authority's scoped-list answer, served on the local runtime target. */ +let desktopListScoped = vi.fn() + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + view = null + mocks.callRuntimeRpc.mockReset() + mocks.getRuntimeEnvironmentStatus.mockReset() + desktopListScoped = vi.fn().mockResolvedValue({ automations: [], items: [], orphanCount: 0 }) + mocks.callRuntimeRpc.mockImplementation(async (target: unknown, _method: string, params) => { + if ((target as { kind?: string } | null)?.kind === 'local') { + return await desktopListScoped(params) + } + return { automations: [legacyAutomation()] } + }) + mocks.getRuntimeEnvironmentStatus.mockResolvedValue({ capabilities: [] }) + Object.assign(window, { + api: { + ssh: { connect: vi.fn() }, + runtimeEnvironments: { connect: vi.fn() } + } + }) +}) + +afterEach(async () => { + await act(async () => { + roots.splice(0).forEach((root) => root.unmount()) + }) + document.body.innerHTML = '' +}) + +describe('useAutomationHostCatalog orphan bootstrap', () => { + it('projects the orphan entry a host answer reported', async () => { + desktopListScoped.mockResolvedValue({ automations: [], items: [], orphanCount: 2 }) + + await renderCatalog([]) + + // Only a committed answer can report it, so the catalog has to re-derive on + // the cache; nothing in the store changes when an orphan count arrives. + expect(view?.catalog.entries.map((entry) => entry.stableKey)).toContain('host:desktop:orphan') + }) +}) + +describe('useAutomationHostCatalog legacy partitioning', () => { + it('does not let a same-id desktop project claim an old runtime record for Self', async () => { + await renderCatalog([repo({ id: PROJECT_ID })]) + + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: ENVIRONMENT_ID }, + 'automation.list', + null, + expect.anything() + ) + expect(runtimeSelfAutomationIds()).toEqual([]) + }) + + it('classifies the record as Self from the runtime’s own mirrored project', async () => { + await renderCatalog([ + repo({ id: PROJECT_ID, executionHostId: `runtime:${ENVIRONMENT_ID}` }), + // Listed last so a bare-ID table would answer with this desktop SSH repo. + repo({ id: PROJECT_ID, connectionId: 'devbox' }) + ]) + + expect(runtimeSelfAutomationIds()).toEqual(['a-legacy']) + }) +}) diff --git a/src/renderer/src/components/automations/use-automation-host-catalog.ts b/src/renderer/src/components/automations/use-automation-host-catalog.ts new file mode 100644 index 00000000000..3d358e44584 --- /dev/null +++ b/src/renderer/src/components/automations/use-automation-host-catalog.ts @@ -0,0 +1,256 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useAppStore } from '@/store' +import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' +import { getLocalExecutionHostLabel } from '../../../../shared/execution-host' +import type { AutomationHostFilter } from '../../../../shared/automation-host-filter' +import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref' +import { buildAutomationHostCatalog } from './automation-host-catalog' +import { buildAutomationHostCatalogSource } from './automation-host-catalog-source' +import { + automationHostLoadCounts, + withAutomationHostCacheHealth, + type AutomationHostLoadCounts +} from './automation-host-cache-health' +import { + createAutomationHostQueryController, + type AutomationHostQueryController +} from './automation-host-cache-controller' +import { automationHostCatalogEntryFingerprint } from './automation-host-catalog-generation' +import type { AutomationAuthorityChangeEvent } from './automation-host-invalidation' +import type { + AutomationHostCatalog, + AutomationHostCatalogEntry +} from './automation-host-catalog-types' +import { automationHostFilterStableKey } from '../../../../shared/automation-host-filter' +import { + resolveAutomationHostFilter, + type AutomationHostFilterResolution +} from './automation-host-filter-resolution' +import { + resolveAutomationHostListRows, + type AutomationHostListRows +} from './automation-host-list-rows' +import { automationHostFetchTarget } from './automation-host-fetch-target' +import { + runAutomationHostRecovery, + type AutomationHostRecoveryDeps +} from './automation-host-recovery' +import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors' +import { + automationAuthorityPartitionContext, + automationRuntimePairingRevision, + groupReposByAutomationAuthority +} from './automation-authority-identity' +import { useAutomationDesktopSshRegistrations } from './use-automation-desktop-ssh-registrations' +import { useAutomationHostCatalogSourceState } from './use-automation-host-catalog-source-state' + +/** + * Owns the per-host query controller and the two catalogs the page needs. + * + * There are deliberately two: the one applied to the controller is derived from + * mirrored store state only, because applying a catalog advances generations and + * cancels in-flight work, and a catalog derived from query results would do that + * every time a query landed. The display catalog folds those results back in. + */ + +export type AutomationHostCatalogView = { + catalog: AutomationHostCatalog + entries: readonly AutomationHostCatalogEntry[] + resolution: AutomationHostFilterResolution + rows: AutomationHostListRows + loadCounts: AutomationHostLoadCounts + selectHost: (filter: AutomationHostFilter) => void + /** Runs the recovery verb the notice or empty state offered for a host. */ + recover: (action: AutomationHostRecoveryAction, entry?: AutomationHostCatalogEntry | null) => void + /** Manual refresh of every host in view, bypassing TTL where reachable. */ + refreshHosts: () => void + /** + * Invalidates the host a local write just landed on, without waiting for the + * authority's own event. The event still arrives and is harmless; what this + * removes is the round trip between the save returning and the list agreeing. + */ + notifyAuthorityChange: (event: AutomationAuthorityChangeEvent) => void +} + +export type AutomationHostCatalogOptions = { + /** Stable keys a stored automation or persisted filter still points at. */ + referencedStableKeys?: Iterable + /** Authorities whose most recent unscoped list attempt failed. */ + failedAuthorityKeys?: ReadonlySet +} + +export function useAutomationHostCatalog( + options: AutomationHostCatalogOptions = {} +): AutomationHostCatalogView { + const settings = useAppStore((s) => s.settings) + const repos = useAppStore((s) => s.repos) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const automationHostFilter = useAppStore((s) => s.automationHostFilter) + const setAutomationHostFilter = useAppStore((s) => s.setAutomationHostFilter) + const openSettingsPage = useAppStore((s) => s.openSettingsPage) + const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) + const desktopSshGenerations = useAutomationDesktopSshRegistrations() + + const desktopLabel = useMemo( + () => getHostDisplayLabelOverrides(settings).get('local') ?? getLocalExecutionHostLabel(), + [settings] + ) + // Why a ref: the controller outlives every render, so its legacy-partition + // callback must read the latest repo tables, not the ones it was created with. + const repoTables = useMemo(() => groupReposByAutomationAuthority(repos), [repos]) + const repoTablesRef = useRef(repoTables) + repoTablesRef.current = repoTables + const [controller] = useState(() => + createAutomationHostQueryController({ + // Scoped to the answering authority: another host's identically named repo + // is not evidence about this one, and its absence is not evidence either. + legacyPartitionContext: (authority) => + automationAuthorityPartitionContext(repoTablesRef.current, authority) + }) + ) + + const [cacheVersion, setCacheVersion] = useState(0) + useEffect(() => { + return controller.cache.subscribe(() => setCacheVersion((version) => version + 1)) + }, [controller]) + useEffect(() => () => controller.dispose(), [controller]) + + const orphanCount = useCallback( + (authority: StableAutomationAuthorityRef) => controller.authorityOrphanCount(authority), + [controller] + ) + const sourceState = useAutomationHostCatalogSourceState({ + desktopSshGenerations, + runtimeEnvironments + }) + const selectedStableKey = automationHostFilterStableKey(automationHostFilter) + // The saved selection references itself: without this the catalog would omit a + // removed host the user is still filtered to, and absence would read as removal. + const referencedStableKeys = useMemo( + () => + new Set([ + ...(options.referencedStableKeys ?? []), + ...(selectedStableKey ? [selectedStableKey] : []) + ]), + [options.referencedStableKeys, selectedStableKey] + ) + const queryCatalog = useMemo( + () => + buildAutomationHostCatalog( + buildAutomationHostCatalogSource({ + ...sourceState, + desktopLabel, + orphanCount, + referencedStableKeys + }) + ), + // oxlint-disable-next-line react-hooks/exhaustive-deps -- cacheVersion is the cache's change signal: orphan counts only exist in committed answers, so the catalog re-reads them when the cache moves. + [cacheVersion, desktopLabel, orphanCount, referencedStableKeys, sourceState] + ) + + // Why a signature rather than the catalog object: health and label changes + // re-derive the catalog but must not re-apply it, since applying cancels work. + // The generation fingerprint is embedded whole rather than summarised, so a + // change the commit fence would reject can never fail to re-apply here. + const applySignature = useMemo( + () => + [ + selectedStableKey ?? '', + ...queryCatalog.entries.map((entry) => + [ + automationHostCatalogEntryFingerprint(entry), + entry.querySupport, + entry.authorityHealth === 'unavailable' ? 'down' : 'up' + ].join(':') + ) + ].join('\n'), + [queryCatalog, selectedStableKey] + ) + useEffect(() => { + void controller.applyCatalog(queryCatalog, { selectedStableKey }) + // oxlint-disable-next-line react-hooks/exhaustive-deps -- applySignature is the apply policy: it covers the generation fingerprint plus every other field the scheduler reads, and keying off the catalog object would re-apply (and cancel in-flight work) on a label or health change. + }, [applySignature, controller]) + + const catalog = useMemo( + () => + withAutomationHostCacheHealth(queryCatalog, { + entry: (stableKey) => controller.cache.getByKey(stableKey), + ...(options.failedAuthorityKeys ? { failedAuthorityKeys: options.failedAuthorityKeys } : {}) + }), + // oxlint-disable-next-line react-hooks/exhaustive-deps -- cacheVersion is the cache's change signal; the cache object itself never changes identity. + [cacheVersion, controller, options.failedAuthorityKeys, queryCatalog] + ) + const resolution = useMemo( + () => + resolveAutomationHostFilter({ + filter: automationHostFilter, + catalog, + // Deliberately not `referencedStableKeys`: that set includes the saved + // selection so the catalog still projects it, and a selection that + // referenced itself could never be dropped when its host really goes. + referencedStableKeys: new Set([ + ...(options.referencedStableKeys ?? []), + ...catalog.entries + .filter((entry) => (controller.cache.getByKey(entry.stableKey)?.data.length ?? 0) > 0) + .map((entry) => entry.stableKey) + ]) + }), + // oxlint-disable-next-line react-hooks/exhaustive-deps -- cacheVersion is the cache's change signal; the cache object itself never changes identity. + [automationHostFilter, cacheVersion, catalog, controller, options.referencedStableKeys] + ) + const rows = useMemo( + () => + resolveAutomationHostListRows({ + catalog, + resolution, + entry: (stableKey) => controller.cache.getByKey(stableKey) + }), + // oxlint-disable-next-line react-hooks/exhaustive-deps -- cacheVersion is the cache's change signal; the cache object itself never changes identity. + [cacheVersion, catalog, controller, resolution] + ) + + const pairingRevision = useCallback( + (environmentId: string) => automationRuntimePairingRevision(runtimeEnvironments, environmentId), + [runtimeEnvironments] + ) + const recoveryDeps = useMemo( + (): AutomationHostRecoveryDeps => ({ + retry: (entry) => { + void controller.scheduler.retry(automationHostFetchTarget(entry, pairingRevision)) + }, + connectSshTarget: (targetId) => { + void window.api.ssh.connect({ targetId }) + }, + connectRuntimeEnvironment: (environmentId) => { + void window.api.runtimeEnvironments.connect({ selector: environmentId }) + }, + openSettings: (target) => { + openSettingsPage() + openSettingsTarget(target) + } + }), + [controller, openSettingsPage, openSettingsTarget, pairingRevision] + ) + + const recover = useCallback( + (action: AutomationHostRecoveryAction, entry?: AutomationHostCatalogEntry | null) => { + runAutomationHostRecovery(action, entry ?? resolution.entry, recoveryDeps) + }, + [recoveryDeps, resolution.entry] + ) + const refreshHosts = useCallback(() => { + void controller.applyCatalog(queryCatalog, { selectedStableKey, force: true }) + }, [controller, queryCatalog, selectedStableKey]) + + return { + catalog, + entries: catalog.entries, + resolution, + rows, + loadCounts: useMemo(() => automationHostLoadCounts(catalog), [catalog]), + selectHost: setAutomationHostFilter, + recover, + refreshHosts, + notifyAuthorityChange: controller.handleAuthorityEvent + } +} diff --git a/src/renderer/src/components/automations/use-automation-list-focus-recovery.test.tsx b/src/renderer/src/components/automations/use-automation-list-focus-recovery.test.tsx new file mode 100644 index 00000000000..186b04ae971 --- /dev/null +++ b/src/renderer/src/components/automations/use-automation-list-focus-recovery.test.tsx @@ -0,0 +1,91 @@ +// @vitest-environment happy-dom + +/** + * The hook, not the pure resolver: what it repairs depends on where focus went + * after it left a row, and only a real DOM can answer that. + */ + +import { act, useRef } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { useAutomationListFocusRecovery } from './use-automation-list-focus-recovery' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +function Harness({ rowKeys }: { rowKeys: readonly string[] }): React.JSX.Element { + const listRef = useRef(null) + const pickerRef = useRef(null) + useAutomationListFocusRecovery({ rowKeys, containerRef: listRef, fallbackRef: pickerRef }) + return ( +
+ {/* The header: outside the list container, exactly as the panel lays it out. */} +
+
+ +
+ {rowKeys.map((rowKey) => ( +
+ ))} +
+
+ ) +} + +function render(rowKeys: readonly string[]): void { + act(() => { + root.render() + }) +} + +function node(testId: string): HTMLElement { + const found = container.querySelector(`[data-testid="${testId}"]`) + if (!found) { + throw new Error(`no node for ${testId}`) + } + return found +} + +function focus(testId: string): void { + act(() => { + node(testId).focus() + }) +} + +describe('useAutomationListFocusRecovery', () => { + it('leaves focus in the search field when filtering removes the row it was on', () => { + render(['r1', 'r2', 'r3']) + focus('r2') + focus('search') + const search = node('search') + + render(['r1', 'r3']) + + expect(document.activeElement).toBe(search) + }) + + it('still repairs focus lost from the list itself', () => { + render(['r1', 'r2', 'r3']) + focus('r2') + act(() => { + node('r2').blur() + }) + + render(['r1', 'r3']) + + expect(document.activeElement).toBe(node('r3')) + }) +}) diff --git a/src/renderer/src/components/automations/use-automation-list-focus-recovery.ts b/src/renderer/src/components/automations/use-automation-list-focus-recovery.ts new file mode 100644 index 00000000000..d7f5b290dcd --- /dev/null +++ b/src/renderer/src/components/automations/use-automation-list-focus-recovery.ts @@ -0,0 +1,88 @@ +import { useEffect, useRef } from 'react' +import { resolveAutomationListFocusRecovery } from './automation-list-focus-recovery' + +/** + * Keeps keyboard focus inside the list when the row it was on disappears. + * + * The focused row has to be recorded as the user moves through the list, not + * read when the change lands: by then the row is unmounted and the browser has + * already dropped focus to ``, which is exactly the state this repairs. + * Focus is only moved when it was in this list and was actually lost — a user + * who has tabbed away is left alone. + */ + +export const AUTOMATION_ROW_ID_ATTRIBUTE = 'data-automation-row-id' + +export type AutomationListFocusRecoveryOptions = { + rowKeys: readonly string[] + containerRef: React.RefObject + /** Focused as the last resort — it is the control that produced this list. */ + fallbackRef: React.RefObject +} + +function focusRow(container: HTMLElement, rowKey: string): boolean { + const selector = `[${AUTOMATION_ROW_ID_ATTRIBUTE}="${CSS.escape(rowKey)}"]` + const row = container.querySelector(selector) + row?.focus() + return row !== null +} + +/** The fallback is a wrapper, so focus the control inside it rather than the box. */ +function focusFallback(fallback: HTMLElement | null): void { + const focusable = fallback?.querySelector('button, [tabindex]:not([tabindex="-1"])') + ;(focusable ?? fallback)?.focus() +} + +export function useAutomationListFocusRecovery({ + rowKeys, + containerRef, + fallbackRef +}: AutomationListFocusRecoveryOptions): void { + const focusedRowKeyRef = useRef(null) + const previousRowKeysRef = useRef(rowKeys) + + // Document-level: focus moving to the search field or any other control outside + // the list must erase the memory, and that event never reaches the container. + useEffect(() => { + const onFocusIn = (event: FocusEvent): void => { + const container = containerRef.current + const target = event.target + if (!container || !(target instanceof HTMLElement) || !container.contains(target)) { + focusedRowKeyRef.current = null + return + } + focusedRowKeyRef.current = + target + .closest(`[${AUTOMATION_ROW_ID_ATTRIBUTE}]`) + ?.getAttribute(AUTOMATION_ROW_ID_ATTRIBUTE) ?? null + } + document.addEventListener('focusin', onFocusIn) + return () => document.removeEventListener('focusin', onFocusIn) + }, [containerRef]) + + useEffect(() => { + const previousRowKeys = previousRowKeysRef.current + previousRowKeysRef.current = rowKeys + const container = containerRef.current + if (!container) { + return + } + // Focus still somewhere in the list means nothing was lost to repair. + if (container.contains(document.activeElement)) { + return + } + const target = resolveAutomationListFocusRecovery({ + previousRowKeys, + nextRowKeys: rowKeys, + focusedRowKey: focusedRowKeyRef.current + }) + if (!target) { + return + } + focusedRowKeyRef.current = null + if (target.kind === 'row' && focusRow(container, target.rowKey)) { + return + } + focusFallback(fallbackRef.current) + }, [rowKeys, containerRef, fallbackRef]) +} diff --git a/src/renderer/src/components/automations/use-automation-list-search.test.tsx b/src/renderer/src/components/automations/use-automation-list-search.test.tsx new file mode 100644 index 00000000000..49a2e60a7b9 --- /dev/null +++ b/src/renderer/src/components/automations/use-automation-list-search.test.tsx @@ -0,0 +1,197 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import { makeAutomation, makeAutomationListRow, REPO_ID } from './automations-page-fixtures' +import type { AutomationListRow } from './automation-list-row-identity' +import { resolveAutomationListEmptyState } from './automation-list-empty-state' +import type { AutomationHostFilterResolution } from './automation-host-filter-resolution' +import * as searchRows from './automation-list-search-rows' +import { useAutomationListSearch } from './use-automation-list-search' + +// Why: the 50 ms budget is a claim about how often indexing runs, so the test +// counts index builds instead of timing them (a wall clock would flake in CI). +vi.mock('./automation-list-search-rows', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + buildAutomationListSearchRows: vi.fn(actual.buildAutomationListSearchRows), + matchAutomationListSearchRowKeys: vi.fn(actual.matchAutomationListSearchRowKeys) + } +}) + +const buildRowsSpy = vi.mocked(searchRows.buildAutomationListSearchRows) +const matchRowsSpy = vi.mocked(searchRows.matchAutomationListSearchRowKeys) + +const repoMap = new Map([ + [REPO_ID, { id: REPO_ID, displayName: 'orca', path: '/src/orca' } as Repo] +]) + +type SearchResult = ReturnType + +let container: HTMLDivElement +let root: Root +let latest: SearchResult | null = null + +function Harness({ query, rows }: { query: string; rows: readonly AutomationListRow[] }): null { + latest = useAutomationListSearch({ + listSearchQuery: query, + rows, + externalAutomationEntries: [], + repoMap, + selectedRowKey: null, + selectedExternalKey: null, + selectAutomationRow: () => undefined, + selectExternalKey: () => undefined + }) + return null +} + +function render(query: string, rows: readonly AutomationListRow[]): void { + act(() => { + root.render() + }) +} + +function makeRows(count: number): AutomationListRow[] { + return Array.from({ length: count }, (_, index) => + makeAutomationListRow({ + automation: makeAutomation({ + id: `a-${index}`, + name: `Automation ${index}`, + prompt: `sweep repo ${index}` + }) + }) + ) +} + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + buildRowsSpy.mockClear() + matchRowsSpy.mockClear() +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + latest = null +}) + +describe('useAutomationListSearch index shape', () => { + it('indexes the row set once and never again per keystroke', () => { + render('', makeRows(20)) + const buildsAfterMount = buildRowsSpy.mock.calls.length + + // Fresh arrays per keystroke: a refresh tick mid-typing must not re-index. + for (const query of ['s', 'sw', 'swe', 'swee', 'sweep']) { + render(query, makeRows(20)) + } + + expect(buildRowsSpy.mock.calls.length).toBe(buildsAfterMount) + expect(latest?.filteredRows).toHaveLength(20) + }) + + it('reuses the index when a refresh tick replaces rows with equal search content', () => { + render('', makeRows(5)) + const buildsAfterMount = buildRowsSpy.mock.calls.length + + // New objects, new array, same searchable content — a nextRunAt-style tick. + render( + '', + makeRows(5).map((row) => ({ + ...row, + automation: { ...row.automation, nextRunAt: Date.now() } + })) + ) + + expect(buildRowsSpy.mock.calls.length).toBe(buildsAfterMount) + }) + + it('rebuilds once when searchable content actually changes', () => { + const automations = makeRows(5) + render('', automations) + const buildsAfterMount = buildRowsSpy.mock.calls.length + + const renamed = automations.map((row, index) => + index === 0 ? { ...row, automation: { ...row.automation, name: 'Renamed sweep' } } : row + ) + render('', renamed) + + expect(buildRowsSpy.mock.calls.length).toBe(buildsAfterMount + 1) + }) + + it('matches in a single pass over prebuilt indexes per settled query', () => { + const automations = makeRows(10) + render('', automations) + matchRowsSpy.mockClear() + + render('sweep repo 3', automations) + + // Two row sets (local + external), matched once each for the settled query. + expect(matchRowsSpy.mock.calls.length).toBeLessThanOrEqual(2) + expect(latest?.filteredRows.map((row) => row.automation.id)).toEqual(['a-3']) + }) +}) + +describe('useAutomationListSearch cross-host identity', () => { + it('keeps both hosts’ copies of one automation ID through a matching query', () => { + // Same ID under two authorities is legal (doc:38). Reconstructing the + // filtered list from a bare-ID map silently returns one host's copy twice + // and drops the other host's row from its group. + const collided = [ + makeAutomationListRow({ + automation: makeAutomation({ id: 'a-1', name: 'Nightly desktop' }) + }), + makeAutomationListRow({ + automation: makeAutomation({ id: 'a-1', name: 'Nightly web-01' }), + hostStableKey: 'host:runtime:gpu:ssh:web-01', + hostLabel: 'web-01' + }) + ] + render('nightly', collided) + + expect(latest?.filteredRows.map((row) => row.automation.name)).toEqual([ + 'Nightly desktop', + 'Nightly web-01' + ]) + }) +}) + +describe('useAutomationListSearch counts', () => { + it('reports rows before and after search for the empty-state view', () => { + const automations = makeRows(4) + render('', automations) + expect(latest?.searchCounts).toEqual({ + hostRowCount: 4, + visibleRowCount: 4, + searchActive: false + }) + + render('no-such-automation', automations) + expect(latest?.searchCounts).toEqual({ + hostRowCount: 4, + visibleRowCount: 0, + searchActive: true + }) + }) + + it('feeds the no-match empty state instead of letting it recompute counts', () => { + const resolution: AutomationHostFilterResolution = { + effective: { kind: 'all' }, + entry: null, + status: 'all', + announceFallback: false + } + render('no-such-automation', makeRows(3)) + const counts = latest?.searchCounts + if (!counts) { + throw new Error('hook produced no counts') + } + + expect(resolveAutomationListEmptyState({ resolution, ...counts }).kind).toBe('search-no-match') + }) +}) diff --git a/src/renderer/src/components/automations/use-automation-list-search.ts b/src/renderer/src/components/automations/use-automation-list-search.ts index 95ac89262a0..435e534962e 100644 --- a/src/renderer/src/components/automations/use-automation-list-search.ts +++ b/src/renderer/src/components/automations/use-automation-list-search.ts @@ -1,45 +1,72 @@ -import { useDeferredValue, useEffect, useMemo } from 'react' -import type { Automation } from '../../../../shared/automations-types' -import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity' +import { useDeferredValue, useEffect, useMemo, useRef } from 'react' import type { Repo } from '../../../../shared/repo-types' +import { resolveAutomationListSearchQuery } from './automation-list-search' +import type { AutomationListRow } from './automation-list-row-identity' import { - automationListSearchIndexMatches, - buildAutomationListSearchIndex, - buildAutomationProjectSearchText, - resolveAutomationListSearchQuery, - truncateAutomationListSearchField, - AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS, - type AutomationListSearchFields, - type AutomationListSearchIndex -} from './automation-list-search' + buildAutomationListSearchRowFingerprint, + buildAutomationListSearchRows, + buildAutomationSearchRowSources, + buildExternalAutomationSearchRowSources, + matchAutomationListSearchRowKeys, + type AutomationListSearchRow, + type AutomationListSearchRowSource, + type AutomationWorkspaceNameLookup +} from './automation-list-search-rows' import type { ExternalAutomationListEntry } from './external-automation-list-entries' -import { getExternalProviderLabel } from './external-automation-display' + +/** Counts the empty-state view consumes, so it never recomputes what search already knows. */ +export type AutomationListSearchCounts = { + hostRowCount: number + visibleRowCount: number + searchActive: boolean +} + +/** + * Rebuilds indexes only when the fingerprint changes, so a refresh tick that + * replaces arrays with equal search content re-renders without re-indexing. + * The cache is keyed by fingerprint rather than array identity, which useMemo + * cannot express on its own. + */ +function useAutomationSearchRows( + sources: AutomationListSearchRowSource[] +): AutomationListSearchRow[] { + const fingerprint = useMemo(() => buildAutomationListSearchRowFingerprint(sources), [sources]) + const cacheRef = useRef<{ fingerprint: string; rows: AutomationListSearchRow[] } | null>(null) + if (!cacheRef.current || cacheRef.current.fingerprint !== fingerprint) { + cacheRef.current = { fingerprint, rows: buildAutomationListSearchRows(sources) } + } + return cacheRef.current.rows +} export function useAutomationListSearch({ listSearchQuery, - automations, + rows, externalAutomationEntries, repoMap, - selectedId, + worktreeMap, + selectedRowKey, selectedExternalKey, - selectAutomationId, + selectAutomationRow, selectExternalKey }: { listSearchQuery: string - automations: readonly Automation[] + rows: readonly AutomationListRow[] externalAutomationEntries: readonly ExternalAutomationListEntry[] repoMap: ReadonlyMap - selectedId: string | null + worktreeMap?: AutomationWorkspaceNameLookup + /** The row currently on screen, not the bare id: two hosts can hold that id. */ + selectedRowKey: string | null selectedExternalKey: string | null - selectAutomationId: (automationId: string | null) => void + selectAutomationRow: (rowKey: string | null) => void selectExternalKey: (externalKey: string | null) => void }): { isListSearchQueryTooLarge: boolean isListSearchActive: boolean - filteredAutomations: readonly Automation[] + filteredRows: readonly AutomationListRow[] filteredExternalAutomationEntries: readonly ExternalAutomationListEntry[] hasListItems: boolean hasFilteredListItems: boolean + searchCounts: AutomationListSearchCounts } { // Why: keep the input snappy; matching is deferred so caret never waits on // index scans. Only the normalized active query can fire a search. @@ -60,127 +87,55 @@ export function useAutomationListSearch({ deferredListSearchResolution.status === 'active' ? deferredListSearchResolution.query : null const isListSearchActive = activeListSearchQuery !== null - // Why: fingerprint includes id + search fields so refresh ticks that only - // change nextRunAt / usage do not rebuild indexes or re-run matching. Prompts - // are truncated to the indexed prefix so each tick stays O(bound) per row. - const automationSearchFingerprint = useMemo( - () => - automations - .map((automation) => { - const repo = repoMap.get(getAutomationRunRepoId(automation)) - const project = buildAutomationProjectSearchText({ - displayName: repo?.displayName, - path: repo?.path - }) - const prompt = truncateAutomationListSearchField( - automation.prompt, - AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS - ) - return `${automation.id}\u0001${automation.name}\u0001${project}\u0001${prompt}` - }) - .join('\u0000'), - [automations, repoMap] + const automationSearchSources = useMemo( + () => buildAutomationSearchRowSources(rows, { repoMap, worktreeMap }), + [rows, repoMap, worktreeMap] ) - const automationSearchRows = useMemo((): { - id: string - index: AutomationListSearchIndex - }[] => { - return automations.map((automation) => { - const repo = repoMap.get(getAutomationRunRepoId(automation)) - return { - id: automation.id, - index: buildAutomationListSearchIndex({ - name: automation.name, - project: buildAutomationProjectSearchText({ - displayName: repo?.displayName, - path: repo?.path - }), - prompt: automation.prompt - }) - } - }) - // eslint-disable-next-line react-hooks/exhaustive-deps -- fingerprint is the rebuild gate - }, [automationSearchFingerprint]) + const automationSearchRows = useAutomationSearchRows(automationSearchSources) - const externalAutomationSearchFingerprint = useMemo( - () => - externalAutomationEntries - .map((entry) => { - const prompt = truncateAutomationListSearchField( - entry.job.prompt ?? entry.job.promptPreview ?? '', - AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS - ) - return `${entry.key}\u0001${entry.job.name}\u0001${getExternalProviderLabel(entry.manager)}\u0001${entry.manager.targetLabel}\u0001${entry.job.workdir ?? ''}\u0001${prompt}` - }) - .join('\u0000'), + const externalAutomationSearchSources = useMemo( + () => buildExternalAutomationSearchRowSources(externalAutomationEntries), [externalAutomationEntries] ) - const externalAutomationSearchRows = useMemo((): { - key: string - index: AutomationListSearchIndex - }[] => { - return externalAutomationEntries.map((entry) => { - const fields: AutomationListSearchFields = { - name: entry.job.name, - project: [ - getExternalProviderLabel(entry.manager), - entry.manager.targetLabel, - entry.job.workdir - ] - .filter(Boolean) - .join(' '), - prompt: entry.job.prompt ?? entry.job.promptPreview ?? '' - } - return { key: entry.key, index: buildAutomationListSearchIndex(fields) } - }) - // eslint-disable-next-line react-hooks/exhaustive-deps -- fingerprint is the rebuild gate - }, [externalAutomationSearchFingerprint]) + const externalAutomationSearchRows = useAutomationSearchRows(externalAutomationSearchSources) // Why: matching runs only when the normalized query or search content changes — // never on relativeNow / nextRunAt / usage refresh alone. - const filteredAutomationIds = useMemo((): readonly string[] | null => { - if (activeListSearchQuery === null) { - return null - } - const ids: string[] = [] - for (const row of automationSearchRows) { - if (automationListSearchIndexMatches(row.index, activeListSearchQuery)) { - ids.push(row.id) - } - } - return ids - }, [activeListSearchQuery, automationSearchRows]) + const filteredRowKeys = useMemo( + (): readonly string[] | null => + activeListSearchQuery === null + ? null + : matchAutomationListSearchRowKeys(automationSearchRows, activeListSearchQuery), + [activeListSearchQuery, automationSearchRows] + ) - const filteredExternalAutomationKeys = useMemo((): readonly string[] | null => { - if (activeListSearchQuery === null) { - return null - } - const keys: string[] = [] - for (const row of externalAutomationSearchRows) { - if (automationListSearchIndexMatches(row.index, activeListSearchQuery)) { - keys.push(row.key) - } - } - return keys - }, [activeListSearchQuery, externalAutomationSearchRows]) + const filteredExternalAutomationKeys = useMemo( + (): readonly string[] | null => + activeListSearchQuery === null + ? null + : matchAutomationListSearchRowKeys(externalAutomationSearchRows, activeListSearchQuery), + [activeListSearchQuery, externalAutomationSearchRows] + ) - const filteredAutomations = useMemo((): readonly Automation[] => { - if (filteredAutomationIds === null) { - return automations + const filteredRows = useMemo((): readonly AutomationListRow[] => { + if (filteredRowKeys === null) { + return rows } - if (filteredAutomationIds.length === 0) { + if (filteredRowKeys.length === 0) { return [] } - const byId = new Map(automations.map((automation) => [automation.id, automation])) - const next: Automation[] = [] - for (const id of filteredAutomationIds) { - const automation = byId.get(id) - if (automation) { - next.push(automation) + // Keyed by row, not automation id: a bare-id map holds one entry for two + // hosts' copies, so one host's row would be dropped and the other doubled. + const byKey = new Map(rows.map((row) => [row.key, row])) + const next: AutomationListRow[] = [] + for (const key of filteredRowKeys) { + const row = byKey.get(key) + if (row) { + next.push(row) } } return next - }, [automations, filteredAutomationIds]) + }, [rows, filteredRowKeys]) const filteredExternalAutomationEntries = useMemo((): readonly ExternalAutomationListEntry[] => { if (filteredExternalAutomationKeys === null) { @@ -200,9 +155,8 @@ export function useAutomationListSearch({ return next }, [externalAutomationEntries, filteredExternalAutomationKeys]) - const hasListItems = automations.length + externalAutomationEntries.length > 0 - const hasFilteredListItems = - filteredAutomations.length + filteredExternalAutomationEntries.length > 0 + const hostRowCount = rows.length + externalAutomationEntries.length + const visibleRowCount = filteredRows.length + filteredExternalAutomationEntries.length // Why: when search hides the current row, move selection to the first visible // match so list highlight and detail stay aligned. No matches → keep detail. @@ -212,21 +166,21 @@ export function useAutomationListSearch({ } const localVisible = selectedExternalKey === null && - selectedId != null && - filteredAutomations.some((automation) => automation.id === selectedId) + selectedRowKey != null && + filteredRows.some((row) => row.key === selectedRowKey) const externalVisible = selectedExternalKey != null && filteredExternalAutomationEntries.some((entry) => entry.key === selectedExternalKey) if (localVisible || externalVisible) { return } - const firstLocal = filteredAutomations[0] + const firstLocal = filteredRows[0] if (firstLocal) { if (selectedExternalKey !== null) { selectExternalKey(null) } - if (selectedId !== firstLocal.id) { - selectAutomationId(firstLocal.id) + if (selectedRowKey !== firstLocal.key) { + selectAutomationRow(firstLocal.key) } return } @@ -236,20 +190,23 @@ export function useAutomationListSearch({ } }, [ activeListSearchQuery, - filteredAutomations, + filteredRows, filteredExternalAutomationEntries, - selectAutomationId, + selectAutomationRow, selectExternalKey, selectedExternalKey, - selectedId + selectedRowKey ]) return { isListSearchQueryTooLarge, isListSearchActive, - filteredAutomations, + filteredRows, filteredExternalAutomationEntries, - hasListItems, - hasFilteredListItems + hasListItems: hostRowCount > 0, + hasFilteredListItems: visibleRowCount > 0, + // Why: the empty-state view reads rows-before and rows-after from here + // rather than recomputing either count from its own props. + searchCounts: { hostRowCount, visibleRowCount, searchActive: isListSearchActive } } } diff --git a/src/renderer/src/components/automations/use-automation-list-view.ts b/src/renderer/src/components/automations/use-automation-list-view.ts deleted file mode 100644 index f87550c4079..00000000000 --- a/src/renderer/src/components/automations/use-automation-list-view.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { useEffect, useMemo } from 'react' -import type { Automation, AutomationRun } from '../../../../shared/automations-types' -import type { ExternalAutomationListEntry } from './external-automation-list-entries' -import { - applyAutomationListView, - isAutomationListFilterActive, - type AutomationListFilter, - type AutomationListSort, - type AutomationListViewItem -} from './automation-list-view' - -export function useAutomationListView({ - automations, - externalEntries, - runs, - filter, - sort, - selectedId, - selectedExternalKey, - selectAutomationId, - selectExternalKey -}: { - automations: readonly Automation[] - externalEntries: readonly ExternalAutomationListEntry[] - runs: readonly AutomationRun[] - filter: AutomationListFilter - sort: AutomationListSort | null - selectedId: string | null - selectedExternalKey: string | null - selectAutomationId: (automationId: string | null) => void - selectExternalKey: (externalKey: string | null) => void -}): { - visibleItems: readonly AutomationListViewItem[] - isListFilterActive: boolean - hasVisibleListItems: boolean -} { - const isListFilterActive = isAutomationListFilterActive(filter) - const visibleItems = useMemo( - () => - applyAutomationListView({ - automations, - externalEntries, - runs, - filter, - sort - }), - [automations, externalEntries, filter, runs, sort] - ) - const hasVisibleListItems = visibleItems.length > 0 - - // Why: search already hides rows; filter/sort must keep highlight + detail on a - // still-visible row so the list never points at something the user cannot see. - useEffect(() => { - if (!isListFilterActive && !sort) { - return - } - const localVisible = - selectedExternalKey === null && - selectedId != null && - visibleItems.some((item) => item.kind === 'local' && item.id === selectedId) - const externalVisible = - selectedExternalKey != null && - visibleItems.some((item) => item.kind === 'external' && item.id === selectedExternalKey) - if (localVisible || externalVisible) { - return - } - const first = visibleItems[0] - if (!first) { - return - } - if (first.kind === 'local') { - if (selectedExternalKey !== null) { - selectExternalKey(null) - } - if (selectedId !== first.id) { - selectAutomationId(first.id) - } - return - } - if (selectedExternalKey !== first.id) { - selectExternalKey(first.id) - } - }, [ - isListFilterActive, - selectAutomationId, - selectExternalKey, - selectedExternalKey, - selectedId, - sort, - visibleItems - ]) - - return { - visibleItems, - isListFilterActive, - hasVisibleListItems - } -} diff --git a/src/renderer/src/components/automations/use-automation-source-host-availability.ts b/src/renderer/src/components/automations/use-automation-source-host-availability.ts new file mode 100644 index 00000000000..890a8deb4aa --- /dev/null +++ b/src/renderer/src/components/automations/use-automation-source-host-availability.ts @@ -0,0 +1,166 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { useAppStore } from '@/store' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import type { TaskSourceContext } from '../../../../shared/task-source-context' +import type { PreflightStatus } from '../../../../preload/api-types' +import { + getRepoBackedProviderAvailability, + type RuntimeProviderPreflightStatus +} from '../task-source-provider-availability' +import type { TaskSourceHostAvailability } from '../task-source-context-summary' +import { + getRepoBackedAutomationSourceContext, + getRuntimeSourceHostAvailability, + type RepoBackedAutomationSourceContext +} from './automation-source-context' +import type { AutomationListRow } from './automation-list-row-identity' +import { createAutomationHostRequestPool } from './automation-host-scheduler-queue' + +/** + * Availability of each automation's *source* host — the host its task provider + * is authenticated against, which can be a different machine from the one the + * automation runs on. Provider auth and tooling therefore have to be checked + * there, not on the run target. + * + * Each runtime source host is probed once per mount; the result is remembered + * even when the probe fails, so a host that could not be reached reads as + * checked-and-unknown rather than being dialled again on every render. + * + * Keyed by row, not by automation ID: two authorities may hold that ID, and + * their copies can name different source hosts. + */ +export function useAutomationSourceHostAvailability( + rows: readonly AutomationListRow[] +): ReadonlyMap { + const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) + const preflightStatus = useAppStore((s) => s.preflightStatus) + const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked) + const preflightStatusContextKey = useAppStore((s) => s.preflightStatusContextKey) + const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus) + const expectedPreflightContextKey = useAppStore((s) => + localPreflightContextKey(getLocalPreflightContext(s)) + ) + const mountedRef = useRef(true) + const requestedHostIdsRef = useRef>(new Set()) + const [requestPool] = useState(createAutomationHostRequestPool) + const [statusByHostId, setStatusByHostId] = useState< + ReadonlyMap + >(() => new Map()) + const preflightStatusCurrent = preflightStatusContextKey === expectedPreflightContextKey + + const sourceContexts = useMemo( + () => + rows + .map((row) => getRepoBackedAutomationSourceContext(row.automation)) + .filter((context): context is RepoBackedAutomationSourceContext => context !== null), + [rows] + ) + const runtimeSourceHostIds = useMemo(() => { + const hostIds = new Set() + for (const context of sourceContexts) { + const parsed = parseExecutionHostId(context.hostId) + if (parsed?.kind !== 'runtime') { + continue + } + if (getRuntimeSourceHostAvailability(context, runtimeStatusByEnvironmentId)) { + continue + } + hostIds.add(parsed.id) + } + return [...hostIds].sort() + }, [runtimeStatusByEnvironmentId, sourceContexts]) + + useEffect( + () => () => { + mountedRef.current = false + requestPool.cancelQueued() + }, + [requestPool] + ) + useEffect(() => { + if (!preflightStatusCurrent || !preflightStatusChecked) { + void refreshPreflightStatus() + } + }, [preflightStatusChecked, preflightStatusCurrent, refreshPreflightStatus]) + + useEffect(() => { + const unrequested = runtimeSourceHostIds.filter( + (hostId) => !requestedHostIdsRef.current.has(hostId) + ) + if (unrequested.length === 0) { + return + } + const record = (hostId: TaskSourceContext['hostId'], status: PreflightStatus | null): void => { + if (!mountedRef.current) { + return + } + setStatusByHostId((current) => new Map(current).set(hostId, { checked: true, status })) + } + setStatusByHostId((current) => { + const next = new Map(current) + for (const hostId of unrequested) { + next.set(hostId, { checked: false, status: null }) + } + return next + }) + for (const hostId of unrequested) { + requestedHostIdsRef.current.add(hostId) + const parsed = parseExecutionHostId(hostId) + if (parsed?.kind !== 'runtime') { + continue + } + void requestPool.submit({ + run: async () => { + try { + const status = await callRuntimeRpc( + { kind: 'environment', environmentId: parsed.environmentId }, + 'preflight.check', + undefined, + { timeoutMs: 15_000 } + ) + record(hostId, status) + } catch { + record(hostId, null) + } + } + }) + } + }, [requestPool, runtimeSourceHostIds]) + + return useMemo(() => { + const availabilityByRowKey = new Map() + for (const row of rows) { + const context = getRepoBackedAutomationSourceContext(row.automation) + if (!context) { + continue + } + const hostAvailability = getRuntimeSourceHostAvailability( + context, + runtimeStatusByEnvironmentId + ) + const availability = [ + ...(hostAvailability ? [hostAvailability] : []), + ...getRepoBackedProviderAvailability({ + provider: context.provider, + contexts: [context], + preflightStatus, + preflightReady: preflightStatusCurrent && preflightStatusChecked, + runtimePreflightStatusByHostId: statusByHostId + }) + ] + if (availability.length > 0) { + availabilityByRowKey.set(row.key, availability) + } + } + return availabilityByRowKey + }, [ + rows, + preflightStatus, + preflightStatusChecked, + preflightStatusCurrent, + runtimeStatusByEnvironmentId, + statusByHostId + ]) +} diff --git a/src/renderer/src/components/automations/use-external-automation-scope-retention.test.tsx b/src/renderer/src/components/automations/use-external-automation-scope-retention.test.tsx new file mode 100644 index 00000000000..73e44e5f151 --- /dev/null +++ b/src/renderer/src/components/automations/use-external-automation-scope-retention.test.tsx @@ -0,0 +1,140 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import { useExternalAutomationScopeRetention } from './use-external-automation-scope-retention' + +const retainExternalScopes = + vi.fn<(request: { owners: readonly AutomationOwnerRef[] }) => Promise>() + +let container: HTMLDivElement +let root: Root + +function entry(overrides: Partial = {}): AutomationHostCatalogEntry { + return { + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'self' } }, + owner: { authority: { kind: 'desktop' }, selector: { kind: 'self' } }, + stableKey: 'host:desktop:self', + label: 'This computer', + authorityLabel: 'This computer', + kind: 'self', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'connected', + querySupport: 'scoped', + ...overrides + } +} + +function sshEntry(targetId: string, targetGeneration = 3): AutomationHostCatalogEntry { + return entry({ + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId } }, + owner: { + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId, targetGeneration } + }, + stableKey: `host:desktop:ssh:${targetId}`, + label: targetId, + kind: 'ssh' + }) +} + +const runtimeEntry = entry({ + stableRef: { authority: { kind: 'runtime', environmentId: 'env-1' }, selector: { kind: 'self' } }, + owner: { + authority: { kind: 'runtime', environmentId: 'env-1', pairingRevision: 1 }, + selector: { kind: 'self' } + }, + stableKey: 'host:runtime:env-1:self', + label: 'Build box' +}) + +function Harness({ entries }: { entries: readonly AutomationHostCatalogEntry[] }): null { + useExternalAutomationScopeRetention(entries) + return null +} + +function render(entries: readonly AutomationHostCatalogEntry[]): void { + act(() => { + root.render() + }) +} + +function retainedOwners(callIndex: number): readonly AutomationOwnerRef[] { + return retainExternalScopes.mock.calls[callIndex]?.[0].owners ?? [] +} + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + retainExternalScopes.mockReset() + retainExternalScopes.mockResolvedValue(undefined) + Object.assign(window, { api: { automations: { retainExternalScopes } } }) +}) + +afterEach(() => { + container.remove() +}) + +describe('useExternalAutomationScopeRetention', () => { + it('retains only the hosts whose managers are actually listed', () => { + render([entry(), runtimeEntry, sshEntry('t1')]) + + expect(retainExternalScopes).toHaveBeenCalledTimes(1) + expect(retainedOwners(0)).toEqual([ + { authority: { kind: 'desktop' }, selector: { kind: 'self' } }, + { + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId: 't1', targetGeneration: 3 } + } + ]) + act(() => root.unmount()) + }) + + it('does not restart in-flight probes when the same scope set re-renders', () => { + render([entry(), sshEntry('t1')]) + render([entry(), sshEntry('t1')]) + + expect(retainExternalScopes).toHaveBeenCalledTimes(1) + act(() => root.unmount()) + }) + + it('drops a host as soon as the filter moves off it', () => { + render([entry(), sshEntry('t1')]) + render([sshEntry('t1')]) + + expect(retainExternalScopes).toHaveBeenCalledTimes(2) + expect(retainedOwners(1)).toEqual([ + { + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId: 't1', targetGeneration: 3 } + } + ]) + act(() => root.unmount()) + }) + + it('re-retains when a host is re-added under a new incarnation', () => { + render([sshEntry('t1', 3)]) + render([sshEntry('t1', 4)]) + + expect(retainExternalScopes).toHaveBeenCalledTimes(2) + expect(retainedOwners(1)).toEqual([ + { + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId: 't1', targetGeneration: 4 } + } + ]) + act(() => root.unmount()) + }) + + it('retains nothing once the page unmounts', () => { + render([entry()]) + act(() => root.unmount()) + + expect(retainedOwners(retainExternalScopes.mock.calls.length - 1)).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/automations/use-external-automation-scope-retention.ts b/src/renderer/src/components/automations/use-external-automation-scope-retention.ts new file mode 100644 index 00000000000..294479d8974 --- /dev/null +++ b/src/renderer/src/components/automations/use-external-automation-scope-retention.ts @@ -0,0 +1,32 @@ +import { useEffect, useMemo, useRef } from 'react' +import { ownerKey } from '../../../../shared/automation-owner-key' +import { externalAutomationProbeOwners } from './external-automation-scope-gating' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' + +/** + * Keeps the main-process probe pool working only on the hosts currently in view. + * + * Probes for a host the user has filtered away are cancelled rather than left to + * finish: they would land after the view moved on, and they compete with the + * Orca traffic the user is waiting on. Leaving the page retains nothing. + */ +export function useExternalAutomationScopeRetention( + entries: readonly AutomationHostCatalogEntry[] +): void { + const owners = useMemo(() => externalAutomationProbeOwners(entries), [entries]) + // Why: the signature gates the send so a re-rendered but identical scope set + // does not cancel and restart probes that are already in flight. + const signature = useMemo(() => owners.map(ownerKey).join(' '), [owners]) + const ownersRef = useRef(owners) + ownersRef.current = owners + + useEffect(() => { + void window.api.automations.retainExternalScopes({ owners: ownersRef.current }) + }, [signature]) + + useEffect(() => { + return () => { + void window.api.automations.retainExternalScopes({ owners: [] }) + } + }, []) +} diff --git a/src/renderer/src/components/automations/use-scoped-external-automations.ts b/src/renderer/src/components/automations/use-scoped-external-automations.ts new file mode 100644 index 00000000000..03a033fca3c --- /dev/null +++ b/src/renderer/src/components/automations/use-scoped-external-automations.ts @@ -0,0 +1,149 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { + ExternalAutomationAction, + ExternalAutomationJob +} from '../../../../shared/automations-types' +import type { ScopedExternalManagerMutationFields } from '../../../../shared/external-automation-scope' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import { + createScopedExternalAutomation, + desktopExternalAutomationOwner, + externalAutomationScopes, + listScopedExternalAutomationManagers, + listScopedExternalAutomationRuns, + runScopedExternalAutomationAction, + updateScopedExternalAutomation, + type ExternalAutomationScope, + type ScopedExternalAutomationFailure, + type ScopedExternalAutomationManager +} from './external-automation-scope-client' +import { externalAutomationScopeKey } from './external-automation-scope-keys' +import { useExternalAutomationScopeRetention } from './use-external-automation-scope-retention' + +/** + * External managers for the hosts currently in view, and the scope each one was + * discovered under. + * + * A manager ID is unique only inside one host — `hermes:local` names no + * authority at all — so every call here takes the scope its row was discovered + * under rather than a manager to look one up from. There is no lookup to miss + * and no host to guess at. + */ + +export type ScopedExternalAutomationsInput = { + /** The whole catalog — used to resolve a create target, never to probe. */ + catalogEntries: readonly AutomationHostCatalogEntry[] + /** The hosts in view. Only these are probed, and only these are retained. */ + scopeEntries: readonly AutomationHostCatalogEntry[] +} + +export type ScopedExternalAutomationsView = { + managers: readonly ScopedExternalAutomationManager[] + /** Scopes that answered with neither a manager nor a denial. */ + failures: readonly ScopedExternalAutomationFailure[] + /** Reloads every in-view scope; safe to call from the page's refresh path. */ + reload: () => Promise + /** The scope a new external automation would be created in, or null to disable it. */ + createScope: (connectionId: string | null) => ExternalAutomationScope | null + saveExternalAutomation: ( + scope: ExternalAutomationScope, + fields: ScopedExternalManagerMutationFields, + jobId: string | null + ) => Promise + runExternalAction: ( + scope: ExternalAutomationScope, + jobId: string, + action: ExternalAutomationAction + ) => Promise + fetchRuns: ( + scope: ExternalAutomationScope, + job: ExternalAutomationJob, + page: number, + pageSize: number + ) => Promise<{ runs: readonly ExternalAutomationRunRow[]; totalCount: number }> +} + +type ExternalAutomationRunRow = Awaited< + ReturnType +>['runs'][number] + +const EMPTY_VIEW = { + managers: [] as readonly ScopedExternalAutomationManager[], + failures: [] as readonly ScopedExternalAutomationFailure[] +} + +export function useScopedExternalAutomations({ + catalogEntries, + scopeEntries +}: ScopedExternalAutomationsInput): ScopedExternalAutomationsView { + const [result, setResult] = useState(EMPTY_VIEW) + const catalogEntriesRef = useRef(catalogEntries) + catalogEntriesRef.current = catalogEntries + useExternalAutomationScopeRetention(scopeEntries) + + const scopes = useMemo(() => externalAutomationScopes(scopeEntries), [scopeEntries]) + const signature = useMemo( + () => scopes.map((scope) => externalAutomationScopeKey(scope)).join(' '), + [scopes] + ) + const scopesRef = useRef(scopes) + scopesRef.current = scopes + const signatureRef = useRef(signature) + signatureRef.current = signature + // Seeded with the mount signature: the page's own mount refresh issues the + // first load, and this must not duplicate it. + const loadedSignatureRef = useRef(signature) + // A selection change can overtake the probe it replaced; only the newest wins. + const loadTokenRef = useRef(0) + + const reload = useCallback(async (): Promise => { + const token = ++loadTokenRef.current + loadedSignatureRef.current = signatureRef.current + const next = await listScopedExternalAutomationManagers(scopesRef.current) + if (token !== loadTokenRef.current) { + return + } + setResult({ managers: next.managers, failures: next.failures }) + }, []) + + // The scope set is the query: when it changes, what is on screen is another host's answer. + useEffect(() => { + if (loadedSignatureRef.current === signature) { + return + } + // The old host's managers are not evidence about the new one, so they go now + // rather than when its probe answers. + setResult(EMPTY_VIEW) + void reload().catch(() => undefined) + }, [signature, reload]) + + const createScope = useCallback((connectionId: string | null): ExternalAutomationScope | null => { + const owner = desktopExternalAutomationOwner(catalogEntriesRef.current, connectionId) + // Hermes is the only provider a create dialog offers today; the picker + // that would choose between providers does not exist yet. + return owner ? { owner, provider: 'hermes' } : null + }, []) + + return { + managers: result.managers, + failures: result.failures, + reload, + createScope, + saveExternalAutomation: useCallback( + async (scope, fields, jobId) => + jobId === null + ? await createScopedExternalAutomation(scope, fields) + : await updateScopedExternalAutomation(scope, jobId, fields), + [] + ), + runExternalAction: useCallback( + async (scope, jobId, action) => await runScopedExternalAutomationAction(scope, jobId, action), + [] + ), + fetchRuns: useCallback( + async (scope, job, page, pageSize) => + await listScopedExternalAutomationRuns(scope, job, page, pageSize), + [] + ) + } +} diff --git a/src/renderer/src/components/automations/use-selected-automation-run-history.test.tsx b/src/renderer/src/components/automations/use-selected-automation-run-history.test.tsx new file mode 100644 index 00000000000..ae5d768293b --- /dev/null +++ b/src/renderer/src/components/automations/use-selected-automation-run-history.test.tsx @@ -0,0 +1,193 @@ +// @vitest-environment happy-dom + +/** + * A refused history read is an answer too. It used to be dropped on the floor, + * which left the previous automation's ID in place — so the pane showed zero + * runs for a question the host never answered, and anything waiting on this + * read waited forever. + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AutomationRun } from '../../../../shared/automations-types' +import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref' +import { ownerKey } from '../../../../shared/automation-owner-key' +import { makeAutomation, makeRun } from './automations-page-fixtures' +import { automationListRowKey, type AutomationListRow } from './automation-list-row-identity' +import { + useSelectedAutomationRunHistory, + type SelectedAutomationRunHistoryInput, + type SelectedAutomationRunHistoryOutcome +} from './use-selected-automation-run-history' + +const mocks = vi.hoisted(() => ({ dispatch: vi.fn() })) + +vi.mock('./automation-row-action-dispatch', () => ({ + dispatchAutomationRunHistory: (...args: unknown[]) => mocks.dispatch(...args) +})) + +const roots: Root[] = [] + +const DESKTOP_ROW: AutomationListRow = { + key: automationListRowKey('host:desktop|self', 'a-1'), + automation: makeAutomation({ id: 'a-1' }), + hostLabel: 'This computer', + usageSummary: null +} + +function Harness(props: { input: SelectedAutomationRunHistoryInput }): null { + useSelectedAutomationRunHistory(props.input) + return null +} + +function makeInput( + overrides: Partial = {} +): SelectedAutomationRunHistoryInput { + return { + selected: DESKTOP_ROW, + context: { capturedOwners: new Map(), authority: { kind: 'desktop' } }, + legacyTarget: () => ({ kind: 'local' }), + navigation: null, + reloadToken: 0, + onSettled: vi.fn(), + ...overrides + } +} + +async function render( + input: SelectedAutomationRunHistoryInput +): Promise<(next: SelectedAutomationRunHistoryInput) => Promise> { + const container = document.createElement('div') + const root = createRoot(container) + roots.push(root) + const rerender = async (next: SelectedAutomationRunHistoryInput): Promise => { + await act(async () => { + root.render() + }) + } + await rerender(input) + return rerender +} + +function settled(input: SelectedAutomationRunHistoryInput): SelectedAutomationRunHistoryOutcome[] { + return (input.onSettled as ReturnType).mock.calls.map( + ([outcome]) => outcome as SelectedAutomationRunHistoryOutcome + ) +} + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + vi.clearAllMocks() +}) + +afterEach(async () => { + await act(async () => { + roots.splice(0).forEach((root) => root.unmount()) + }) +}) + +describe('useSelectedAutomationRunHistory', () => { + it('reports the refusal instead of leaving the pane on the previous automation', async () => { + mocks.dispatch.mockResolvedValue({ + ok: false, + notice: { message: 'web-01 is not connected', recovery: 'reconnect', severity: 'failure' } + }) + const input = makeInput() + + await render(input) + + expect(settled(input)).toEqual([ + { + automationId: 'a-1', + rowKey: DESKTOP_ROW.key, + ownerKey: 'uncaptured', + runs: [], + notice: { message: 'web-01 is not connected', recovery: 'reconnect', severity: 'failure' } + } + ]) + }) + + it('reports a blocked row rather than discarding its reason', async () => { + mocks.dispatch.mockResolvedValue({ + ok: false, + notice: { + message: 'This automation has no host to run on.', + recovery: null, + severity: 'owner' + } + }) + const input = makeInput() + + await render(input) + + expect(settled(input)[0]?.notice?.severity).toBe('owner') + expect(settled(input)[0]?.automationId).toBe('a-1') + }) + + it('settles a successful read with no notice', async () => { + const runs: AutomationRun[] = [makeRun({ id: 'run-1', automationId: 'a-1' })] + mocks.dispatch.mockResolvedValue({ ok: true, value: runs }) + const input = makeInput() + + await render(input) + + expect(settled(input)).toEqual([ + { automationId: 'a-1', rowKey: DESKTOP_ROW.key, ownerKey: 'uncaptured', runs, notice: null } + ]) + }) + + it('re-asks the host when the reload token changes, so a failure can be retried', async () => { + mocks.dispatch.mockResolvedValue({ + ok: false, + notice: { message: 'offline', recovery: 'retry', severity: 'failure' } + }) + const input = makeInput() + + const rerender = await render(input) + expect(mocks.dispatch).toHaveBeenCalledTimes(1) + + mocks.dispatch.mockResolvedValue({ ok: true, value: [] }) + await rerender({ ...input, reloadToken: 1 }) + + expect(mocks.dispatch).toHaveBeenCalledTimes(2) + expect(settled(input).at(-1)).toEqual({ + automationId: 'a-1', + rowKey: DESKTOP_ROW.key, + ownerKey: 'uncaptured', + runs: [], + notice: null + }) + }) + + it('names the owner the history was read under', async () => { + mocks.dispatch.mockResolvedValue({ ok: true, value: [] }) + const owner: AutomationOwnerRef = { + authority: { kind: 'runtime', environmentId: 'gpu', pairingRevision: 2 }, + selector: { kind: 'self' } + } + const input = makeInput({ + context: { + capturedOwners: new Map([[DESKTOP_ROW.key, { owner, selector: { kind: 'self' } }]]), + authority: { kind: 'desktop' } + } + }) + + await render(input) + + // The ID alone is unique only inside one authority, so the outcome carries + // the owner and the pane can refuse a result read for a different host. + expect(settled(input).at(-1)?.ownerKey).toBe(ownerKey(owner)) + }) + + it('clears the pane when nothing is selected', async () => { + const input = makeInput({ selected: null }) + + await render(input) + + expect(settled(input)).toEqual([ + { automationId: null, rowKey: null, ownerKey: null, runs: [], notice: null } + ]) + expect(mocks.dispatch).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/automations/use-selected-automation-run-history.ts b/src/renderer/src/components/automations/use-selected-automation-run-history.ts new file mode 100644 index 00000000000..2f1608f639a --- /dev/null +++ b/src/renderer/src/components/automations/use-selected-automation-run-history.ts @@ -0,0 +1,111 @@ +/** + * Fetches only the selected automation's history, through its captured owner. + * + * The refetch policy is a string key rather than the objects the fetch reads: + * the dispatch context is rebuilt on every cache reprojection, and keying an + * effect on it re-asks the same host for the same history in a loop. What + * actually decides the request is the row, the owner it was listed under, and + * the host a navigation named — so those, and only those, are the key. + */ + +import { useEffect, useRef } from 'react' +import type { AutomationRun } from '../../../../shared/automations-types' +import { capturedAutomationOwner, capturedAutomationOwnerKey } from './automation-captured-owner' +import type { AutomationListRow } from './automation-list-row-identity' +import { + type AutomationActionNotice, + type AutomationDispatchContext, + dispatchAutomationRunHistory +} from './automation-row-action-dispatch' +import { + getAutomationTargetFromHostId, + listAutomationRunsForTarget, + type AutomationHostTarget +} from './automation-host-client' + +export type SelectedAutomationRunNavigation = { + automationId: string + hostId?: string | null +} | null + +export type SelectedAutomationRunHistoryOutcome = { + automationId: string | null + /** Which listed row was asked; an ID alone cannot name one of two hosts' copies. */ + rowKey: string | null + /** Which incarnation answered; a re-pairing makes the same row's answer stale. */ + ownerKey: string | null + runs: AutomationRun[] + /** + * Non-null when the host refused or failed the read. The runs are then unknown + * rather than absent, and the caller must say so instead of showing zero runs. + */ + notice: AutomationActionNotice | null +} + +export type SelectedAutomationRunHistoryInput = { + selected: AutomationListRow | null + context: AutomationDispatchContext + /** The row's own host, used only when no owner was captured for it. */ + legacyTarget: (row: AutomationListRow) => AutomationHostTarget | null + navigation: SelectedAutomationRunNavigation + /** Changing this re-asks the host; it is how a failed read offers Retry. */ + reloadToken: number + onSettled: (outcome: SelectedAutomationRunHistoryOutcome) => void +} + +function navigationHostId( + navigation: SelectedAutomationRunNavigation, + automationId: string +): string | null { + return navigation?.automationId === automationId ? (navigation.hostId ?? null) : null +} + +export function useSelectedAutomationRunHistory(input: SelectedAutomationRunHistoryInput): void { + const inputRef = useRef(input) + inputRef.current = input + + const automationId = input.selected?.automation.id ?? null + const rowKey = input.selected?.key ?? null + const reloadToken = input.reloadToken + const fetchKey = + automationId && rowKey + ? [ + rowKey, + capturedAutomationOwnerKey(capturedAutomationOwner(input.context.capturedOwners, rowKey)), + navigationHostId(input.navigation, automationId) ?? '' + ].join('|') + : '' + + useEffect(() => { + const { selected, context, legacyTarget, navigation, onSettled } = inputRef.current + if (!selected || !automationId || !rowKey) { + onSettled({ automationId: null, rowKey: null, ownerKey: null, runs: [], notice: null }) + return + } + let cancelled = false + const owner = capturedAutomationOwnerKey( + capturedAutomationOwner(context.capturedOwners, rowKey) + ) + const navigationHost = navigationHostId(navigation, automationId) + const target = navigationHost + ? getAutomationTargetFromHostId(navigationHost) + : (legacyTarget(selected) ?? { kind: 'local' }) + void dispatchAutomationRunHistory(context, { rowKey, automationId }, () => + listAutomationRunsForTarget(target, automationId) + ).then((result) => { + if (cancelled) { + return + } + // A refused or failed read still settles: leaving the previous automation's + // ID in place strands anything waiting on this one and shows its runs as zero. + onSettled( + result.ok + ? { automationId, rowKey, ownerKey: owner, runs: result.value, notice: null } + : { automationId, rowKey, ownerKey: owner, runs: [], notice: result.notice } + ) + }) + return () => { + cancelled = true + } + }, [automationId, rowKey, fetchKey, reloadToken]) +} diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx index 7af24b68947..d8ea235b852 100644 --- a/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx +++ b/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx @@ -293,6 +293,7 @@ describe('RepositoryHostSetupsSection', () => { } ] ]), + targetGenerations: new Map(), targetLabels: new Map([ ['direct', 'Direct box'], ['jump', 'Jump box'] diff --git a/src/renderer/src/components/settings/ssh-target-save-payload.ts b/src/renderer/src/components/settings/ssh-target-save-payload.ts index 7133daf6db2..8e51a531212 100644 --- a/src/renderer/src/components/settings/ssh-target-save-payload.ts +++ b/src/renderer/src/components/settings/ssh-target-save-payload.ts @@ -1,4 +1,8 @@ -import { MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, type SshTarget } from '../../../../shared/ssh-types' +import { + MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, + type SshTargetCreateInput, + type SshTargetUpdateInput +} from '../../../../shared/ssh-types' import { getSshTargetDraftConnectionFields, isRelayGracePeriodValid, @@ -8,8 +12,8 @@ import { import { translate } from '../../i18n/i18n' type SshTargetSavePayload = { - target: Omit - updates: Partial> + target: SshTargetCreateInput + updates: SshTargetUpdateInput } type SshTargetSavePayloadResult = @@ -55,7 +59,7 @@ export function buildSshTargetSavePayload(form: EditingTarget): SshTargetSavePay const jumpHost = form.jumpHost.trim() || undefined const systemSshConnectionReuse = form.systemSshConnectionReuse ? undefined : false - const target: Omit = { + const target: SshTargetCreateInput = { label: form.label.trim() || (username ? `${username}@${host}` : configHost), configHost, host, diff --git a/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.test.tsx new file mode 100644 index 00000000000..89ca240cc98 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.test.tsx @@ -0,0 +1,126 @@ +// @vitest-environment happy-dom + +/** + * The card decides whether to offer "Open automation", which means deciding + * which host to ask. It sits outside `components/automations/`, so a sweep of + * that folder misses it — and it inferred the automation's host from the + * workspace's, which is a different fact. + */ + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { AutomationWorkspaceProvenance } from '../../../../shared/worktree/types' +import type * as AutomationHostClient from '@/components/automations/automation-host-client' +import { WorktreeCardAutomationDetailSection } from './WorktreeCardAutomationDetailSection' + +const mocks = vi.hoisted(() => ({ + listAutomationsForTarget: vi.fn(), + listAutomationRunsForTarget: vi.fn() +})) + +vi.mock('@/components/automations/automation-host-client', async (importOriginal) => ({ + ...(await importOriginal()), + listAutomationsForTarget: mocks.listAutomationsForTarget, + listAutomationRunsForTarget: mocks.listAutomationRunsForTarget +})) + +function provenance( + overrides: Partial = {} +): AutomationWorkspaceProvenance { + return { + kind: 'created-by-automation', + automationId: 'a-1', + automationNameSnapshot: 'Nightly', + automationRunId: 'run-1', + automationRunTitleSnapshot: 'Nightly #1', + createdAt: 1, + executionTargetType: 'local', + executionTargetId: 'local', + projectId: 'repo-1', + ...overrides + } +} + +const roots: Root[] = [] + +async function render( + record: AutomationWorkspaceProvenance, + handlers: { + onOpenAutomation?: (event: React.MouseEvent) => void + onOpenAutomationRun?: (event: React.MouseEvent) => void + } = {} +): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render( + + + + ) + }) + return container +} + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + vi.clearAllMocks() + mocks.listAutomationsForTarget.mockResolvedValue([]) + mocks.listAutomationRunsForTarget.mockResolvedValue([]) +}) + +afterEach(async () => { + await act(async () => { + roots.splice(0).forEach((root) => root.unmount()) + }) + document.body.innerHTML = '' +}) + +describe('WorktreeCardAutomationDetailSection host resolution', () => { + it('asks the host the provenance recorded', async () => { + mocks.listAutomationsForTarget.mockResolvedValue([{ id: 'a-1' }]) + mocks.listAutomationRunsForTarget.mockResolvedValue([{ id: 'run-1' }]) + + await render(provenance({ hostId: 'runtime:gpu' })) + + expect(mocks.listAutomationsForTarget).toHaveBeenCalledWith({ + kind: 'environment', + environmentId: 'gpu' + }) + }) + + it('reports a miss as uncheckable when no host was ever recorded', async () => { + const container = await render(provenance()) + + // The read went to the desktop for want of anywhere else, so its silence is + // not evidence the automation was removed. + expect(container.textContent).toContain('Automation availability could not be checked.') + expect(container.textContent).not.toContain('Automation no longer available.') + }) + + it('still reports a miss as removed when the recorded host answered', async () => { + const container = await render(provenance({ hostId: 'local' })) + + expect(container.textContent).toContain('Automation no longer available.') + }) + + it('offers both affordances when a record with no recorded host is found anyway', async () => { + // The missing host degrades the *miss*, never the find: an automation the + // desktop positively returned is evidence, whatever the read was aimed by. + mocks.listAutomationsForTarget.mockResolvedValue([{ id: 'a-1' }]) + mocks.listAutomationRunsForTarget.mockResolvedValue([{ id: 'run-1' }]) + + const container = await render(provenance(), { + onOpenAutomation: vi.fn(), + onOpenAutomationRun: vi.fn() + }) + + expect(container.querySelector('[aria-label="Open automation"]')).not.toBeNull() + expect(container.querySelector('[aria-label="Open run"]')).not.toBeNull() + expect(container.textContent).not.toContain('could not be checked') + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.tsx b/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.tsx index 1ba6a45ce48..9f2811a54b9 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.tsx @@ -1,6 +1,5 @@ import React from 'react' import { CalendarClock, PlayCircle } from 'lucide-react' -import type { ExecutionHostId } from '../../../../shared/execution-host' import type { AutomationWorkspaceProvenance } from '../../../../shared/worktree/types' import { WorktreeCardDetailSection, @@ -16,7 +15,6 @@ import { type WorktreeCardAutomationDetailSectionProps = { provenance: AutomationWorkspaceProvenance - worktreeHostId?: ExecutionHostId onOpenAutomation?: (event: React.MouseEvent) => void onOpenAutomationRun?: (event: React.MouseEvent) => void } @@ -29,7 +27,6 @@ type AutomationProvenanceAvailability = export function WorktreeCardAutomationDetailSection({ provenance, - worktreeHostId, onOpenAutomation, onOpenAutomationRun }: WorktreeCardAutomationDetailSectionProps): React.JSX.Element { @@ -42,12 +39,17 @@ export function WorktreeCardAutomationDetailSection({ async function resolveAvailability(): Promise { setAvailability({ status: 'checking' }) try { - const target = getAutomationTargetFromHostId(provenance.hostId ?? worktreeHostId) + // Why not the workspace's host: the automation's host is recorded on the + // provenance, and an automation can land a workspace somewhere else + // entirely — substituting one for the other asks the wrong machine. + const target = getAutomationTargetFromHostId(provenance.hostId) const automations = await listAutomationsForTarget(target) const automation = automations.find((entry) => entry.id === provenance.automationId) if (!automation) { if (!cancelled) { - setAvailability({ status: 'automation-missing' }) + // With no recorded host this read went to the desktop on no evidence, + // so a miss here is "could not check", never "removed". + setAvailability({ status: provenance.hostId ? 'automation-missing' : 'unavailable' }) } return } @@ -69,7 +71,7 @@ export function WorktreeCardAutomationDetailSection({ return () => { cancelled = true } - }, [provenance.automationId, provenance.automationRunId, provenance.hostId, worktreeHostId]) + }, [provenance.automationId, provenance.automationRunId, provenance.hostId]) const canOpenAutomation = availability.status === 'available' const canOpenAutomationRun = availability.status === 'available' && availability.runAvailable diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx index 4767d7463cb..071d857aed2 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx @@ -62,7 +62,6 @@ export function WorktreeCardDetailsHover({ workspaceTitle, identityOrder = 'workspace-first', workspaceTitleRenameDisabled = false, - automationHostId, detailsAfter, openDelay = 250, closeDelay = 120, @@ -314,7 +313,6 @@ export function WorktreeCardDetailsHover({ {automationProvenance && ( ({ @@ -25,7 +25,7 @@ describe('individual SSH config host selection', () => { }) it('saves effective values while leaving all config identities authoritative', async () => { - let savedTarget: Omit | undefined + let savedTarget: SshTargetCreateInput | undefined const ssh = { resolveConfigHost: vi.fn().mockResolvedValue({ alias: 'prod', @@ -92,7 +92,7 @@ describe('manual SSH host label fallback', () => { }) it('labels a bare host with its hostname instead of an empty string', async () => { - let savedTarget: Omit | undefined + let savedTarget: SshTargetCreateInput | undefined const ssh = { resolveConfigHost: vi.fn(), listTargets: vi.fn().mockResolvedValue([]), diff --git a/src/renderer/src/components/sidebar/add-remote-host-ssh-actions.ts b/src/renderer/src/components/sidebar/add-remote-host-ssh-actions.ts index 6b585156749..e1f7e371f37 100644 --- a/src/renderer/src/components/sidebar/add-remote-host-ssh-actions.ts +++ b/src/renderer/src/components/sidebar/add-remote-host-ssh-actions.ts @@ -19,13 +19,14 @@ import type { SshConfigHostResolution, SshRepoReadoption, SshTarget, - SshTargetAddResult + SshTargetAddResult, + SshTargetCreateInput } from '../../../../shared/ssh-types' import { isDuplicateSshTargetAlias } from './ssh-target-duplicate' type SshApi = { listTargets: () => Promise - addTarget: (args: { target: Omit }) => Promise + addTarget: (args: { target: SshTargetCreateInput }) => Promise listConfigHosts: (args?: SshConfigHostListArgs) => Promise resolveConfigHost: (args: { alias: string }) => Promise importConfig: (args?: { reAdopt?: boolean }) => Promise<{ diff --git a/src/renderer/src/components/sidebar/worktree-card-meta-types.ts b/src/renderer/src/components/sidebar/worktree-card-meta-types.ts index 0be771e7534..54637f7a50a 100644 --- a/src/renderer/src/components/sidebar/worktree-card-meta-types.ts +++ b/src/renderer/src/components/sidebar/worktree-card-meta-types.ts @@ -1,4 +1,3 @@ -import type { ExecutionHostId } from '../../../../shared/execution-host' import type { IssueInfo } from '../../../../shared/github/pull-request-types' import type { AutomationWorkspaceProvenance, @@ -50,7 +49,6 @@ export type WorktreeCardDetailsHoverProps = WorktreeCardMetaBadgesProps & { workspaceTitle?: string identityOrder?: 'workspace-first' | 'branch-first' workspaceTitleRenameDisabled?: boolean - automationHostId?: ExecutionHostId detailsAfter?: React.ReactNode openDelay?: number closeDelay?: number diff --git a/src/renderer/src/components/sidebar/worktree-card-presentation.tsx b/src/renderer/src/components/sidebar/worktree-card-presentation.tsx index 72aa34321c2..de5c72f43b2 100644 --- a/src/renderer/src/components/sidebar/worktree-card-presentation.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-presentation.tsx @@ -153,7 +153,6 @@ export function buildWorktreeCardPresentation(card: WorktreeCardController) { comment={metaComment} automationProvenance={metaAutomationProvenance} cliProvenance={metaCliProvenance} - automationHostId={worktree.hostId} branchName={showBranchIdentityHover ? branch : undefined} workspaceTitle={worktree.displayName} identityOrder="branch-first" @@ -225,7 +224,6 @@ export function buildWorktreeCardPresentation(card: WorktreeCardController) { comment={metaComment} automationProvenance={metaAutomationProvenance} cliProvenance={metaCliProvenance} - automationHostId={worktree.hostId} detailsAfter={hasPorts ? : null} hoverControl={detailsHoverControl} onEditIssue={affiliateListMode ? undefined : handleEditIssue} diff --git a/src/renderer/src/components/sidebar/worktree-card-surface.tsx b/src/renderer/src/components/sidebar/worktree-card-surface.tsx index 7bc0abdcb89..a9c91c78131 100644 --- a/src/renderer/src/components/sidebar/worktree-card-surface.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-surface.tsx @@ -82,7 +82,6 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }): comment={hoverComment} automationProvenance={metaAutomationProvenance} cliProvenance={metaCliProvenance} - automationHostId={worktree.hostId} branchName={hoverBranchName} workspaceTitle={hoverWorkspaceTitle} workspaceTitleRenameDisabled={isDeleting || affiliateListMode} diff --git a/src/renderer/src/components/ui/select.tsx b/src/renderer/src/components/ui/select.tsx index c94cc4659fc..a27f230538d 100644 --- a/src/renderer/src/components/ui/select.tsx +++ b/src/renderer/src/components/ui/select.tsx @@ -14,6 +14,20 @@ function SelectValue({ ...props }: React.ComponentProps } +function SelectGroup({ ...props }: React.ComponentProps) { + return +} + +function SelectLabel({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + function SelectTrigger({ className, size = 'default', @@ -157,7 +171,9 @@ function SelectScrollDownButton({ export { Select, SelectContent, + SelectGroup, SelectItem, + SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, diff --git a/src/renderer/src/hooks/automations-changed-event-attribution.test.ts b/src/renderer/src/hooks/automations-changed-event-attribution.test.ts new file mode 100644 index 00000000000..8b9fc9c25f1 --- /dev/null +++ b/src/renderer/src/hooks/automations-changed-event-attribution.test.ts @@ -0,0 +1,153 @@ +/** + * The runtime path used to drop the publisher's identity here, which forced every + * change on any host to invalidate every host. These pin the attribution the hook + * must forward: which environment, and which selector when the host names one. + */ + +import { describe, expect, it, vi } from 'vitest' +import type * as ReactModule from 'react' +import type { RuntimeClientEvent } from '../../../shared/runtime-client-events' +import type { AutomationsChangedWindowDetail } from '@/lib/automations-changed-window-event' +import { AUTOMATIONS_CHANGED_EVENT } from '@/lib/automations-changed-window-event' + +type ChangedListener = (payload: AutomationsChangedWindowDetail) => void + +type Mounted = { + /** Call inside the test body: useIpcEvents runs its effects eagerly there. */ + useIpcEvents: () => void + emitRuntimeEvent: (environmentId: string, event: RuntimeClientEvent) => void + emitDesktopChange: (payload: AutomationsChangedWindowDetail) => void + details: () => AutomationsChangedWindowDetail[] +} + +async function mountIpcEvents(): Promise { + vi.resetModules() + vi.unstubAllGlobals() + let onRuntimeEvent: ((environmentId: string, event: RuntimeClientEvent) => void) | null = null + let onDesktopChange: ChangedListener | null = null + const dispatchEvent = vi.fn() + + vi.doMock('react', async () => { + const actual = await vi.importActual('react') + return { ...actual, useEffect: (effect: () => void | (() => void)) => void effect() } + }) + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => ({ + settings: { activeRuntimeEnvironmentId: null }, + repos: [], + worktreesByRepo: {}, + folderWorkspaces: [], + projectGroups: [], + tabsByWorktree: {}, + ptyIdsByTabId: {}, + fetchRepos: vi.fn(() => Promise.resolve()), + fetchProjectGroups: vi.fn(() => Promise.resolve()), + fetchFolderWorkspaces: vi.fn(() => Promise.resolve()) + }) + } + })) + vi.doMock('./runtime-client-events-sync', () => ({ + createRuntimeClientEventsSync: (deps: { + onEvent: (environmentId: string, event: RuntimeClientEvent) => void + }) => { + onRuntimeEvent = deps.onEvent + return { sync: vi.fn(), stop: vi.fn() } + } + })) + + // Why: the hook registers dozens of IPC namespaces at mount; stub the rest so a + // pending promise keeps unrelated paths dormant instead of failing on store setters. + const autoStubNamespace = new Proxy( + {}, + { + get: + () => + (...args: unknown[]) => + typeof args[0] === 'function' ? () => {} : new Promise(() => {}) + } + ) + const api = new Proxy( + { + automations: new Proxy( + { + onChanged: (listener: ChangedListener) => { + onDesktopChange = listener + return () => {} + } + } as Record, + { get: (target, prop: string) => target[prop] ?? autoStubNamespace[prop] } + ) + } as Record, + { get: (target, prop: string) => target[prop] ?? autoStubNamespace } + ) + vi.stubGlobal('window', { + api, + dispatchEvent, + addEventListener: () => {}, + removeEventListener: () => {} + }) + + const { useIpcEvents } = await import('./useIpcEvents') + + return { + useIpcEvents, + emitRuntimeEvent: (environmentId, event) => { + if (!onRuntimeEvent) { + throw new Error('Expected the runtime client-event handler to be registered') + } + onRuntimeEvent(environmentId, event) + }, + emitDesktopChange: (payload) => { + if (!onDesktopChange) { + throw new Error('Expected the desktop automations:changed listener to be registered') + } + onDesktopChange(payload) + }, + details: () => + dispatchEvent.mock.calls + .map(([event]) => event as CustomEvent) + .filter((event) => event.type === AUTOMATIONS_CHANGED_EVENT) + .map((event) => event.detail) + } +} + +describe('automationsChanged attribution through useIpcEvents', () => { + it('carries the environment and selector of a runtime-published change', async () => { + const { useIpcEvents, emitRuntimeEvent, details } = await mountIpcEvents() + useIpcEvents() + + emitRuntimeEvent('env-1', { + type: 'automationsChanged', + selector: { kind: 'ssh', targetId: 'target-1' }, + reason: 'definition' + }) + + expect(details()).toEqual([ + { + environmentId: 'env-1', + selector: { kind: 'ssh', targetId: 'target-1' }, + reason: 'definition' + } + ]) + }) + + it('still names the environment when an older host sends no selector', async () => { + const { useIpcEvents, emitRuntimeEvent, details } = await mountIpcEvents() + useIpcEvents() + + emitRuntimeEvent('env-2', { type: 'automationsChanged' }) + + expect(details()).toEqual([{ environmentId: 'env-2' }]) + }) + + it('forwards the desktop payload unchanged, so it stays the desktop authority', async () => { + const { useIpcEvents, emitDesktopChange, details } = await mountIpcEvents() + useIpcEvents() + + emitDesktopChange({ selector: { kind: 'self' }, reason: 'definition' }) + + expect(details()).toEqual([{ selector: { kind: 'self' }, reason: 'definition' }]) + }) +}) diff --git a/src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx b/src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx index 75c26fe627c..300c07a9280 100644 --- a/src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx +++ b/src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx @@ -142,6 +142,7 @@ describe('runtime and direct SSH wake isolation', () => { } ] ]), + targetGenerations: new Map(), targetLabels: new Map([['runtime-owned-target', 'runtime target']]), removedTargetLabels: new Map(), targetsHydrated: true diff --git a/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts b/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts index d2143333c2e..5b50e3d0520 100644 --- a/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts +++ b/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts @@ -23,6 +23,7 @@ export function buildWindowApi(args: { return { api: { repos: { onChanged: () => () => {} }, + automations: { onChanged: () => () => {} }, worktrees: { onChanged: () => () => {}, onBaseStatus: () => () => {}, diff --git a/src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts b/src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts index e094375ff99..eede0d6fa8e 100644 --- a/src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts +++ b/src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts @@ -142,6 +142,7 @@ export async function useIpcEventsForCloseRouting({ dispatchEvent: vi.fn(), api: { repos: { onChanged: () => () => {} }, + automations: { onChanged: () => () => {} }, worktrees: { onChanged: () => () => {}, onBaseStatus: () => () => {}, diff --git a/src/renderer/src/hooks/ipc-events-terminal-create-window-test-fixtures.ts b/src/renderer/src/hooks/ipc-events-terminal-create-window-test-fixtures.ts index 509fa52af90..20e954aa102 100644 --- a/src/renderer/src/hooks/ipc-events-terminal-create-window-test-fixtures.ts +++ b/src/renderer/src/hooks/ipc-events-terminal-create-window-test-fixtures.ts @@ -26,6 +26,7 @@ export function buildTerminalCreateWindow(args: { dispatchEvent, api: { repos: { onChanged: () => () => {} }, + automations: { onChanged: () => () => {} }, worktrees: { onChanged: () => () => {}, onBaseStatus: () => () => {}, diff --git a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts index f4bc7c6c007..5704f8e61f7 100644 --- a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts @@ -1,4 +1,5 @@ import { getTabIdsAwaitingHostHydrationRemount } from '@/lib/parked-terminal-host-hydration' +import { emitAutomationsChangedWindowEvent } from '@/lib/automations-changed-window-event' import { createBackgroundSleepingAgentWakeDispatcher } from '@/lib/wake-sleeping-agents-in-background' import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge' import { resetAgentHookCompletionNotificationCoordinators } from '../agent-hook-completion-notifications' @@ -54,6 +55,9 @@ export function installAppLifetimeIpcEvents( const backgroundWakeDispatcher = createBackgroundSleepingAgentWakeDispatcher() unsubs.push(backgroundWakeDispatcher.dispose) unsubs.push(attachMobileMarkdownBridge()) + unsubs.push( + window.api.automations.onChanged((payload) => emitAutomationsChangedWindowEvent(payload)) + ) const worktreeRuntime = createWorktreeEventRuntime(unsubs, isRuntimeEnvironmentActive) const unsubscribeRuntimeEnvironmentStore = registerRuntimeClientIpcBridge(unsubs, worktreeRuntime) diff --git a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts index a4218c65763..2b8d720ca63 100644 --- a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts @@ -1,5 +1,6 @@ import { applyHostWorktreeTerminalSleepState } from '@/components/terminal-pane/pty-shutdown-exit-deferral' import { dispatchTerminalSideEffectBatch } from '@/components/terminal-pane/terminal-side-effect-facts-handler' +import { emitAutomationsChangedWindowEvent } from '@/lib/automations-changed-window-event' import { applyNativeChatLaunchDraftResolved } from '@/runtime/native-chat-launch-draft-runtime-resolution' import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' import { @@ -95,6 +96,15 @@ export function registerRuntimeClientIpcBridge( runtimeProjectRefreshScheduler.request(environmentId) return } + if (event.type === 'automationsChanged') { + // Why: without the environment the subscriber cannot attribute the changed authority. + emitAutomationsChangedWindowEvent({ + environmentId, + ...(event.selector ? { selector: event.selector } : {}), + ...(event.reason ? { reason: event.reason } : {}) + }) + return + } if (event.type === 'sshStateChanged') { applyRuntimeEnvironmentSshStateChanged(environmentId, event.targetId, event.state, generation) return diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts index 557663a8a25..81fc23a5728 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts @@ -1,5 +1,8 @@ import type * as ReactModule from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AUTOMATIONS_CHANGED_EVENT } from '@/lib/automations-changed-window-event' + +const mockDispatchEvent = vi.fn() const mockLaunchAgentBackgroundSession = vi.fn() const mockLaunchWorktreeBackgroundTerminals = vi.fn() @@ -134,6 +137,11 @@ vi.mock('@/lib/agent-paste-draft', () => ({ submitPromptToAgentPty: mockSubmitPromptToAgentPty })) +// The reuse path reads run history over the local runtime target, not IPC. +vi.mock('@/components/automations/automation-host-client', () => ({ + listAutomationRunsForTarget: vi.fn().mockResolvedValue([]) +})) + vi.mock('@/lib/automation-session-reuse', () => ({ findReusableAutomationSession: mockFindReusableAutomationSession })) @@ -216,8 +224,7 @@ describe('useAutomationDispatchEvents setup launch', () => { onDispatchRequested: mockOnDispatchRequested, rendererReady: mockRendererReady, markDispatchResult: mockMarkDispatchResult, - runPrecheck: vi.fn(), - listRuns: vi.fn().mockResolvedValue([]) + runPrecheck: vi.fn() }, ssh: { needsPassphrasePrompt: mockSshNeedsPassphrasePrompt, @@ -225,10 +232,21 @@ describe('useAutomationDispatchEvents setup launch', () => { connect: mockSshConnect } }, - dispatchEvent: vi.fn() + dispatchEvent: mockDispatchEvent }) }) + // The scoped event main publishes with the write is the only one; a local emit + // here would name no host and re-invalidate every host in the catalog. + it('leaves the automationsChanged event to the host that owns the write', async () => { + await registerAndDispatch() + + expect(mockMarkDispatchResult).toHaveBeenCalled() + expect( + mockDispatchEvent.mock.calls.filter(([event]) => event?.type === AUTOMATIONS_CHANGED_EVENT) + ).toEqual([]) + }) + it('starts setup terminal launch without waiting before launching the automation agent', async () => { const order: string[] = [] let finishSetupLaunch: (() => void) | null = null diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.ts index a7741ec959e..1e46b315f28 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.ts @@ -25,16 +25,16 @@ import { translate } from '@/i18n/i18n' import { createBrowserUuid } from '@/lib/browser-uuid' import type { AutomationTerminalOwnership } from '@/lib/automation-terminal-ownership' import { getResolvedExecutionHostIdForWorktree } from '@/lib/resolved-worktree-execution-host' +import { listAutomationRunsForTarget } from '@/components/automations/automation-host-client' import { getRepoExecutionHostId, parseExecutionHostId, toSshExecutionHostId } from '../../../shared/execution-host' import { parseWorkspaceKey } from '../../../shared/workspace-scope' -import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection' import type { AgentStateHistoryEntry } from '../../../shared/agent-status-types' +import { resolveFolderWorkspaceHost } from '../../../shared/folder-workspace-execution-host' -const AUTOMATIONS_CHANGED_EVENT = 'orca:automations-changed' const activeReuseDispatchTabIds = new Set() function acquireReuseDispatchTab(tabId: string): (() => void) | null { @@ -91,8 +91,10 @@ export function useAutomationDispatchEvents(): void { const unsubscribe = window.api.automations.onDispatchRequested( async ({ automation, run, dispatchToken }) => { const markDispatchResult = async (result: AutomationDispatchResult): Promise => { + // Deliberately no local emit: the write publishes its own host-scoped event + // (automation-run-writer.ts) before this reply returns, and an unscoped one + // here would arrive second and invalidate every host in the catalog. await window.api.automations.markDispatchResult(result) - window.dispatchEvent(new Event(AUTOMATIONS_CHANGED_EVENT)) } const state = useAppStore.getState() const focusBeforeDispatch = { @@ -140,17 +142,22 @@ export function useAutomationDispatchEvents(): void { } try { - const folderWorkspaceConnectionId = + const folderWorkspaceHost = automationWorkspaceScope?.type === 'folder' - ? getFolderWorkspaceConnectionId(state, automationWorkspaceScope.folderWorkspaceId) + ? resolveFolderWorkspaceHost(state, automationWorkspaceScope.folderWorkspaceId) : null + const folderWorkspaceConnectionId = + folderWorkspaceHost?.kind === 'ssh' ? folderWorkspaceHost.targetId : null + // A workspace whose host does not resolve to one place is refused, not guessed at. + const folderWorkspaceHostUnresolved = + folderWorkspaceHost !== null && folderWorkspaceHost.kind === 'ambiguous' const folderWorkspaceHostId = - automationWorkspaceScope?.type === 'folder' && automationWorktree - ? folderWorkspaceConnectionId === undefined - ? null - : folderWorkspaceConnectionId - ? toSshExecutionHostId(folderWorkspaceConnectionId) - : getResolvedExecutionHostIdForWorktree(state, automationWorktree.id) + folderWorkspaceHost && automationWorktree + ? folderWorkspaceConnectionId + ? toSshExecutionHostId(folderWorkspaceConnectionId) + : folderWorkspaceHost.kind === 'local' + ? getResolvedExecutionHostIdForWorktree(state, automationWorktree.id) + : null : null const runHostId = parseExecutionHostId(automation.runContext?.hostId)?.id ?? getRepoExecutionHostId(repo) @@ -169,10 +176,15 @@ export function useAutomationDispatchEvents(): void { status: 'skipped_unavailable', workspaceId: automation.workspaceId, workspaceDisplayName: dispatchWorkspaceDisplayName, - error: translate( - 'auto.hooks.useAutomationDispatchEvents.3ad7d77f57', - 'The target workspace is on a different host than this automation run target.' - ) + error: folderWorkspaceHostUnresolved + ? translate( + 'auto.hooks.useAutomationDispatchEvents.workspaceHostUnresolved', + 'The target workspace spans more than one host, so this run has no single host to use.' + ) + : translate( + 'auto.hooks.useAutomationDispatchEvents.3ad7d77f57', + 'The target workspace is on a different host than this automation run target.' + ) }) return } @@ -502,7 +514,9 @@ export function useAutomationDispatchEvents(): void { agentId: automation.agentId, worktreeId: worktree.id, currentRunId: run.id, - runs: await window.api.automations.listRuns({ automationId: automation.id }), + // Why: the dispatch loop only ever executes for the desktop authority, + // so its history read addresses the local runtime explicitly. + runs: await listAutomationRunsForTarget({ kind: 'local' }, automation.id), state: useAppStore.getState() }) if (reusableSession) { diff --git a/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts b/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts index 2a3a612bd21..8e21584268c 100644 --- a/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts @@ -136,6 +136,7 @@ describe('useIpcEvents browser tab create routing', () => { clearTimeout: vi.fn(), api: { repos: { onChanged: () => () => {} }, + automations: { onChanged: () => () => {} }, worktrees: { onChanged: () => () => {}, onGitStatusMetadataChanged: () => () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts b/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts index ca6b6928343..3e9e8d96ac4 100644 --- a/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts @@ -108,6 +108,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { vi.stubGlobal('window', { api: { repos: { onChanged: () => () => {} }, + automations: { onChanged: () => () => {} }, worktrees: { onChanged: () => () => {}, onBaseStatus: () => () => {}, @@ -378,6 +379,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { vi.stubGlobal('window', { api: { repos: { onChanged: () => () => {} }, + automations: { onChanged: () => () => {} }, worktrees: { onChanged: (callback: (data: { repoId: string }) => void) => { localWorktreesOnChanged = callback diff --git a/src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts b/src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts index ea555032fdf..aa2b87f5b96 100644 --- a/src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts @@ -114,6 +114,7 @@ describe('useIpcEvents browser tab close routing', () => { dispatchEvent: vi.fn(), api: { repos: { onChanged: () => () => {} }, + automations: { onChanged: () => () => {} }, worktrees: { onChanged: () => () => {}, onBaseStatus: () => () => {}, @@ -339,6 +340,7 @@ describe('useIpcEvents browser tab close routing', () => { dispatchEvent: vi.fn(), api: { repos: { onChanged: () => () => {} }, + automations: { onChanged: () => () => {} }, worktrees: { onChanged: () => () => {}, onBaseStatus: () => () => {}, @@ -559,6 +561,7 @@ describe('useIpcEvents browser tab close routing', () => { dispatchEvent: vi.fn(), api: { repos: { onChanged: () => () => {} }, + automations: { onChanged: () => () => {} }, worktrees: { onChanged: () => () => {}, onBaseStatus: () => () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts index 7b7e8a9f65a..ea84f1b2811 100644 --- a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts @@ -8,6 +8,7 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [ 'agentStatus.onMigrationUnsupported', 'agentStatus.onMigrationUnsupportedClear', 'agentStatus.onSet', + 'automations.onChanged', 'browser.onActivateView', 'browser.onCertificateFailureChanged', 'browser.onGuestLoadFailed', @@ -100,6 +101,7 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [ const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [ 'ui.onMobileMarkdownRequest', + 'automations.onChanged', 'repos.onChanged', 'worktrees.onChanged', 'worktrees.onHeadIdentitiesChanged', @@ -371,8 +373,9 @@ describe('useIpcEvents App-lifetime lifecycle', () => { ) ).toEqual([ 'ui.onMobileMarkdownRequest', + 'automations.onChanged', 'runtimeEnvironments.subscribe', - ...EXPECTED_CALLBACK_REGISTRATION_SEQUENCE.slice(1) + ...EXPECTED_CALLBACK_REGISTRATION_SEQUENCE.slice(2) ]) const groupOrder = (names: readonly string[]): string[] => registrationOrder.filter((entry) => names.includes(entry)) diff --git a/src/renderer/src/hooks/useIpcEvents-rate-limit-hydration.test.ts b/src/renderer/src/hooks/useIpcEvents-rate-limit-hydration.test.ts index 1b5aece6e00..f1790583292 100644 --- a/src/renderer/src/hooks/useIpcEvents-rate-limit-hydration.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-rate-limit-hydration.test.ts @@ -148,6 +148,7 @@ describe('useIpcEvents rate-limit hydration', () => { clearTimeout: vi.fn(), api: { repos: makeEvents(), + automations: makeEvents(), worktrees: makeEvents(), keybindings: makeEvents(), settings: makeEvents(), diff --git a/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts b/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts index 8c37759a646..622e3909b4b 100644 --- a/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts @@ -124,6 +124,7 @@ describe('useIpcEvents updater integration', () => { vi.stubGlobal('window', { api: { repos: { onChanged: () => () => {} }, + automations: { onChanged: () => () => {} }, worktrees: { onChanged: () => () => {}, onBaseStatus: () => () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts b/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts index fedf0d43b38..1ff5554f59c 100644 --- a/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts @@ -89,6 +89,7 @@ describe('useIpcEvents updater integration', () => { vi.stubGlobal('window', { api: { repos: { onChanged: () => () => {} }, + automations: { onChanged: () => () => {} }, worktrees: { onChanged: () => () => {}, onBaseStatus: () => () => {}, @@ -289,6 +290,7 @@ describe('useIpcEvents updater integration', () => { vi.stubGlobal('window', { api: { repos: makeEvents(), + automations: makeEvents(), worktrees: makeEvents(), keybindings: makeEvents(), settings: makeEvents(), diff --git a/src/renderer/src/hooks/useIpcEvents-zoom-routing.test.ts b/src/renderer/src/hooks/useIpcEvents-zoom-routing.test.ts index 222faf6bff7..dc954e451a6 100644 --- a/src/renderer/src/hooks/useIpcEvents-zoom-routing.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-zoom-routing.test.ts @@ -196,6 +196,7 @@ describe('useIpcEvents zoom routing', () => { clearTimeout: vi.fn(), api: { repos: makeEvents(), + automations: makeEvents(), worktrees: makeEvents(), keybindings: makeEvents(), settings: makeEvents(), @@ -341,6 +342,7 @@ describe('useIpcEvents zoom routing', () => { clearTimeout: vi.fn(), api: { repos: makeEvents(), + automations: makeEvents(), worktrees: makeEvents(), keybindings: makeEvents(), settings: makeEvents(), diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 03d27a47769..a4743b65757 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -932,7 +932,8 @@ "59718b120b": "The target workspace is no longer available.", "16a21d6413": "SSH reconnect requires interactive credentials.", "386db94f3e": "The target project is no longer available.", - "3ad7d77f57": "The target workspace is on a different host than this automation run target." + "3ad7d77f57": "The target workspace is on a different host than this automation run target.", + "workspaceHostUnresolved": "The target workspace spans more than one host, so this run has no single host to use." }, "useComposerState": { "7eb3f44ff7": "Selected agent is disabled. Choose an enabled agent before creating.", @@ -15276,7 +15277,8 @@ "8faaa00726": "Run", "53fc5f07ab": "Run history", "a00e38d1a3": "n/a", - "fdb3caa8fb": "known" + "fdb3caa8fb": "known", + "historyUnavailable": "Run history could not be loaded for this automation." }, "AutomationRunPageFrame": { "40a511bed4": "Run context", @@ -15393,6 +15395,7 @@ "backToList": "All automations", "tableName": "Name", "tableProject": "Project", + "tableHost": "Host", "tableStatus": "Status", "tableActions": "Actions", "newAutomation": "New Automation", @@ -15561,6 +15564,109 @@ "AutomationListSortHeader": { "sortedAscending": "{{value0}}, sorted ascending", "sortedDescending": "{{value0}}, sorted descending" + }, + "hostSummary": { + "partialFailure": "{{failed}} of {{total}} hosts could not be loaded" + }, + "emptyState": { + "unknownHost": "this host", + "hostLoading": "Loading host…", + "hostLoadingDetail": "Waiting for {{hostLabel}} to report its automations.", + "hostError": "Automations could not be loaded from {{hostLabel}}", + "hostUnavailableDetail": "The host could not be reached, so its automations are unknown.", + "hostIncompatibleDetail": "This server is too old to scope automations by host.", + "hostStaleDetail": "The most recent refresh did not complete.", + "hostNotConnected": "{{hostLabel}} is not connected", + "hostNotConnectedDetail": "Connect to this host to see the automations stored for it.", + "hostEmpty": "No automations on {{hostLabel}}", + "searchNoMatch": "No automations match your search", + "filtersNoMatch": "No automations match your filters", + "allHostsEmpty": "No automations across loaded hosts" + }, + "hostNotice": { + "loading": "Loading host…", + "unavailable": "{{hostLabel}} could not be reached. Showing the automations last loaded from it.", + "ghost": "{{hostLabel}} was removed. Automations still assigned to it remain listed.", + "removed": "The selected host is no longer available. Showing all hosts." + }, + "hostPicker": { + "allHosts": "All hosts", + "loadingHost": "Loading host…" + }, + "ownerConflict": { + "dismiss": "Dismiss" + }, + "capturedOwner": { + "orphan": "This automation has no host to run on. Delete it, or re-add the host it belongs to.", + "unfenced": "This host cannot confirm which automation it is changing, so this automation is read-only here." + }, + "hostStatus": { + "authority": { + "loading": "Loading", + "loadingDescription": "Loading automations from this host.", + "fresh": "Up to date", + "freshDescription": "Automations loaded from this host.", + "refreshing": "Refreshing", + "refreshingDescription": "Checking this host for changes.", + "staleError": "Not refreshed", + "staleErrorDescription": "Showing the automations last loaded from this host. The most recent refresh did not complete.", + "unavailable": "Unreachable", + "unavailableDescription": "This host could not be reached, so its automations could not be loaded.", + "incompatible": "Update server", + "incompatibleDescription": "This server does not support host-scoped automation queries. Update it to load automations for this host." + }, + "execution": { + "connected": "Connected", + "connectedDescription": "This host is connected and can run automations.", + "connecting": "Connecting", + "connectingDescription": "Connecting to this host.", + "disconnected": "Not connected", + "disconnectedDescription": "Automations on this host cannot run until the connection is restored.", + "unavailable": "Unavailable", + "unavailableDescription": "This execution target is no longer available.", + "unknown": "Connection unknown", + "unknownDescription": "The connection state for this host has not loaded yet." + }, + "query": { + "legacyUnscopedDescription": "This server lists automations without host scoping, so they cannot be edited or run from here.", + "incompatible": "Update server", + "incompatibleDescription": "This server is too old to scope automations by host. Update it to manage automations here.", + "viewOnly": "View only", + "targetRemovedDescription": "This host was removed, so its automations can no longer be edited or run from here.", + "targetUnverifiedDescription": "This host has not been verified since its connection dropped, so its automations cannot be edited or run from here.", + "targetUnregisteredDescription": "This host has no registration record, so its automations cannot be edited or run from here." + }, + "action": { + "retry": "Retry", + "reconnect": "Reconnect", + "updateServer": "Update server" + } + }, + "ownerAction": { + "failed": "That automation action did not complete." + }, + "externalScope": { + "unknownManager": "This external automation is no longer listed for any host in view.", + "uncheckedHost": "External automation managers on {{hostLabel}} could not be checked.", + "uncheckedHosts": "External automation managers on {{count}} hosts could not be checked." + }, + "enablement": { + "paused": "Paused" + }, + "runHistory": { + "occurrencesWithLatest": "{{times}} times, most recently {{date}}", + "occurrences": "{{times}} times" + }, + "createDestination": { + "label": "Create on", + "placeholder": "Select a host", + "storedOn": "Stored and scheduled by {authority}.", + "unselected": "Choose the host this automation will be created on.", + "orphan": "Automations with no host cannot hold new ones. Choose a host to create this automation on.", + "unavailable": "That host cannot hold a new automation yet. Choose another host to create this one on.", + "stale": "{host} changed while this form was open. Choose it again before saving.", + "projectMismatch": "That project is not on {host}. Choose a project on that host, or another host.", + "noProjects": "No projects are set up on {host}. Add one there, or choose another host." } }, "agent": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 1f5f859af9e..62165ef4f48 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -13843,6 +13843,7 @@ "backToList": "Todas las automatizaciones", "tableName": "Nombre", "tableProject": "Proyecto", + "tableHost": "Host", "tableStatus": "Estado", "tableActions": "Acciones", "newAutomation": "Nueva automatización", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index d210ca6fa18..3f2f4d4491f 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -13843,6 +13843,7 @@ "backToList": "すべての自動化", "tableName": "名前", "tableProject": "プロジェクト", + "tableHost": "ホスト", "tableStatus": "ステータス", "tableActions": "操作", "newAutomation": "新しい自動化", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 300560c340a..f34e3b30662 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -13875,6 +13875,7 @@ "backToList": "모든 자동화", "tableName": "이름", "tableProject": "프로젝트", + "tableHost": "호스트", "tableStatus": "상태", "tableActions": "작업", "newAutomation": "새 자동화", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 288863841c6..1f41bf675dc 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -13901,6 +13901,7 @@ "backToList": "全部自动化", "tableName": "名称", "tableProject": "项目", + "tableHost": "主机", "tableStatus": "状态", "tableActions": "操作", "newAutomation": "新建自动化", diff --git a/src/renderer/src/lib/automations-changed-window-event.ts b/src/renderer/src/lib/automations-changed-window-event.ts new file mode 100644 index 00000000000..3e7be91b13b --- /dev/null +++ b/src/renderer/src/lib/automations-changed-window-event.ts @@ -0,0 +1,24 @@ +/** Renderer-internal fan-out for authority `automationsChanged` events, so the + * Automations page refreshes without every publisher knowing about the page. */ +import type { AutomationsChangedPayload } from '../../../shared/runtime-client-events' + +export const AUTOMATIONS_CHANGED_EVENT = 'orca:automations-changed' + +/** `environmentId` names the runtime authority that published; absent means desktop. */ +export type AutomationsChangedWindowDetail = AutomationsChangedPayload & { + environmentId?: string | null +} + +export function emitAutomationsChangedWindowEvent( + detail: AutomationsChangedWindowDetail = {} +): void { + window.dispatchEvent( + new CustomEvent(AUTOMATIONS_CHANGED_EVENT, { detail }) + ) +} + +/** Missing or malformed detail degrades to an unscoped desktop event, never a dropped one. */ +export function automationsChangedWindowDetail(event: Event): AutomationsChangedWindowDetail { + const detail = (event as CustomEvent).detail + return detail && typeof detail === 'object' ? (detail as AutomationsChangedWindowDetail) : {} +} diff --git a/src/renderer/src/lib/editor-file-operation-owner.test.ts b/src/renderer/src/lib/editor-file-operation-owner.test.ts index f319e6a84ea..43e257419db 100644 --- a/src/renderer/src/lib/editor-file-operation-owner.test.ts +++ b/src/renderer/src/lib/editor-file-operation-owner.test.ts @@ -140,6 +140,7 @@ describe('editor file operation owner', () => { 'hub-a', { targetsHydrated: true, + targetGenerations: new Map(), targetLabels: new Map([['private', 'private']]), removedTargetLabels: new Map(), connectionStates: new Map([ diff --git a/src/renderer/src/lib/folder-workspace-connection.ts b/src/renderer/src/lib/folder-workspace-connection.ts index 7edfbeedf32..b0840fc7e9f 100644 --- a/src/renderer/src/lib/folder-workspace-connection.ts +++ b/src/renderer/src/lib/folder-workspace-connection.ts @@ -1,112 +1,27 @@ -import type { FolderWorkspace } from '../../../shared/folder-workspace-types' -import type { ProjectGroup } from '../../../shared/project-group-types' import type { Repo } from '../../../shared/repo-types' -import { isPathInsideOrEqual } from '../../../shared/cross-platform-path' -import { getProjectGroupSubtreeIds } from '../../../shared/project-groups' -import { parseExecutionHostId } from '../../../shared/execution-host' +import { + findFolderWorkspaceCandidateRepos, + resolveFolderWorkspaceHost, + type FolderWorkspaceHostState +} from '../../../shared/folder-workspace-execution-host' -export type FolderWorkspaceConnectionState = { - folderWorkspaces: readonly FolderWorkspace[] - projectGroups: readonly ProjectGroup[] - repos: readonly Repo[] -} - -function getFolderScopeCandidateRepos(args: { - folderPath: string - projectGroupId: string - connectionId?: string | null - projectGroups: readonly ProjectGroup[] - repos: readonly Repo[] -}): Repo[] { - const groupIds = getProjectGroupSubtreeIds(args.projectGroups, args.projectGroupId) - const groupRepos = args.repos.filter( - (repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId) - ) - const pathRepos = args.repos.filter( - (repo) => - !(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) && - isPathInsideOrEqual(args.folderPath, repo.path) - ) - if (args.connectionId) { - return [ - ...groupRepos, - ...pathRepos.filter((repo) => (repo.connectionId ?? null) === args.connectionId) - ] - } - if (groupRepos.length === 0) { - return pathRepos - } - const groupConnectionIds = new Set(groupRepos.map((repo) => repo.connectionId ?? null)) - return [ - ...groupRepos, - ...pathRepos.filter((repo) => groupConnectionIds.has(repo.connectionId ?? null)) - ] -} +export type FolderWorkspaceConnectionState = FolderWorkspaceHostState export function getFolderWorkspaceCandidateRepos( state: FolderWorkspaceConnectionState, folderWorkspaceId: string ): Repo[] { - const workspace = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId) - if (!workspace) { - return [] - } - const group = state.projectGroups.find((entry) => entry.id === workspace.projectGroupId) - return getFolderScopeCandidateRepos({ - folderPath: workspace.folderPath, - projectGroupId: workspace.projectGroupId, - connectionId: workspace.connectionId ?? group?.connectionId ?? null, - projectGroups: state.projectGroups, - repos: state.repos - }) + return findFolderWorkspaceCandidateRepos(state, folderWorkspaceId) } +/** Legacy tri-state view of the shared resolution: `undefined` = gone or ambiguous. */ export function getFolderWorkspaceConnectionId( state: FolderWorkspaceConnectionState, folderWorkspaceId: string ): string | null | undefined { - const workspace = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId) - if (!workspace) { - return undefined + const host = resolveFolderWorkspaceHost(state, folderWorkspaceId) + if (host.kind === 'ssh') { + return host.targetId } - const explicitHost = parseExecutionHostId(workspace.executionHostId) - if (explicitHost) { - return explicitHost.kind === 'ssh' ? explicitHost.targetId : null - } - const scopeConnectionId = - workspace.connectionId ?? - state.projectGroups.find((entry) => entry.id === workspace.projectGroupId)?.connectionId ?? - null - const candidateRepos = getFolderWorkspaceCandidateRepos(state, folderWorkspaceId) - let hasLocalRepo = false - const connectionIds = new Set() - for (const repo of candidateRepos) { - if (repo.connectionId) { - connectionIds.add(repo.connectionId) - } else { - hasLocalRepo = true - } - } - if (scopeConnectionId) { - const hasDifferentSshConnection = [...connectionIds].some( - (connectionId) => connectionId !== scopeConnectionId - ) - if (hasLocalRepo || hasDifferentSshConnection) { - return undefined - } - return scopeConnectionId - } - if (candidateRepos.length === 0) { - return null - } - if (hasLocalRepo && connectionIds.size > 0) { - return undefined - } - if (connectionIds.size === 0) { - return null - } - if (connectionIds.size === 1) { - return [...connectionIds][0] - } - return undefined + return host.kind === 'local' ? null : undefined } diff --git a/src/renderer/src/lib/ssh-mutation-expectation.test.ts b/src/renderer/src/lib/ssh-mutation-expectation.test.ts index 97055f14475..9e7036cba8a 100644 --- a/src/renderer/src/lib/ssh-mutation-expectation.test.ts +++ b/src/renderer/src/lib/ssh-mutation-expectation.test.ts @@ -33,6 +33,7 @@ function stateWithGenerations(): Pick { expect(unsubscribe).toHaveBeenCalledTimes(1) }) + it('forwards automationsChanged and still drops event types it does not know', async () => { + let capturedOnResponse: ((response: unknown) => void) | undefined + const subscribe = vi.fn(async (_args, nextCallbacks) => { + capturedOnResponse = (nextCallbacks as { onResponse: (response: unknown) => void }).onResponse + return { unsubscribe: vi.fn(), sendBinary: vi.fn() } + }) + const onEvent = vi.fn() + const onError = vi.fn() + vi.stubGlobal('window', { api: { runtimeEnvironments: { subscribe } } }) + + await subscribeRuntimeClientEvents('env-1', onEvent, onError) + if (!capturedOnResponse) { + throw new Error('Expected subscription callbacks') + } + + capturedOnResponse({ ok: true, result: { type: 'automationsChanged', reason: 'run' } }) + // Why: the same allowlist is what makes this event additive for old clients. + capturedOnResponse({ ok: true, result: { type: 'automationsFromTheFuture' } }) + + expect(onEvent).toHaveBeenCalledTimes(1) + expect(onEvent).toHaveBeenCalledWith({ type: 'automationsChanged', reason: 'run' }) + expect(onError).not.toHaveBeenCalled() + }) + it('signals a replay-tagged response so event-derived state can resync after a reconnect', async () => { let capturedOnResponse: ((response: unknown) => void) | undefined const subscribe = vi.fn(async (_args, nextCallbacks) => { diff --git a/src/renderer/src/runtime/runtime-client-events.ts b/src/renderer/src/runtime/runtime-client-events.ts index 2631166379c..9e438e68e6a 100644 --- a/src/renderer/src/runtime/runtime-client-events.ts +++ b/src/renderer/src/runtime/runtime-client-events.ts @@ -89,6 +89,7 @@ function isRuntimeClientEvent( message.type === 'nativeChatLaunchDraftResolved' || message.type === 'terminalSideEffects' || message.type === 'sshStateChanged' || + message.type === 'automationsChanged' || message.type === 'linearLinkedIssueUpdated' || message.type === 'activateWorktree' || message.type === 'worktreeTerminalSleepState' diff --git a/src/renderer/src/runtime/runtime-environment-ssh-state.ts b/src/renderer/src/runtime/runtime-environment-ssh-state.ts index 333720a61a0..59f1a3256ae 100644 --- a/src/renderer/src/runtime/runtime-environment-ssh-state.ts +++ b/src/renderer/src/runtime/runtime-environment-ssh-state.ts @@ -3,7 +3,12 @@ import type { SshConnectionState, SshTargetSummary } from '../../../shared/ssh-t import { callRuntimeRpc } from './runtime-rpc-client' import { getEnvironmentSshStateGeneration } from '@/store/slices/runtime-environment-ssh' import { admitSshConnectionState } from '../../../shared/ssh-retained-payload-admission' -import { sshTargetLabelsEqual } from '@/store/slices/ssh-target-cleanup' +import { + collectSshTargetGenerations, + sshTargetGenerationsEqual, + sshTargetLabelsEqual +} from '@/store/slices/ssh-target-cleanup' +import { sanitizeSshTargetGeneration } from '../../../shared/ssh-target-generation' /** * Mirrors a remote Orca server's own SSH targets into that environment's @@ -33,7 +38,13 @@ async function fetchEnvironmentSshTargets(environmentId: string): Promise() + const nextTargetGenerations = collectSshTargetGenerations(targets) const metadataChanged = - !bucket?.targetsHydrated || !sshTargetLabelsEqual(bucket.targetLabels, targets) + !bucket?.targetsHydrated || + !sshTargetLabelsEqual(bucket.targetLabels, targets) || + !sshTargetGenerationsEqual(priorTargetGenerations, nextTargetGenerations) useAppStore.getState().setEnvironmentSshTargetsMetadata(environmentId, targets, generation) const priorTargetIds = new Set(bucket?.targetLabels.keys() ?? []) // Why: read states after the write — a resync racing this fetch can label a target that never had one read. @@ -136,7 +151,10 @@ async function runEnvironmentSshTargetMetadataRefresh(environmentId: string): Pr .getState() .sshStateByEnvironment.get(environmentId)?.connectionStates const needStateRead = targets.filter( - (target) => !priorTargetIds.has(target.id) || !knownStates?.has(target.id) + (target) => + !priorTargetIds.has(target.id) || + !knownStates?.has(target.id) || + priorTargetGenerations.get(target.id) !== nextTargetGenerations.get(target.id) ) if (!metadataChanged) { await fetchEnvironmentSshConnectionStates(environmentId, needStateRead, generation) diff --git a/src/renderer/src/runtime/runtime-rpc-result.ts b/src/renderer/src/runtime/runtime-rpc-result.ts index c168b3ab116..a0606cf76b6 100644 --- a/src/renderer/src/runtime/runtime-rpc-result.ts +++ b/src/renderer/src/runtime/runtime-rpc-result.ts @@ -1,5 +1,8 @@ import type { RuntimeRpcFailure, RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +// Why: the CLI reads the same flattened tokens, so the matcher lives in shared and both clients use one copy. +export { hasRuntimeRpcErrorCode } from '../../../shared/runtime-rpc-error-code' + export class RuntimeRpcCallError extends Error { readonly code: string readonly response: RuntimeRpcFailure @@ -12,55 +15,6 @@ export class RuntimeRpcCallError extends Error { } } -// Why: transports re-wrap the token into a longer message and drop the cause (Electron IPC's -// "Error invoking remote method 'x': Error: selector_not_found", relay envelope re-throws), so the -// token classifies only as the whole message or after a real message boundary (": " or a newline) — -// prose that merely trails off in the token must not reach the destructive forget-local fallback. -const CODE_TOKEN_BOUNDARY = /(?:: |\n)[ \t]*$/ - -function endsWithCodeToken(text: string, expectedCode: string): boolean { - const trimmed = text.trimEnd() - if (!trimmed.endsWith(expectedCode)) { - return false - } - const prefix = trimmed.slice(0, -expectedCode.length) - return prefix.trim() === '' || CODE_TOKEN_BOUNDARY.test(prefix) -} - -export function hasRuntimeRpcErrorCode(error: unknown, expectedCode: string): boolean { - const seen = new Set() - let current = error - while (!seen.has(current)) { - if (typeof current === 'string') { - return endsWithCodeToken(current, expectedCode) - } - if (!current || typeof current !== 'object') { - return false - } - seen.add(current) - const candidate = current as { - cause?: unknown - code?: unknown - message?: unknown - response?: { error?: { code?: unknown; message?: unknown } } - } - // Machine tokens are checked before messages so a subclass with a human-readable message still classifies by code. - if (candidate.code === expectedCode || candidate.response?.error?.code === expectedCode) { - return true - } - const messages = [candidate.message, candidate.response?.error?.message] - if ( - messages.some( - (message) => typeof message === 'string' && endsWithCodeToken(message, expectedCode) - ) - ) { - return true - } - current = candidate.cause - } - return false -} - export function unwrapRuntimeRpcResult(response: RuntimeRpcResponse): TResult { if (response.ok === false) { throw new RuntimeRpcCallError(response) diff --git a/src/renderer/src/store/slices/runtime-environment-ssh-selectors.ts b/src/renderer/src/store/slices/runtime-environment-ssh-selectors.ts new file mode 100644 index 00000000000..662f72d581f --- /dev/null +++ b/src/renderer/src/store/slices/runtime-environment-ssh-selectors.ts @@ -0,0 +1,90 @@ +import type { AppState } from '../types' +import type { SshConnectionStatus } from '../../../../shared/ssh-types' + +type RuntimeAwareSshReadState = Pick< + AppState, + | 'sshConnectionStates' + | 'sshTargetLabels' + | 'removedSshTargetLabels' + | 'sshTargetsHydrated' + | 'sshStateByEnvironment' +> & + Partial> + +function isEnvironmentReachable(state: RuntimeAwareSshReadState, environmentId: string): boolean { + return Boolean(state.runtimeStatusByEnvironmentId?.get(environmentId)?.status) +} + +export function selectRuntimeAwareSshStatus( + state: RuntimeAwareSshReadState, + environmentId: string | null, + targetId: string +): SshConnectionStatus | null { + if (environmentId === null) { + return state.sshConnectionStates.get(targetId)?.status ?? 'disconnected' + } + if (!isEnvironmentReachable(state, environmentId)) { + return null + } + const bucket = state.sshStateByEnvironment.get(environmentId) + if (!bucket?.targetsHydrated) { + return null + } + return bucket.connectionStates.get(targetId)?.status ?? null +} + +export function selectRuntimeAwareSshError( + state: RuntimeAwareSshReadState, + environmentId: string | null, + targetId: string +): string | null { + if (environmentId === null) { + return state.sshConnectionStates.get(targetId)?.error ?? null + } + if (!isEnvironmentReachable(state, environmentId)) { + return null + } + const bucket = state.sshStateByEnvironment.get(environmentId) + if (!bucket?.targetsHydrated) { + return null + } + return bucket.connectionStates.get(targetId)?.error ?? null +} + +export function selectRuntimeAwareSshTargetLabel( + state: RuntimeAwareSshReadState, + environmentId: string | null, + targetId: string +): string { + if (environmentId === null) { + return ( + state.sshTargetLabels.get(targetId) ?? state.removedSshTargetLabels.get(targetId) ?? targetId + ) + } + const bucket = state.sshStateByEnvironment.get(environmentId) + return bucket?.targetLabels.get(targetId) ?? bucket?.removedTargetLabels.get(targetId) ?? targetId +} + +export function selectRuntimeAwareSshTargetRemoved( + state: RuntimeAwareSshReadState, + environmentId: string | null, + targetId: string +): boolean { + if (environmentId === null) { + return ( + state.removedSshTargetLabels.has(targetId) || + (state.sshTargetsHydrated && !state.sshTargetLabels.has(targetId)) + ) + } + if (!isEnvironmentReachable(state, environmentId)) { + return false + } + const bucket = state.sshStateByEnvironment.get(environmentId) + if (!bucket) { + return false + } + return ( + bucket.removedTargetLabels.has(targetId) || + (bucket.targetsHydrated && !bucket.targetLabels.has(targetId)) + ) +} diff --git a/src/renderer/src/store/slices/runtime-environment-ssh.test.ts b/src/renderer/src/store/slices/runtime-environment-ssh.test.ts index acc807073cd..0cc4f0ab3f4 100644 --- a/src/renderer/src/store/slices/runtime-environment-ssh.test.ts +++ b/src/renderer/src/store/slices/runtime-environment-ssh.test.ts @@ -251,3 +251,83 @@ describe('runtime-environment-ssh slice', () => { }) }) }) + +describe('runtime-environment-ssh target registration generations', () => { + it('mirrors generations per target and leaves omitted ones absent', () => { + const store = createTestStore() + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, [ + { id: 'ssh-a', label: 'a-box', generation: 4 }, + { id: 'ssh-legacy', label: 'legacy-box' } + ]) + const bucket = store.getState().sshStateByEnvironment.get(ENV_A) + expect(bucket?.targetGenerations.get('ssh-a')).toBe(4) + expect(bucket?.targetGenerations.has('ssh-legacy')).toBe(false) + }) + + it('rejects generation values that could not have been issued', () => { + const store = createTestStore() + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, [ + { id: 'zero', label: 'z', generation: 0 }, + { id: 'fraction', label: 'f', generation: 1.5 }, + { id: 'negative', label: 'n', generation: -3 } + ]) + expect(store.getState().sshStateByEnvironment.get(ENV_A)?.targetGenerations.size).toBe(0) + }) + + it('updates the bucket when only a generation changed', () => { + const store = createTestStore() + store + .getState() + .setEnvironmentSshTargetsMetadata(ENV_A, [{ id: 'ssh-a', label: 'a-box', generation: 4 }]) + const before = store.getState().sshStateByEnvironment.get(ENV_A) + store + .getState() + .setEnvironmentSshTargetsMetadata(ENV_A, [{ id: 'ssh-a', label: 'a-box', generation: 5 }]) + const after = store.getState().sshStateByEnvironment.get(ENV_A) + expect(after).not.toBe(before) + expect(after?.targetGenerations.get('ssh-a')).toBe(5) + }) + + it('keeps the bucket reference when labels and generations are unchanged', () => { + const store = createTestStore() + const targets = [{ id: 'ssh-a', label: 'a-box', generation: 4 }] + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, targets) + const before = store.getState().sshStateByEnvironment.get(ENV_A) + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, [...targets]) + expect(store.getState().sshStateByEnvironment.get(ENV_A)).toBe(before) + }) + + it('clears generations when the environment SSH state goes stale, keeping labels', () => { + const store = createTestStore() + store + .getState() + .setEnvironmentSshTargetsMetadata(ENV_A, [{ id: 'ssh-a', label: 'a-box', generation: 4 }]) + store.getState().markEnvironmentSshStateStale(ENV_A) + const bucket = store.getState().sshStateByEnvironment.get(ENV_A) + expect(bucket?.targetGenerations.size).toBe(0) + expect(bucket?.targetLabels.get('ssh-a')).toBe('a-box') + expect(bucket?.targetsHydrated).toBe(false) + }) + + it('drops generations with the whole bucket on environment removal', () => { + const store = createTestStore() + store + .getState() + .setEnvironmentSshTargetsMetadata(ENV_A, [{ id: 'ssh-a', label: 'a-box', generation: 4 }]) + store.getState().removeEnvironmentSshState(ENV_A) + expect(store.getState().sshStateByEnvironment.has(ENV_A)).toBe(false) + }) + + it('respects the stale-generation guard', () => { + const store = createTestStore() + store + .getState() + .setEnvironmentSshTargetsMetadata(ENV_A, [{ id: 'ssh-a', label: 'a-box', generation: 4 }]) + store.getState().markEnvironmentSshStateStale(ENV_A) + // Generation 0 belongs to the pre-stale incarnation and must be ignored. + store + .getState() + .setEnvironmentSshTargetsMetadata(ENV_A, [{ id: 'ssh-a', label: 'a-box', generation: 9 }], 0) + expect(store.getState().sshStateByEnvironment.get(ENV_A)?.targetGenerations.size).toBe(0) + }) +}) diff --git a/src/renderer/src/store/slices/runtime-environment-ssh.ts b/src/renderer/src/store/slices/runtime-environment-ssh.ts index eb8880e677e..61c0ce65638 100644 --- a/src/renderer/src/store/slices/runtime-environment-ssh.ts +++ b/src/renderer/src/store/slices/runtime-environment-ssh.ts @@ -1,11 +1,14 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' -import type { - SshConnectionState, - SshConnectionStatus, - SshTargetSummary -} from '../../../../shared/ssh-types' +import type { SshConnectionState, SshTargetSummary } from '../../../../shared/ssh-types' +import { sanitizeSshTargetGeneration } from '../../../../shared/ssh-target-generation' import { sshConnectionStatesEqual, sshTargetLabelsEqual } from './ssh-target-cleanup' +export { + selectRuntimeAwareSshError, + selectRuntimeAwareSshStatus, + selectRuntimeAwareSshTargetLabel, + selectRuntimeAwareSshTargetRemoved +} from './runtime-environment-ssh-selectors' /** * SSH state of one remote Orca server's own SSH targets, mirrored on this @@ -16,6 +19,11 @@ import { sshConnectionStatesEqual, sshTargetLabelsEqual } from './ssh-target-cle export type RuntimeEnvironmentSshBucket = { connectionStates: Map targetLabels: Map + /** Durable SSH *registration* generation per target, for automation owner + * fencing. Absent for targets an older server omitted it for — never + * defaulted, because a guessed generation would fence against the wrong + * registration. Cleared with the rest of the bucket when state goes stale. */ + targetGenerations: Map removedTargetLabels: Map /** Mirrors the local `sshTargetsHydrated` positive-evidence rule: absence * from `targetLabels` only counts as target removal once a target list @@ -60,10 +68,29 @@ export type RuntimeEnvironmentSshSlice = { const EMPTY_BUCKET: RuntimeEnvironmentSshBucket = { connectionStates: new Map(), targetLabels: new Map(), + targetGenerations: new Map(), removedTargetLabels: new Map(), targetsHydrated: false } +function collectTargetGenerations(targets: SshTargetSummary[]): Map { + const generations = new Map() + for (const target of targets) { + const generation = sanitizeSshTargetGeneration(target.generation) + if (generation !== undefined) { + generations.set(target.id, generation) + } + } + return generations +} + +function targetGenerationsEqual(current: Map, next: Map): boolean { + return ( + current.size === next.size && + [...next].every(([targetId, generation]) => current.get(targetId) === generation) + ) +} + const stateGenerationByEnvironment = new Map() const targetConnectionGenerationByEnvironment = new Map() @@ -173,7 +200,11 @@ export const createRuntimeEnvironmentSshSlice: StateCreator< const connectionStates = new Map( Array.from(bucket.connectionStates).filter(([targetId]) => targetIds.has(targetId)) ) - if (sshTargetLabelsEqual(bucket.targetLabels, targets)) { + const targetGenerations = collectTargetGenerations(targets) + if ( + sshTargetLabelsEqual(bucket.targetLabels, targets) && + targetGenerationsEqual(bucket.targetGenerations, targetGenerations) + ) { // Why: an unchanged (even empty) list is still a successful load — the // hydration flag must flip on the first fetch of an empty target set. return bucket.targetsHydrated @@ -184,6 +215,7 @@ export const createRuntimeEnvironmentSshSlice: StateCreator< ...bucket, connectionStates, targetLabels: new Map(targets.map((target) => [target.id, target.label])), + targetGenerations, targetsHydrated: true }) }), @@ -207,14 +239,21 @@ export const createRuntimeEnvironmentSshSlice: StateCreator< set((s) => { advanceEnvironmentSshStateGeneration(environmentId) const bucket = s.sshStateByEnvironment.get(environmentId) - if (!bucket || (!bucket.targetsHydrated && bucket.connectionStates.size === 0)) { + if ( + !bucket || + (!bucket.targetsHydrated && + bucket.connectionStates.size === 0 && + bucket.targetGenerations.size === 0) + ) { return s } // Labels are kept so a re-hydrating overlay can still show a friendly // host name; hydration=false alone forces reads back to "unknown". + // Generations are dropped: fencing must never run on unverified state. return withBucket(s, environmentId, { ...bucket, connectionStates: new Map(), + targetGenerations: new Map(), targetsHydrated: false }) }), @@ -245,117 +284,3 @@ export const createRuntimeEnvironmentSshSlice: StateCreator< return changed ? { sshStateByEnvironment: next } : s }) }) - -type RuntimeAwareSshReadState = Pick< - AppState, - | 'sshConnectionStates' - | 'sshTargetLabels' - | 'removedSshTargetLabels' - | 'sshTargetsHydrated' - | 'sshStateByEnvironment' -> & - Partial> - -function isEnvironmentReachable(state: RuntimeAwareSshReadState, environmentId: string): boolean { - return Boolean(state.runtimeStatusByEnvironmentId?.get(environmentId)?.status) -} - -/** - * Reconnect-overlay status for an SSH target owned by `environmentId` (a - * remote Orca server) or by this machine (`environmentId === null`). - * - * Returns null when the state is unknown — environment unreachable or its - * bucket not hydrated — so callers show nothing rather than another machine's - * stale state. The runtime environment's own disconnected UI outranks any SSH - * overlay in that case. - */ -export function selectRuntimeAwareSshStatus( - state: RuntimeAwareSshReadState, - environmentId: string | null, - targetId: string -): SshConnectionStatus | null { - if (environmentId === null) { - return state.sshConnectionStates.get(targetId)?.status ?? 'disconnected' - } - if (!isEnvironmentReachable(state, environmentId)) { - return null - } - const bucket = state.sshStateByEnvironment.get(environmentId) - if (!bucket?.targetsHydrated) { - return null - } - return bucket.connectionStates.get(targetId)?.status ?? null -} - -/** - * The failure detail behind the status, when there is one. - * - * Mirrors selectRuntimeAwareSshStatus exactly so the pair cannot disagree about which source they - * read. Null whenever the status selector would return null, so a caller never pairs a detail with - * a status it did not come from. - */ -export function selectRuntimeAwareSshError( - state: RuntimeAwareSshReadState, - environmentId: string | null, - targetId: string -): string | null { - if (environmentId === null) { - return state.sshConnectionStates.get(targetId)?.error ?? null - } - if (!isEnvironmentReachable(state, environmentId)) { - return null - } - const bucket = state.sshStateByEnvironment.get(environmentId) - if (!bucket?.targetsHydrated) { - return null - } - return bucket.connectionStates.get(targetId)?.error ?? null -} - -export function selectRuntimeAwareSshTargetLabel( - state: RuntimeAwareSshReadState, - environmentId: string | null, - targetId: string -): string { - if (environmentId === null) { - return ( - state.sshTargetLabels.get(targetId) ?? - // Fall back to the removed target's last known label (ghost host) before - // the raw id, so a removed host shows its name instead of ssh--. - state.removedSshTargetLabels.get(targetId) ?? - targetId - ) - } - const bucket = state.sshStateByEnvironment.get(environmentId) - return bucket?.targetLabels.get(targetId) ?? bucket?.removedTargetLabels.get(targetId) ?? targetId -} - -/** - * True only on positive evidence that the target was removed on its owning - * host: a removal tombstone, or a hydrated target list that lacks the id. - * An unreachable environment or un-hydrated bucket never reports removal, so - * destructive "remove workspace" UI cannot be offered out of ignorance. - */ -export function selectRuntimeAwareSshTargetRemoved( - state: RuntimeAwareSshReadState, - environmentId: string | null, - targetId: string -): boolean { - if (environmentId === null) { - return ( - state.removedSshTargetLabels.has(targetId) || - (state.sshTargetsHydrated && !state.sshTargetLabels.has(targetId)) - ) - } - if (!isEnvironmentReachable(state, environmentId)) { - return false - } - const bucket = state.sshStateByEnvironment.get(environmentId) - if (!bucket) { - return false - } - return ( - bucket.removedTargetLabels.has(targetId) || - (bucket.targetsHydrated && !bucket.targetLabels.has(targetId)) - ) -} diff --git a/src/renderer/src/store/slices/ssh-target-cleanup.ts b/src/renderer/src/store/slices/ssh-target-cleanup.ts index 43707fa86d6..5750894936b 100644 --- a/src/renderer/src/store/slices/ssh-target-cleanup.ts +++ b/src/renderer/src/store/slices/ssh-target-cleanup.ts @@ -1,6 +1,7 @@ import type { AppState } from '../types' -import type { SshConnectionState, SshTarget } from '../../../../shared/ssh-types' +import type { SshConnectionState, SshTarget, SshTargetSummary } from '../../../../shared/ssh-types' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import { sanitizeSshTargetGeneration } from '../../../../shared/ssh-target-generation' import { resolveDirectSshTargetScope } from '../../lib/direct-ssh-target-scope' export function sshConnectionStatesEqual( @@ -29,6 +30,34 @@ export function sshTargetLabelsEqual( return targets.every((target) => labels.get(target.id) === target.label) } +/** + * Registration generations by target ID, dropping any the sanitizer rejects. + * + * An absent generation is left absent rather than defaulted: only a generation + * makes a target fenceable, and a guessed one would fence an automation against + * a registration that never existed. + */ +export function collectSshTargetGenerations(targets: SshTargetSummary[]): Map { + const generations = new Map() + for (const target of targets) { + const generation = sanitizeSshTargetGeneration(target.generation) + if (generation !== undefined) { + generations.set(target.id, generation) + } + } + return generations +} + +export function sshTargetGenerationsEqual( + current: Map, + next: Map +): boolean { + return ( + current.size === next.size && + [...next].every(([targetId, generation]) => current.get(targetId) === generation) + ) +} + function collectSshTargetTerminalTabIds(state: AppState, targetId: string): Set { const targetWorktreeIds = resolveDirectSshTargetScope({ targetId, @@ -206,6 +235,10 @@ export function buildRemovedSshTargetCleanupPatch( const removedConnectionState = nextConnectionStates.delete(targetId) const nextLabels = new Map(state.sshTargetLabels) const removedLabel = nextLabels.delete(targetId) + // Why: a lingering generation would keep a deleted registration fenceable, and + // the id is reissued fresh on re-add, so the old value can never become right. + const nextGenerations = new Map(state.sshTargetGenerations) + const removedGeneration = nextGenerations.delete(targetId) const nextHydrated = new Set(state.remoteWorkspaceHydratedTargetIds) const removedHydrated = nextHydrated.delete(targetId) const removedSyncStatus = Object.hasOwn(state.remoteWorkspaceSyncStatusByTargetId, targetId) @@ -225,6 +258,7 @@ export function buildRemovedSshTargetCleanupPatch( removedTransientClearBlock || removedConnectionState || removedLabel || + removedGeneration || removedHydrated || removedSyncStatus || removedPortForwards || @@ -247,6 +281,7 @@ export function buildRemovedSshTargetCleanupPatch( : {}), ...(removedConnectionState ? { sshConnectionStates: nextConnectionStates } : {}), ...(removedLabel ? { sshTargetLabels: nextLabels } : {}), + ...(removedGeneration ? { sshTargetGenerations: nextGenerations } : {}), ...(removedHydrated ? { remoteWorkspaceHydratedTargetIds: nextHydrated } : {}), ...(removedSyncStatus ? { remoteWorkspaceSyncStatusByTargetId: nextSyncStatus } : {}), ...(removedPortForwards ? { portForwardsByConnection: nextPortForwards } : {}), diff --git a/src/renderer/src/store/slices/ssh.test.ts b/src/renderer/src/store/slices/ssh.test.ts index 00ef5532140..6a24ba58954 100644 --- a/src/renderer/src/store/slices/ssh.test.ts +++ b/src/renderer/src/store/slices/ssh.test.ts @@ -330,6 +330,88 @@ describe('createSshSlice', () => { expect(store.getState().sshTargetLabels).toBe(labels) }) + it('mirrors registration generations so desktop SSH hosts are fenceable', () => { + const store = createTestStore() + + store.getState().setSshTargetsMetadata([ + { id: 'ssh-1', label: 'Remote', generation: 4 }, + { id: 'ssh-2', label: 'Other', generation: 7 } + ]) + + expect(store.getState().sshTargetGenerations.get('ssh-1')).toBe(4) + expect(store.getState().sshTargetGenerations.get('ssh-2')).toBe(7) + }) + + it('leaves an unusable generation absent rather than defaulting it', () => { + const store = createTestStore() + + store.getState().setSshTargetsMetadata([ + { id: 'ssh-legacy', label: 'Legacy' }, + { id: 'ssh-bad', label: 'Bad', generation: 0 }, + { id: 'ssh-ok', label: 'Ok', generation: 2 } + ]) + + // Why: a defaulted generation would fence an owner against a registration + // that never existed; absent degrades that one host to view-only instead. + const generations = store.getState().sshTargetGenerations + expect(generations.has('ssh-legacy')).toBe(false) + expect(generations.has('ssh-bad')).toBe(false) + expect(generations.get('ssh-ok')).toBe(2) + }) + + it('refreshes generations when a host is re-registered under a new id', () => { + const store = createTestStore() + store.getState().setSshTargetsMetadata([{ id: 'ssh-1', label: 'Remote', generation: 4 }]) + + // Remove-and-re-add issues a fresh id and a fresh generation; the mirror has + // to follow, or the re-added host stays view-only for the rest of the session. + store.getState().setSshTargetsMetadata([{ id: 'ssh-2', label: 'Remote', generation: 9 }]) + + expect(store.getState().sshTargetGenerations.has('ssh-1')).toBe(false) + expect(store.getState().sshTargetGenerations.get('ssh-2')).toBe(9) + }) + + it('refreshes a generation re-registered under the same id', () => { + const store = createTestStore() + store.getState().setSshTargetsMetadata([{ id: 'ssh-runtime-owned-r1', label: 'Env' }]) + store + .getState() + .setSshTargetsMetadata([{ id: 'ssh-runtime-owned-r1', label: 'Env', generation: 4 }]) + + // Runtime-owned targets keep a deterministic id, so a re-upsert after removal + // reissues a *new* generation under the *old* key — the one re-registration + // shape a key-set comparison would miss, freezing the host view-only. + store + .getState() + .setSshTargetsMetadata([{ id: 'ssh-runtime-owned-r1', label: 'Env', generation: 11 }]) + + expect(store.getState().sshTargetGenerations.get('ssh-runtime-owned-r1')).toBe(11) + }) + + it('adopts generations when a prior load mirrored the same labels without them', () => { + const store = createTestStore() + store.getState().setSshTargetsMetadata([{ id: 'ssh-1', label: 'Remote' }]) + + // Why: labels alone would satisfy the unchanged-list early return and freeze + // the mirror empty, leaving the host view-only against a known generation. + store.getState().setSshTargetsMetadata([{ id: 'ssh-1', label: 'Remote', generation: 3 }]) + + expect(store.getState().sshTargetGenerations.get('ssh-1')).toBe(3) + }) + + it('drops a removed target generation so a dead registration stays unfenceable', () => { + const store = createTestStore() + store.getState().setSshTargetsMetadata([ + { id: 'ssh-1', label: 'Remote', generation: 4 }, + { id: 'ssh-2', label: 'Other', generation: 7 } + ]) + + store.getState().clearRemovedSshTargetState('ssh-1') + + expect(store.getState().sshTargetGenerations.has('ssh-1')).toBe(false) + expect(store.getState().sshTargetGenerations.get('ssh-2')).toBe(7) + }) + it('marks targets hydrated on the first load, even when the list is empty', () => { const store = createTestStore() expect(store.getState().sshTargetsHydrated).toBe(false) diff --git a/src/renderer/src/store/slices/ssh.ts b/src/renderer/src/store/slices/ssh.ts index 9e15c81ea40..7f5ee70cd79 100644 --- a/src/renderer/src/store/slices/ssh.ts +++ b/src/renderer/src/store/slices/ssh.ts @@ -4,11 +4,13 @@ import type { SshConnectionState, PortForwardEntry, EnrichedDetectedPort, - SshTarget + SshTargetSummary } from '../../../../shared/ssh-types' import { buildRemovedSshTargetCleanupPatch, + collectSshTargetGenerations, sshConnectionStatesEqual, + sshTargetGenerationsEqual, sshTargetLabelsEqual } from './ssh-target-cleanup' @@ -33,6 +35,16 @@ export type SshSlice = { /** Maps target IDs to their user-facing labels. Populated during hydration * so components can look up labels without per-component IPC calls. */ sshTargetLabels: Map + /** + * Maps target IDs to their *registration* generations — the incarnation an + * owner is fenced against, not `SshConnectionState.connectionGeneration`. + * + * Mirrored here for the same reason as the labels, and mirrored by the runtime + * buckets already: only a generation makes a desktop SSH host fenceable, so a + * consumer without it degrades every such host to view-only. A target with no + * usable generation is simply absent — see `collectSshTargetGenerations`. + */ + sshTargetGenerations: Map /** Maps REMOVED target IDs to their last known label (from re-adoption * tombstones). Lets ghost-host UI show a friendly name instead of the raw id * for a workspace still pinned to a deleted target. */ @@ -60,7 +72,7 @@ export type SshSlice = { setSshConnectionState: (targetId: string, state: SshConnectionState) => void setSshTargetLabels: (labels: Map) => void setRemovedSshTargetLabels: (labels: Record) => void - setSshTargetsMetadata: (targets: Pick[]) => void + setSshTargetsMetadata: (targets: SshTargetSummary[]) => void clearRemovedSshTargetState: (targetId: string) => void markRemoteWorkspaceHydrated: (targetId: string) => void clearRemoteWorkspaceHydrated: (targetId: string) => void @@ -85,6 +97,7 @@ function advanceLocalSshTargetConnectionGeneration(targetId: string): void { export const createSshSlice: StateCreator = (set) => ({ sshConnectionStates: new Map(), sshTargetLabels: new Map(), + sshTargetGenerations: new Map(), removedSshTargetLabels: new Map(), sshTargetsHydrated: false, remoteWorkspaceHydratedTargetIds: new Set(), @@ -123,13 +136,20 @@ export const createSshSlice: StateCreator = (set) => set({ removedSshTargetLabels: new Map(Object.entries(labels)) }), setSshTargetsMetadata: (targets) => set((s) => { - if (sshTargetLabelsEqual(s.sshTargetLabels, targets)) { + const sshTargetGenerations = collectSshTargetGenerations(targets) + // Both maps gate the early return: a caller that first hydrated through a + // generation-less path would otherwise be frozen out by matching labels. + if ( + sshTargetLabelsEqual(s.sshTargetLabels, targets) && + sshTargetGenerationsEqual(s.sshTargetGenerations, sshTargetGenerations) + ) { // Why: an unchanged (even empty) list is still a successful load — the // hydration flag must flip on the first fetch of an empty target set. return s.sshTargetsHydrated ? s : { sshTargetsHydrated: true } } return { sshTargetLabels: new Map(targets.map((target) => [target.id, target.label])), + sshTargetGenerations, sshTargetsHydrated: true } }), diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 7dc72db2ba7..466e3db9724 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -89,6 +89,12 @@ import { normalizeBrowserPageZoomLevel } from '../../../../shared/browser-page-zoom' import { persistedUIValuesEqual } from '../../../../shared/persisted-ui-equality' +import { + ALL_AUTOMATION_HOSTS_FILTER, + parsePersistedAutomationHostFilter, + toPersistedAutomationHostFilter, + type AutomationHostFilter +} from '../../../../shared/automation-host-filter' import { normalizeExecutionHostOrder, normalizeExecutionHostScope, @@ -897,6 +903,9 @@ export type UISlice = { setVisibleWorkspaceHostIds: (ids: VisibleWorkspaceHostIds) => void workspaceHostOrder: WorkspaceHostOrder setWorkspaceHostOrder: (ids: WorkspaceHostOrder) => void + /** Automations page host filter, in stable form. Never written from an unhydrated catalog. */ + automationHostFilter: AutomationHostFilter + setAutomationHostFilter: (filter: AutomationHostFilter) => void manualRepoOrder: ManualRepoOrderEntry[] hideDefaultBranchWorkspace: boolean setHideDefaultBranchWorkspace: (v: boolean) => void @@ -2116,6 +2125,13 @@ export const createUISlice: StateCreator = (set, get) set({ workspaceHostOrder }) window.api.ui.set({ workspaceHostOrder }).catch(console.error) }, + automationHostFilter: ALL_AUTOMATION_HOSTS_FILTER, + setAutomationHostFilter: (filter) => { + window.api.ui + .set({ automationHostFilter: toPersistedAutomationHostFilter(filter) }) + .catch(console.error) + set({ automationHostFilter: filter }) + }, manualRepoOrder: [], hideDefaultBranchWorkspace: false, @@ -2530,6 +2546,8 @@ export const createUISlice: StateCreator = (set, get) workspaceHostScope: normalizeExecutionHostScope(ui.workspaceHostScope), visibleWorkspaceHostIds: normalizeHydratedVisibleWorkspaceHostIds(ui), workspaceHostOrder: normalizeExecutionHostOrder(ui.workspaceHostOrder), + // Why: a malformed or legacy filter value must degrade to All hosts, never throw during hydration. + automationHostFilter: parsePersistedAutomationHostFilter(ui.automationHostFilter), manualRepoOrder, // Why: apply the desktop-owned overlay immediately since UI state can arrive after a catalog or from another client. repos: orderedRepos, diff --git a/src/renderer/src/web/preload-api/web-preference-normalization.ts b/src/renderer/src/web/preload-api/web-preference-normalization.ts index e6dfc33e286..a5e99c34788 100644 --- a/src/renderer/src/web/preload-api/web-preference-normalization.ts +++ b/src/renderer/src/web/preload-api/web-preference-normalization.ts @@ -65,6 +65,7 @@ export function mergeHostWebUIState( // Why `satisfies Record<...>` rather than a `Pick<...>` annotation: every member is optional in // PersistedUIState, so Pick would accept a literal that silently skipped a newly added member. const pinned = { + automationHostFilter: local.automationHostFilter, hideWorkspacesFromOtherDevices: local.hideWorkspacesFromOtherDevices === true, manualRepoOrder: local.manualRepoOrder, workspaceHostOrder: local.workspaceHostOrder diff --git a/src/renderer/src/web/web-preload-api-ui.test.ts b/src/renderer/src/web/web-preload-api-ui.test.ts index ce5e9572f9b..6ec16121513 100644 --- a/src/renderer/src/web/web-preload-api-ui.test.ts +++ b/src/renderer/src/web/web-preload-api-ui.test.ts @@ -461,11 +461,13 @@ describe('web UI preload API', () => { // without wiring the web read seam fails here rather than shipping. The host sample differs from // the browser's for every field, so only the pin makes this pass. const browserLocalUiSamples: Record = { + automationHostFilter: { kind: 'host', hostKey: 'browser-local-host-key' }, hideWorkspacesFromOtherDevices: true, manualRepoOrder: [{ hostId: 'runtime:web-env-1', repoId: 'repo-b' }], workspaceHostOrder: ['runtime:web-env-1', 'local'] } const hostUiSamples: Record = { + automationHostFilter: { kind: 'all' }, hideWorkspacesFromOtherDevices: false, manualRepoOrder: [{ hostId: 'local', repoId: 'repo-a' }], workspaceHostOrder: ['local', 'ssh:box'] diff --git a/src/shared/automation-execution-target.test.ts b/src/shared/automation-execution-target.test.ts new file mode 100644 index 00000000000..aed37067980 --- /dev/null +++ b/src/shared/automation-execution-target.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from 'vitest' +import { + MISSING_AUTOMATION_PROJECT_ERROR, + applyAutomationExecutionTarget, + deriveAutomationExecutionTargetForCreate, + deriveAutomationExecutionTargetForUpdate, + type AutomationStoredExecutionTarget +} from './automation-execution-target' + +const storedSsh: AutomationStoredExecutionTarget = { + executionTargetType: 'ssh', + executionTargetId: 'ssh-1', + executionTargetGeneration: 4 +} + +/** SSH-owned through its folder workspace, not through its project. */ +const storedPinnedLocal: AutomationStoredExecutionTarget = { + executionTargetType: 'local', + executionTargetId: 'local', + executionTargetGeneration: 4 +} + +describe('deriveAutomationExecutionTargetForCreate', () => { + it('fails closed when the project does not resolve', () => { + expect(() => + deriveAutomationExecutionTargetForCreate({ repo: undefined, sshTargetGeneration: undefined }) + ).toThrow(MISSING_AUTOMATION_PROJECT_ERROR) + }) + + it('stores the current registration generation for an SSH project', () => { + expect( + deriveAutomationExecutionTargetForCreate({ + repo: { connectionId: 'ssh-1' }, + sshTargetGeneration: 4 + }) + ).toEqual({ + executionTargetType: 'ssh', + executionTargetId: 'ssh-1', + executionTargetGeneration: 4 + }) + }) + + it('omits the generation for a local project', () => { + expect( + deriveAutomationExecutionTargetForCreate({ + repo: { connectionId: null }, + sshTargetGeneration: 9 + }) + ).toEqual({ executionTargetType: 'local', executionTargetId: 'local' }) + }) + + it('captures the pinned registration for a local project whose workspace runs on SSH', () => { + expect( + deriveAutomationExecutionTargetForCreate({ + repo: { connectionId: null }, + sshTargetGeneration: undefined, + workspaceSshPin: { targetId: 'ssh-1', generation: 4 } + }) + ).toEqual({ + executionTargetType: 'local', + executionTargetId: 'local', + executionTargetGeneration: 4 + }) + }) +}) + +describe('deriveAutomationExecutionTargetForUpdate', () => { + it('preserves the stored SSH selector when the owning project was deleted', () => { + expect( + deriveAutomationExecutionTargetForUpdate({ + current: storedSsh, + repo: undefined, + selectorMoveRequested: false, + sshTargetGeneration: undefined + }) + ).toEqual(storedSsh) + }) + + // The repo resolving is not consent: this is the pause-an-orphan path. + it('preserves the stored selector when the resolved project points somewhere else', () => { + expect( + deriveAutomationExecutionTargetForUpdate({ + current: storedSsh, + repo: { connectionId: 'ssh-2' }, + selectorMoveRequested: false, + sshTargetGeneration: 11 + }) + ).toEqual(storedSsh) + }) + + it('keeps a captured generation the current target no longer has', () => { + expect( + deriveAutomationExecutionTargetForUpdate({ + current: storedSsh, + repo: { connectionId: 'ssh-1' }, + selectorMoveRequested: false, + sshTargetGeneration: undefined + }) + ).toEqual(storedSsh) + }) + + it('throws when a requested move has no resolvable project', () => { + expect(() => + deriveAutomationExecutionTargetForUpdate({ + current: storedSsh, + repo: undefined, + selectorMoveRequested: true, + sshTargetGeneration: undefined + }) + ).toThrow(MISSING_AUTOMATION_PROJECT_ERROR) + }) + + it('re-derives from a resolved project once a move is requested', () => { + expect( + deriveAutomationExecutionTargetForUpdate({ + current: storedSsh, + repo: { connectionId: 'ssh-2' }, + selectorMoveRequested: true, + sshTargetGeneration: 11 + }) + ).toEqual({ + executionTargetType: 'ssh', + executionTargetId: 'ssh-2', + executionTargetGeneration: 11 + }) + }) + + it('captures the new pin when the update re-points the record at another workspace', () => { + expect( + deriveAutomationExecutionTargetForUpdate({ + current: storedPinnedLocal, + repo: { connectionId: null }, + selectorMoveRequested: false, + sshTargetGeneration: undefined, + workspaceSshPin: { targetId: 'ssh-2', generation: 11 }, + workspaceSshPinMoved: true + }) + ).toEqual({ + executionTargetType: 'local', + executionTargetId: 'local', + executionTargetGeneration: 11 + }) + }) + + it('drops the capture when the update leaves the record unpinned', () => { + expect( + deriveAutomationExecutionTargetForUpdate({ + current: storedPinnedLocal, + repo: { connectionId: null }, + selectorMoveRequested: false, + sshTargetGeneration: undefined, + workspaceSshPin: undefined, + workspaceSshPinMoved: true + }) + ).toEqual({ executionTargetType: 'local', executionTargetId: 'local' }) + }) + + it('keeps the capture while the pin is unchanged', () => { + expect( + deriveAutomationExecutionTargetForUpdate({ + current: storedPinnedLocal, + repo: { connectionId: null }, + selectorMoveRequested: false, + sshTargetGeneration: undefined, + workspaceSshPin: { targetId: 'ssh-1', generation: 4 }, + workspaceSshPinMoved: false + }) + ).toEqual(storedPinnedLocal) + }) +}) + +describe('applyAutomationExecutionTarget', () => { + it('clears a stale generation when the record moves off SSH', () => { + const next = applyAutomationExecutionTarget( + { ...storedSsh, name: 'keep me' }, + { executionTargetType: 'local', executionTargetId: 'local' } + ) + expect(next).toEqual({ + executionTargetType: 'local', + executionTargetId: 'local', + name: 'keep me' + }) + expect(Object.hasOwn(next, 'executionTargetGeneration')).toBe(false) + }) + + // Losing it here is what made a same-id re-registration read as the host the user chose. + it('keeps the capture when the destination is still pinned to the same SSH target', () => { + const next = applyAutomationExecutionTarget( + { ...storedPinnedLocal, name: 'keep me' }, + { executionTargetType: 'local', executionTargetId: 'local' }, + { targetId: 'ssh-1', generation: undefined } + ) + expect(next.executionTargetGeneration).toBe(4) + }) +}) diff --git a/src/shared/automation-execution-target.ts b/src/shared/automation-execution-target.ts new file mode 100644 index 00000000000..09b8350f24c --- /dev/null +++ b/src/shared/automation-execution-target.ts @@ -0,0 +1,136 @@ +import type { Automation, AutomationExecutionTargetType } from './automations-types' +import type { AutomationWorkspaceSshPin } from './automation-workspace-pin' + +/** + * Decides which execution target an automation is stored against. + * + * The old rule (`repo?.connectionId ? 'ssh' : 'local'`) silently wrote the + * literal `local` whenever the repo lookup missed, so deleting a project turned + * an SSH automation into a local one that would then run on the wrong host. + * Both paths now fail closed instead: create refuses an unresolvable project, + * and update preserves the stored selector unless the caller explicitly asked + * to move the record to a project that does resolve. + * + * A `local` record whose folder workspace pins it to an SSH host captures that + * registration's generation too. Without a capture, `captured ?? current` always + * reads the *current* generation, so a host removed and re-added under the same + * id can never be told from the one the user chose. + */ + +export type AutomationExecutionTargetSelection = { + executionTargetType: AutomationExecutionTargetType + executionTargetId: string + executionTargetGeneration?: number +} + +export type AutomationExecutionTargetRepo = { connectionId?: string | null } + +export type AutomationStoredExecutionTarget = Pick< + Automation, + 'executionTargetType' | 'executionTargetId' | 'executionTargetGeneration' +> + +export const MISSING_AUTOMATION_PROJECT_ERROR = + "This automation's project no longer exists, so its host cannot be resolved." + +function withGeneration( + selection: Omit, + generation: number | undefined +): AutomationExecutionTargetSelection { + return { + ...selection, + ...(generation === undefined ? {} : { executionTargetGeneration: generation }) + } +} + +function selectionForRepo( + repo: AutomationExecutionTargetRepo, + sshTargetGeneration: number | undefined, + workspaceSshPin: AutomationWorkspaceSshPin | undefined +): AutomationExecutionTargetSelection { + const connectionId = repo.connectionId?.trim() + if (!connectionId) { + // A pinned workspace is the record's real host, so the local target captures its generation. + return withGeneration( + { executionTargetType: 'local', executionTargetId: 'local' }, + workspaceSshPin?.generation + ) + } + return withGeneration( + { executionTargetType: 'ssh', executionTargetId: connectionId }, + sshTargetGeneration + ) +} + +/** + * Apply a derived selection, clearing a stale generation when the record moves + * off SSH. + * + * `retainedWorkspaceSshPin` is the pin the record keeps across the move. Its + * capture is evidence of one registration incarnation and survives even when the + * pinned target is currently unregistered — dropping it there would let a later + * same-id registration read as the host the user chose. + */ +export function applyAutomationExecutionTarget( + record: T, + selection: AutomationExecutionTargetSelection, + retainedWorkspaceSshPin?: AutomationWorkspaceSshPin +): T { + const next = { ...record, ...selection } + if (selection.executionTargetGeneration === undefined && !retainedWorkspaceSshPin) { + delete next.executionTargetGeneration + } + return next +} + +/** Create fails closed: an automation with no resolvable project has no derivable host. */ +export function deriveAutomationExecutionTargetForCreate(input: { + repo: AutomationExecutionTargetRepo | undefined + sshTargetGeneration: number | undefined + workspaceSshPin?: AutomationWorkspaceSshPin +}): AutomationExecutionTargetSelection { + if (!input.repo) { + throw new Error(MISSING_AUTOMATION_PROJECT_ERROR) + } + return selectionForRepo(input.repo, input.sshTargetGeneration, input.workspaceSshPin) +} + +/** + * Update re-derives only when the caller asked to move the record, and then + * fails closed on an unresolvable project. + * + * A resolved repo is not a request to move: re-deriving from it re-adopts an + * orphan onto whatever registration that repo points at today — which pause, + * still enabled on orphans, would silently do — and strips a captured + * generation whenever the current target has none. + */ +export function deriveAutomationExecutionTargetForUpdate(input: { + current: AutomationStoredExecutionTarget + repo: AutomationExecutionTargetRepo | undefined + /** An explicit `projectId` or an explicit destination; nothing else moves a selector. */ + selectorMoveRequested: boolean + sshTargetGeneration: number | undefined + /** The SSH target the record's workspace pins it to once the update lands. */ + workspaceSshPin?: AutomationWorkspaceSshPin + /** The update re-points the record at a different pinned target, or off one entirely. */ + workspaceSshPinMoved?: boolean +}): AutomationExecutionTargetSelection { + if (!input.selectorMoveRequested) { + // Re-pointing the workspace moves a local record's host, so its capture moves with it. + const generation = + input.current.executionTargetType === 'local' && input.workspaceSshPinMoved + ? input.workspaceSshPin?.generation + : input.current.executionTargetGeneration + return withGeneration( + { + executionTargetType: input.current.executionTargetType, + executionTargetId: input.current.executionTargetId + }, + generation + ) + } + if (!input.repo) { + throw new Error(MISSING_AUTOMATION_PROJECT_ERROR) + } + return selectionForRepo(input.repo, input.sshTargetGeneration, input.workspaceSshPin) +} diff --git a/src/shared/automation-host-filter.test.ts b/src/shared/automation-host-filter.test.ts new file mode 100644 index 00000000000..f6b2b74d202 --- /dev/null +++ b/src/shared/automation-host-filter.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { hostStableKey } from './automation-owner-key' +import type { StableAutomationCatalogRef } from './automation-owner-ref' +import { + ALL_AUTOMATION_HOSTS_FILTER, + automationHostFilterStableKey, + automationHostFiltersEqual, + parsePersistedAutomationHostFilter, + toPersistedAutomationHostFilter, + type AutomationHostFilter +} from './automation-host-filter' + +const sshHost: StableAutomationCatalogRef = { + authority: { kind: 'runtime', environmentId: 'env-1' }, + selector: { kind: 'ssh', targetId: 'ssh:a' } +} +const sshFilter: AutomationHostFilter = { kind: 'host', host: sshHost } + +describe('persisted automation host filter', () => { + it('persists only the stable form as a canonical key', () => { + expect(toPersistedAutomationHostFilter(sshFilter)).toEqual({ + kind: 'host', + hostKey: hostStableKey(sshHost) + }) + expect(toPersistedAutomationHostFilter(ALL_AUTOMATION_HOSTS_FILTER)).toEqual({ kind: 'all' }) + }) + + it('round-trips every stable selector kind', () => { + const filters: AutomationHostFilter[] = [ + { kind: 'host', host: { authority: { kind: 'desktop' }, selector: { kind: 'self' } } }, + { kind: 'host', host: { authority: { kind: 'desktop' }, selector: { kind: 'orphan' } } }, + { + kind: 'host', + host: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 'a:b' } } + }, + sshFilter + ] + for (const filter of filters) { + expect(parsePersistedAutomationHostFilter(toPersistedAutomationHostFilter(filter))).toEqual( + filter + ) + } + }) + + it.each([ + undefined, + null, + 'host:desktop:self', + 42, + {}, + { kind: 'host' }, + { kind: 'host', hostKey: 5 }, + { kind: 'host', hostKey: '' }, + { kind: 'host', hostKey: 'desktop:self' }, + { kind: 'host', hostKey: 'host:runtime' }, + { kind: 'host', hostKey: 'host:desktop:bogus' }, + { kind: 'host', hostKey: 'host:desktop:ssh:%zz' }, + { kind: 'nonsense' } + ])('falls back to All hosts for malformed value %j without throwing', (value) => { + expect(parsePersistedAutomationHostFilter(value)).toEqual({ kind: 'all' }) + }) + + it('exposes the stable key and compares by it', () => { + expect(automationHostFilterStableKey(ALL_AUTOMATION_HOSTS_FILTER)).toBeNull() + expect(automationHostFilterStableKey(sshFilter)).toBe(hostStableKey(sshHost)) + expect(automationHostFiltersEqual(sshFilter, { kind: 'host', host: { ...sshHost } })).toBe(true) + expect(automationHostFiltersEqual(sshFilter, ALL_AUTOMATION_HOSTS_FILTER)).toBe(false) + }) + + it('does not collide equal target ids across authorities', () => { + const desktop: AutomationHostFilter = { + kind: 'host', + host: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 'shared' } } + } + const runtime: AutomationHostFilter = { + kind: 'host', + host: { + authority: { kind: 'runtime', environmentId: 'env-1' }, + selector: { kind: 'ssh', targetId: 'shared' } + } + } + expect(automationHostFiltersEqual(desktop, runtime)).toBe(false) + }) +}) diff --git a/src/shared/automation-host-filter.ts b/src/shared/automation-host-filter.ts new file mode 100644 index 00000000000..11cd6222001 --- /dev/null +++ b/src/shared/automation-host-filter.ts @@ -0,0 +1,52 @@ +import { hostStableKey, parseHostStableKey } from './automation-owner-key' +import type { StableAutomationCatalogRef } from './automation-owner-ref' + +/** + * The Automations page host filter. + * + * Only the *stable* form is ever persisted: a rename, reconnect, runtime + * re-pair, or SSH reconnect must not lose the user's selection, while data and + * actions captured from an old incarnation still have to be discarded. + */ +export type AutomationHostFilter = + | { kind: 'all' } + | { kind: 'host'; host: StableAutomationCatalogRef } + +/** On-disk shape: the canonical `hostStableKey` string plus a discriminator. */ +export type PersistedAutomationHostFilter = { kind: 'all' } | { kind: 'host'; hostKey: string } + +/** Shared identity so an unchanged hydration can return the same reference. */ +export const ALL_AUTOMATION_HOSTS_FILTER: AutomationHostFilter = Object.freeze({ kind: 'all' }) + +export function toPersistedAutomationHostFilter( + filter: AutomationHostFilter +): PersistedAutomationHostFilter { + return filter.kind === 'all' + ? { kind: 'all' } + : { kind: 'host', hostKey: hostStableKey(filter.host) } +} + +/** Never throws: a malformed, legacy, or hand-edited value degrades to All hosts. */ +export function parsePersistedAutomationHostFilter(value: unknown): AutomationHostFilter { + if (!value || typeof value !== 'object') { + return ALL_AUTOMATION_HOSTS_FILTER + } + const record = value as Partial> + if (record.kind !== 'host' || typeof record.hostKey !== 'string') { + return ALL_AUTOMATION_HOSTS_FILTER + } + const host = parseHostStableKey(record.hostKey) + return host ? { kind: 'host', host } : ALL_AUTOMATION_HOSTS_FILTER +} + +/** Stable key of the selected host, or null under All hosts. */ +export function automationHostFilterStableKey(filter: AutomationHostFilter): string | null { + return filter.kind === 'all' ? null : hostStableKey(filter.host) +} + +export function automationHostFiltersEqual( + a: AutomationHostFilter, + b: AutomationHostFilter +): boolean { + return automationHostFilterStableKey(a) === automationHostFilterStableKey(b) +} diff --git a/src/shared/automation-legacy-list-partition.test.ts b/src/shared/automation-legacy-list-partition.test.ts new file mode 100644 index 00000000000..635ff715633 --- /dev/null +++ b/src/shared/automation-legacy-list-partition.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import type { Automation } from './automations-types' +import { AUTOMATION_ORPHAN_ISSUES } from './automation-list-scope' +import { partitionLegacyAutomationList } from './automation-legacy-list-partition' + +function automation(overrides: Partial = {}): Automation { + return { + id: 'a1', + name: 'Nightly', + prompt: 'go', + precheck: null, + agentId: 'claude', + projectId: 'repo-local', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'new_per_run', + workspaceId: null, + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: 0, + enabled: true, + nextRunAt: 0, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 0, + updatedAt: 0, + ...overrides + } as Automation +} + +const context = { + repoConnectionId: (repoId: string) => + repoId === 'repo-local' ? null : repoId === 'repo-ssh' ? 'ssh-1' : undefined, + projectsAuthoritative: true +} + +/** What a client holds about a foreign authority: a mirror that may simply not be loaded. */ +const mirroredContext = { ...context, projectsAuthoritative: false } + +describe('partitionLegacyAutomationList', () => { + it('sends a local record with a resolvable local project to self', () => { + const [row] = partitionLegacyAutomationList([automation()], context).rows + expect(row?.selector).toEqual({ kind: 'self' }) + }) + + it('sends a valid SSH record to its target with no generation attached', () => { + const [row] = partitionLegacyAutomationList( + [automation({ executionTargetType: 'ssh', executionTargetId: 'ssh-1' })], + context + ).rows + expect(row?.selector).toEqual({ kind: 'ssh', targetId: 'ssh-1' }) + }) + + it('orphans unknown types, missing SSH ids, and unresolvable projects', () => { + const partition = partitionLegacyAutomationList( + [ + automation({ id: 'x', executionTargetType: 'wat' as Automation['executionTargetType'] }), + automation({ id: 'y', executionTargetType: 'ssh', executionTargetId: ' ' }), + automation({ id: 'z', projectId: 'repo-gone' }) + ], + context + ) + expect(partition.rows.map((row) => row.selector.kind)).toEqual(['orphan', 'orphan', 'orphan']) + expect(partition.rows[2]?.selector).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.projectMissing + }) + expect(partition.orphanCount).toBe(3) + }) + + it('orphans a record scheduled against a runtime instead of moving it', () => { + const partition = partitionLegacyAutomationList( + [automation({ schedulerOwner: 'remote_host_service' })], + context + ) + expect(partition.rows[0]?.selector).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.scheduledElsewhere + }) + }) + + it('reports an unresolved project as unverified when the table is only a mirror', () => { + const partition = partitionLegacyAutomationList( + [automation({ projectId: 'repo-gone' })], + mirroredContext + ) + expect(partition.rows[0]?.selector).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.projectUnverified + }) + }) + + it('still requires positive evidence for self against a mirrored table', () => { + const [row] = partitionLegacyAutomationList([automation()], mirroredContext).rows + expect(row?.selector).toEqual({ kind: 'self' }) + }) + + it('does not read a bare local value as self when the project is hosted over SSH', () => { + const partition = partitionLegacyAutomationList( + [automation({ projectId: 'repo-ssh' })], + context + ) + expect(partition.rows[0]?.selector).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.malformed + }) + }) +}) diff --git a/src/shared/automation-legacy-list-partition.ts b/src/shared/automation-legacy-list-partition.ts new file mode 100644 index 00000000000..d098cce2a28 --- /dev/null +++ b/src/shared/automation-legacy-list-partition.ts @@ -0,0 +1,90 @@ +/** + * Partitions the unscoped list an old authority returns. + * + * Nothing here guesses: Self needs positive evidence (a `local` record whose + * project resolves with no SSH connection), a valid SSH record goes to its + * target even when that target is only a ghost, and everything contradictory + * lands in the orphan bucket. Legacy rows carry no registration generation, so + * they are keyed by target ID alone and stay view-only until the authority + * advertises owner fencing. + * + * The project evidence must come from the answering authority's own registry. + * When the caller can only offer a mirror of it, an unresolved project is + * reported as unverified: not knowing where a record lives is not the same + * diagnosis as knowing its project was deleted. + */ + +import type { Automation } from './automations-types' +import { AUTOMATION_ORPHAN_ISSUES } from './automation-list-scope' +import { getAutomationRunRepoId } from './automation-run-identity' +import { parseExecutionHostId } from './execution-host' + +export type LegacyAutomationSelector = + | { kind: 'self' } + | { kind: 'ssh'; targetId: string } + | { kind: 'orphan'; issue: string } + +export type LegacyAutomationRow = { + automation: Automation + selector: LegacyAutomationSelector +} + +export type LegacyAutomationPartition = { + rows: LegacyAutomationRow[] + orphanCount: number +} + +export type LegacyAutomationPartitionContext = { + /** `undefined` = this table has no such repo; `null` = a local repo; a string = its SSH connection. */ + repoConnectionId: (repoId: string) => string | null | undefined + /** + * Whether the table above is the answering authority's own project registry. + * False for a table this client only mirrors, where a miss is an unread answer + * rather than a deleted project. + */ + projectsAuthoritative: boolean +} + +function classify( + automation: Automation, + context: LegacyAutomationPartitionContext +): LegacyAutomationSelector { + if ( + automation.schedulerOwner === 'remote_host_service' || + parseExecutionHostId(automation.runContext?.hostId)?.kind === 'runtime' + ) { + return { kind: 'orphan', issue: AUTOMATION_ORPHAN_ISSUES.scheduledElsewhere } + } + if (automation.executionTargetType === 'ssh') { + const targetId = automation.executionTargetId?.trim() + return targetId + ? { kind: 'ssh', targetId } + : { kind: 'orphan', issue: AUTOMATION_ORPHAN_ISSUES.malformed } + } + if (automation.executionTargetType !== 'local') { + return { kind: 'orphan', issue: AUTOMATION_ORPHAN_ISSUES.malformed } + } + const connectionId = context.repoConnectionId(getAutomationRunRepoId(automation)) + if (connectionId === undefined) { + return { + kind: 'orphan', + issue: context.projectsAuthoritative + ? AUTOMATION_ORPHAN_ISSUES.projectMissing + : AUTOMATION_ORPHAN_ISSUES.projectUnverified + } + } + return connectionId + ? { kind: 'orphan', issue: AUTOMATION_ORPHAN_ISSUES.malformed } + : { kind: 'self' } +} + +export function partitionLegacyAutomationList( + automations: readonly Automation[], + context: LegacyAutomationPartitionContext +): LegacyAutomationPartition { + const rows = automations.map((automation) => ({ + automation, + selector: classify(automation, context) + })) + return { rows, orphanCount: rows.filter((row) => row.selector.kind === 'orphan').length } +} diff --git a/src/shared/automation-list-response.test.ts b/src/shared/automation-list-response.test.ts new file mode 100644 index 00000000000..19f20cd23d4 --- /dev/null +++ b/src/shared/automation-list-response.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' +import { validateAutomationListResponse } from './automation-list-response' +import type { AutomationListScopeSelector } from './automation-list-scope' + +const SELF: AutomationListScopeSelector = { kind: 'self' } + +function response(overrides: Record = {}) { + return { + automations: [{ id: 'a1' }, { id: 'a2' }], + items: [ + { automationId: 'a1', selector: { kind: 'self' } }, + { automationId: 'a2', selector: { kind: 'self' } } + ], + orphanCount: 3, + ...overrides + } +} + +describe('validateAutomationListResponse', () => { + it('accepts a capable response and keeps the reported orphan count', () => { + const result = validateAutomationListResponse(response(), SELF) + expect(result).toMatchObject({ ok: true, invalidRows: 0 }) + expect(result.ok && result.result.orphanCount).toBe(3) + expect(result.ok && result.result.automations.map((entry) => entry.id)).toEqual(['a1', 'a2']) + }) + + // Why: the field is optional on the wire, so "not reported" must stay + // distinguishable from "authoritatively none" — a fabricated zero settles the + // authority's orphan bucket that nobody counted. + it('leaves an omitted orphan count unreported instead of committing zero', () => { + const raw = response() + delete (raw as { orphanCount?: number }).orphanCount + const result = validateAutomationListResponse(raw, SELF) + expect(result).toMatchObject({ ok: true, invalidRows: 0 }) + expect(result.ok && result.result.orphanCount).toBeUndefined() + expect(result.ok && 'orphanCount' in result.result).toBe(false) + }) + + it('reports a legacy-shaped payload as unsupported host scope', () => { + const result = validateAutomationListResponse({ automations: [{ id: 'a1' }] }, SELF) + expect(result).toMatchObject({ ok: false, error: { code: 'unsupported_host_scope' } }) + }) + + it('rejects a malformed top-level response', () => { + expect(validateAutomationListResponse(null, SELF)).toMatchObject({ + ok: false, + error: { code: 'invalid_response' } + }) + expect(validateAutomationListResponse({ automations: 'nope' }, SELF)).toMatchObject({ + ok: false, + error: { code: 'invalid_response' } + }) + expect(validateAutomationListResponse({ automations: [], items: {} }, SELF)).toMatchObject({ + ok: false, + error: { code: 'invalid_response' } + }) + expect(validateAutomationListResponse(response({ orphanCount: -1 }), SELF)).toMatchObject({ + ok: false, + error: { code: 'invalid_response' } + }) + }) + + it('drops one unmatched row without hiding the rest of the host', () => { + const result = validateAutomationListResponse( + response({ items: [{ automationId: 'a1', selector: { kind: 'self' } }] }), + SELF + ) + expect(result.ok && result.result.automations.map((entry) => entry.id)).toEqual(['a1']) + expect(result.ok && result.invalidRows).toBe(1) + }) + + it('drops both rows when one automation id carries two metadata items', () => { + const result = validateAutomationListResponse( + response({ + items: [ + { automationId: 'a1', selector: { kind: 'self' } }, + { automationId: 'a1', selector: { kind: 'ssh', targetId: 't', targetGeneration: 1 } }, + { automationId: 'a2', selector: { kind: 'self' } } + ] + }), + SELF + ) + expect(result.ok && result.result.automations.map((entry) => entry.id)).toEqual(['a2']) + expect(result.ok && result.invalidRows).toBe(2) + }) + + it('never reclassifies a row whose selector does not match the request', () => { + const result = validateAutomationListResponse( + response({ + items: [ + { automationId: 'a1', selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 2 } }, + { automationId: 'a2', selector: { kind: 'self' } } + ] + }), + SELF + ) + expect(result.ok && result.result.automations.map((entry) => entry.id)).toEqual(['a2']) + expect(result.ok && result.result.items[0]?.selector).toEqual({ kind: 'self' }) + expect(result.ok && result.invalidRows).toBe(1) + }) + + it('requires an SSH item to carry the requested generation', () => { + const scope: AutomationListScopeSelector = { + kind: 'ssh', + targetId: 'ssh-1', + expectedTargetGeneration: 4 + } + const stale = validateAutomationListResponse( + response({ + items: [ + { automationId: 'a1', selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 3 } }, + { automationId: 'a2', selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 4 } } + ] + }), + scope + ) + expect(stale.ok && stale.result.automations.map((entry) => entry.id)).toEqual(['a2']) + expect(stale.ok && stale.invalidRows).toBe(1) + }) + + it('drops rows whose metadata is structurally unusable', () => { + const result = validateAutomationListResponse( + response({ + automations: [{ id: 'a1' }, { name: 'no id' }], + items: [ + { automationId: 'a1', selector: { kind: 'ssh', targetId: 'ssh-1' } }, + { selector: { kind: 'self' } } + ] + }), + SELF + ) + expect(result.ok && result.result.automations).toEqual([]) + expect(result.ok && result.invalidRows).toBe(4) + }) + + it('keeps a row whose usage summary is unusable but blanks the summary', () => { + const result = validateAutomationListResponse( + response({ + automations: [{ id: 'a1' }], + items: [{ automationId: 'a1', selector: { kind: 'self' }, usageSummary: { knownRuns: 1 } }] + }), + SELF + ) + expect(result.ok && result.result.items[0]?.usageSummary).toBeNull() + expect(result.ok && result.invalidRows).toBe(0) + }) +}) diff --git a/src/shared/automation-list-response.ts b/src/shared/automation-list-response.ts new file mode 100644 index 00000000000..57475d4e48a --- /dev/null +++ b/src/shared/automation-list-response.ts @@ -0,0 +1,177 @@ +/** + * Boundary validation for an `automation.list` response. + * + * A client validates rather than casts: a host that answers a scoped request + * with the legacy `{ automations }` shape is reporting the whole authority, and + * committing that as one host's rows would attribute other hosts' automations to + * the selected one. Individual malformed rows are dropped and counted so one bad + * row neither hides a host nor produces an unqualified row, and a returned + * selector that does not match the request is never reclassified into Self. + */ + +import type { Automation } from './automations-types' +import type { AutomationUsageSummary } from './automation-usage-summary' +import { + automationSelectorMatchesScope, + type AutomationListItem, + type AutomationListItemSelector, + type AutomationListResult, + type AutomationListScopeSelector +} from './automation-list-scope' + +export type AutomationListResponseFailure = { + code: 'unsupported_host_scope' | 'invalid_response' + message: string +} + +export type AutomationListResponseValidation = + | { ok: true; result: AutomationListResult; invalidRows: number } + | { ok: false; error: AutomationListResponseFailure } + +const UNSUPPORTED_HOST_SCOPE_MESSAGE = + 'This host does not support per-host automation lists. Update the Orca server to filter by host.' +const INVALID_RESPONSE_MESSAGE = 'This host returned an automation list Orca could not read.' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function parseAutomation(value: unknown): Automation | null { + return isRecord(value) && typeof value.id === 'string' && value.id.length > 0 + ? (value as Automation) + : null +} + +const USAGE_SUMMARY_FIELDS = [ + 'knownRuns', + 'unavailableRuns', + 'inputTokens', + 'outputTokens', + 'cacheTokens', + 'reasoningOutputTokens', + 'totalTokens' +] as const + +function parseUsageSummary(value: unknown): AutomationUsageSummary | null { + if (!isRecord(value)) { + return null + } + if (!USAGE_SUMMARY_FIELDS.every((field) => typeof value[field] === 'number')) { + return null + } + const cost = value.estimatedCostUsd + return cost === null || typeof cost === 'number' ? (value as AutomationUsageSummary) : null +} + +function parseSelector(value: unknown): AutomationListItemSelector | null { + if (!isRecord(value)) { + return null + } + if (value.kind === 'self') { + return { kind: 'self' } + } + if (value.kind === 'orphan') { + return { kind: 'orphan', issue: typeof value.issue === 'string' ? value.issue : '' } + } + if ( + value.kind !== 'ssh' || + typeof value.targetId !== 'string' || + value.targetId.length === 0 || + !Number.isSafeInteger(value.targetGeneration) + ) { + return null + } + return { + kind: 'ssh', + targetId: value.targetId, + targetGeneration: value.targetGeneration as number + } +} + +type ParsedItems = { byId: Map; invalidRows: number } + +function parseItems(raw: readonly unknown[]): ParsedItems { + const byId = new Map() + const duplicated = new Set() + let invalidRows = 0 + for (const entry of raw) { + const selector = isRecord(entry) ? parseSelector(entry.selector) : null + const automationId = + isRecord(entry) && typeof entry.automationId === 'string' && entry.automationId.length > 0 + ? entry.automationId + : null + if (!selector || !automationId) { + invalidRows++ + continue + } + if (byId.has(automationId) || duplicated.has(automationId)) { + // A second item for one ID makes both unusable: neither can be trusted as the row's owner. + byId.delete(automationId) + duplicated.add(automationId) + invalidRows++ + continue + } + byId.set(automationId, { + automationId, + selector, + usageSummary: parseUsageSummary((entry as { usageSummary?: unknown }).usageSummary) + }) + } + return { byId, invalidRows } +} + +function failure(code: AutomationListResponseFailure['code']): AutomationListResponseValidation { + return { + ok: false, + error: { + code, + message: + code === 'unsupported_host_scope' + ? UNSUPPORTED_HOST_SCOPE_MESSAGE + : INVALID_RESPONSE_MESSAGE + } + } +} + +export function validateAutomationListResponse( + raw: unknown, + scope: AutomationListScopeSelector +): AutomationListResponseValidation { + if (!isRecord(raw) || !Array.isArray(raw.automations)) { + return failure('invalid_response') + } + if (raw.items === undefined || raw.items === null) { + return failure('unsupported_host_scope') + } + if (!Array.isArray(raw.items)) { + return failure('invalid_response') + } + const orphanCount = typeof raw.orphanCount === 'number' ? raw.orphanCount : undefined + if ( + raw.orphanCount !== undefined && + (orphanCount === undefined || !Number.isSafeInteger(orphanCount) || orphanCount < 0) + ) { + return failure('invalid_response') + } + const parsedItems = parseItems(raw.items) + let invalidRows = parsedItems.invalidRows + const automations: Automation[] = [] + const items: AutomationListItem[] = [] + for (const entry of raw.automations) { + const automation = parseAutomation(entry) + const item = automation ? parsedItems.byId.get(automation.id) : undefined + if (!automation || !item || !automationSelectorMatchesScope(item.selector, scope)) { + invalidRows++ + continue + } + automations.push(automation) + items.push(item) + } + return { + ok: true, + // Omitted rather than defaulted: a host is allowed not to report, and "not + // reported" must not read downstream as "authoritatively none". + result: { automations, items, ...(orphanCount === undefined ? {} : { orphanCount }) }, + invalidRows + } +} diff --git a/src/shared/automation-list-scope.test.ts b/src/shared/automation-list-scope.test.ts new file mode 100644 index 00000000000..740ce191638 --- /dev/null +++ b/src/shared/automation-list-scope.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it } from 'vitest' +import type { Automation } from './automations-types' +import { + AUTOMATION_ORPHAN_ISSUES, + automationChangePublications, + automationSelectorMatchesScope, + projectAutomationList, + projectAutomationSelector, + toAutomationChangeSelector, + type AutomationProjectionContext +} from './automation-list-scope' +import { + AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, + RUNTIME_CAPABILITIES +} from './protocol-version' + +function automation(overrides: Partial = {}): Automation { + return { + id: 'a1', + name: 'Nightly', + prompt: 'go', + precheck: null, + agentId: 'claude', + projectId: 'repo-1', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'new_per_run', + workspaceId: null, + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: 0, + enabled: true, + nextRunAt: 0, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 0, + updatedAt: 0, + ...overrides + } as Automation +} + +function context( + overrides: Partial = {} +): AutomationProjectionContext { + return { + sshTargetGeneration: (targetId) => (targetId === 'ssh-1' ? 7 : undefined), + repoConnectionId: (repoId) => + repoId === 'repo-1' ? null : repoId === 'repo-ssh' ? 'ssh-1' : undefined, + ...overrides + } +} + +describe('capability advertisement', () => { + it('registers both automation capabilities so getStatus() advertises them', () => { + expect(RUNTIME_CAPABILITIES).toContain(AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY) + expect(RUNTIME_CAPABILITIES).toContain(AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY) + }) +}) + +describe('projectAutomationSelector', () => { + it('qualifies a local record whose project resolves without a connection as self', () => { + expect(projectAutomationSelector(automation(), context())).toEqual({ kind: 'self' }) + }) + + it('refuses to read a bare local value as self when the project is gone', () => { + expect(projectAutomationSelector(automation({ projectId: 'missing' }), context())).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.projectMissing + }) + }) + + it('treats a local record whose project points at an SSH connection as malformed', () => { + expect(projectAutomationSelector(automation({ projectId: 'repo-ssh' }), context())).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.malformed + }) + }) + + it('pins an SSH record to its registration generation', () => { + const record = automation({ + executionTargetType: 'ssh', + executionTargetId: 'ssh-1', + executionTargetGeneration: 7, + projectId: 'repo-ssh' + }) + expect(projectAutomationSelector(record, context())).toEqual({ + kind: 'ssh', + targetId: 'ssh-1', + targetGeneration: 7 + }) + }) + + it('orphans an SSH record whose target was removed and re-added', () => { + const record = automation({ + executionTargetType: 'ssh', + executionTargetId: 'ssh-1', + executionTargetGeneration: 5 + }) + expect(projectAutomationSelector(record, context())).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.targetReplaced + }) + }) + + it('orphans an SSH record whose target is gone', () => { + const record = automation({ executionTargetType: 'ssh', executionTargetId: 'ssh-gone' }) + expect(projectAutomationSelector(record, context())).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.targetMissing + }) + }) + + it('never assigns a runtime-scheduled record to another authority', () => { + const record = automation({ schedulerOwner: 'remote_host_service' }) + expect(projectAutomationSelector(record, context())).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.scheduledElsewhere + }) + const byHostId = automation({ + runContext: { + kind: 'workspace-run', + projectId: 'p', + hostId: 'runtime:env-1', + projectHostSetupId: 's', + repoId: 'repo-1', + path: '/tmp' + } + }) + expect(projectAutomationSelector(byHostId, context()).kind).toBe('orphan') + }) +}) + +describe('automationSelectorMatchesScope', () => { + it('requires the SSH generation to match, not just the target id', () => { + const selector = { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } as const + expect( + automationSelectorMatchesScope(selector, { + kind: 'ssh', + targetId: 'ssh-1', + expectedTargetGeneration: 7 + }) + ).toBe(true) + expect( + automationSelectorMatchesScope(selector, { + kind: 'ssh', + targetId: 'ssh-1', + expectedTargetGeneration: 6 + }) + ).toBe(false) + }) +}) + +describe('projectAutomationList', () => { + const records = [ + automation({ id: 'local-1' }), + automation({ + id: 'ssh-a', + executionTargetType: 'ssh', + executionTargetId: 'ssh-1', + executionTargetGeneration: 7, + projectId: 'repo-ssh' + }), + automation({ id: 'orphan-1', executionTargetType: 'ssh', executionTargetId: 'ssh-gone' }) + ] + + it('returns every record with one item each when no scope is requested', () => { + const result = projectAutomationList(records, context()) + expect(result.automations).toHaveLength(3) + expect(result.items.map((item) => item.automationId)).toEqual(['local-1', 'ssh-a', 'orphan-1']) + expect(result.orphanCount).toBe(1) + }) + + it('narrows to one host while still reporting the authority orphan count', () => { + const result = projectAutomationList(records, context(), { kind: 'self' }) + expect(result.automations.map((entry) => entry.id)).toEqual(['local-1']) + expect(result.orphanCount).toBe(1) + }) + + it('attaches bounded usage summaries when the authority supplies them', () => { + const result = projectAutomationList(records, { + ...context(), + usageSummary: (id) => + id === 'local-1' + ? { + knownRuns: 2, + unavailableRuns: 0, + inputTokens: 1, + outputTokens: 2, + cacheTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 3, + estimatedCostUsd: null + } + : null + }) + expect(result.items[0]?.usageSummary?.knownRuns).toBe(2) + expect(result.items[1]?.usageSummary).toBeNull() + }) +}) + +describe('automationChangePublications', () => { + it('publishes one event when the record stays on its host', () => { + expect(automationChangePublications({ kind: 'self' }, { kind: 'self' })).toEqual([ + { kind: 'self' } + ]) + }) + + it('publishes source and destination when the record moves', () => { + expect(automationChangePublications({ kind: 'self' }, { kind: 'ssh', targetId: 't1' })).toEqual( + [{ kind: 'self' }, { kind: 'ssh', targetId: 't1' }] + ) + }) + + it('treats two SSH hosts as a move', () => { + expect( + automationChangePublications({ kind: 'ssh', targetId: 't1' }, { kind: 'ssh', targetId: 't2' }) + ).toEqual([ + { kind: 'ssh', targetId: 't1' }, + { kind: 'ssh', targetId: 't2' } + ]) + }) + + // Why: a partial publication would leave the unnamed side showing a row that moved away. + it('degrades to one unscoped event when either side is unknown', () => { + expect(automationChangePublications(null, { kind: 'self' })).toEqual([undefined]) + expect(automationChangePublications({ kind: 'self' }, null)).toEqual([undefined]) + }) + + it('drops the incarnation when narrowing a projected selector', () => { + expect( + toAutomationChangeSelector({ kind: 'ssh', targetId: 't1', targetGeneration: 9 }) + ).toEqual({ kind: 'ssh', targetId: 't1' }) + expect(toAutomationChangeSelector({ kind: 'orphan', issue: 'gone' })).toEqual({ + kind: 'orphan' + }) + }) +}) + +// A folder workspace can pin its host directly, and that pin is what dispatch uses. +// Projecting from the repo alone filed those records under Self while they ran elsewhere. +describe('workspace host pin', () => { + it('files a local record under the SSH host its workspace pins', () => { + const selector = projectAutomationSelector( + automation({ workspaceId: 'folder:fw-1' }), + context({ workspaceHost: () => ({ kind: 'ssh', targetId: 'ssh-1' }) }) + ) + + expect(selector).toEqual({ kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 }) + }) + + it('orphans a pin whose SSH target is no longer registered', () => { + const selector = projectAutomationSelector( + automation({ workspaceId: 'folder:fw-1' }), + context({ workspaceHost: () => ({ kind: 'ssh', targetId: 'ssh-gone' }) }) + ) + + expect(selector).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.targetMissing + }) + }) + + it('orphans a workspace that resolves to more than one host instead of assuming Self', () => { + const selector = projectAutomationSelector( + automation({ workspaceId: 'folder:fw-1' }), + context({ workspaceHost: () => ({ kind: 'ambiguous' }) }) + ) + + expect(selector).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.workspaceHostAmbiguous + }) + }) + + it('leaves the repo in charge when nothing pins the workspace', () => { + expect( + projectAutomationSelector( + automation(), + context({ workspaceHost: () => ({ kind: 'unpinned' }) }) + ) + ).toEqual({ kind: 'self' }) + expect( + projectAutomationSelector( + automation({ + projectId: 'repo-ssh', + runContext: { repoId: 'repo-ssh' } + } as Partial), + context({ workspaceHost: () => ({ kind: 'local' }) }) + ) + ).toEqual({ kind: 'orphan', issue: AUTOMATION_ORPHAN_ISSUES.malformed }) + }) +}) diff --git a/src/shared/automation-list-scope.ts b/src/shared/automation-list-scope.ts new file mode 100644 index 00000000000..3d7ad7a85f4 --- /dev/null +++ b/src/shared/automation-list-scope.ts @@ -0,0 +1,291 @@ +/** + * The `automation.list` request/response contract. + * + * The request is host-scoped inside one authority: the authority itself is the + * IPC endpoint or the paired RPC connection and is never a caller-chosen field. + * An omitted selector is the legacy request and still answers with the + * authority's complete list, so an old client reading a new host is unaffected. + * + * The response keeps `automations` for those old clients and adds `items`, + * which qualifies each row with the selector that actually produced it. Rows are + * paired by ID rather than nested so the legacy field is not duplicated on the + * wire. + */ + +import type { Automation } from './automations-types' +import type { AutomationUsageSummary } from './automation-usage-summary' +import { getAutomationRunRepoId } from './automation-run-identity' +import { parseExecutionHostId } from './execution-host' +import { sanitizeSshTargetGeneration } from './ssh-target-generation' +import { isWorkspaceSshPinRepinned } from './automation-workspace-repin' + +export type AutomationListScopeSelector = + | { kind: 'self' } + | { kind: 'ssh'; targetId: string; expectedTargetGeneration: number } + | { kind: 'orphan' } + +export type AutomationListParams = { + /** Omitted, like `null` or `{}`, requests the authority's complete list. */ + selector?: AutomationListScopeSelector +} + +export type AutomationListItemSelector = + | { kind: 'self' } + | { kind: 'ssh'; targetId: string; targetGeneration: number } + | { kind: 'orphan'; issue: string } + +export type AutomationListItem = { + automationId: string + selector: AutomationListItemSelector + usageSummary?: AutomationUsageSummary | null +} + +export type AutomationListResult = { + automations: Automation[] + items: AutomationListItem[] + /** + * Orphans across the whole authority, so any scoped answer can reveal the + * orphan bucket. Optional on the wire, and absent must stay absent: a zero + * here settles a bucket nobody counted. + */ + orphanCount?: number +} + +/** What a host with no scoped list support returns, and all an old client reads. */ +export type LegacyAutomationListResult = { automations: Automation[] } + +export const AUTOMATION_ORPHAN_ISSUES = { + targetMissing: 'Its SSH host is no longer registered.', + targetReplaced: 'Its SSH host was removed and re-added, so it must be re-adopted.', + scheduledElsewhere: 'It is stored here but scheduled against a remote runtime.', + projectMissing: 'Its project no longer exists, so its host cannot be resolved.', + projectUnverified: 'Its project could not be checked from here, so its host is unconfirmed.', + workspaceHostAmbiguous: 'Its workspace spans more than one host, so it has no single owner.', + malformed: 'Its stored host record is incomplete.' +} as const + +/** Where a record's workspace executes; `unpinned` leaves the repo to decide. */ +export type AutomationWorkspaceHost = + | { kind: 'unpinned' } + | { kind: 'local' } + | { kind: 'ssh'; targetId: string } + | { kind: 'ambiguous' } + +export type AutomationProjectionContext = { + /** The process that owns this store; runtime scheduling markers are local only on a runtime. */ + storageAuthority?: 'desktop' | 'runtime' + /** Current registration generation of a saved SSH target; undefined once it is gone. */ + sshTargetGeneration: (targetId: string) => number | undefined + /** `undefined` = the repo is gone; `null` = a local repo; a string = its SSH connection. */ + repoConnectionId: (repoId: string) => string | null | undefined + /** Host the record's workspace pins it to; omitted means no side knows of a pin. */ + workspaceHost?: (automation: Automation) => AutomationWorkspaceHost + /** Registration a generation was allocated to; omitted leaves a re-pin unproven. */ + sshTargetIdForGeneration?: (generation: number) => string | undefined + usageSummary?: (automationId: string) => AutomationUsageSummary | null +} + +function orphan(issue: string): AutomationListItemSelector { + return { kind: 'orphan', issue } +} + +function sshSelector( + automation: Automation, + context: AutomationProjectionContext +): AutomationListItemSelector { + const targetId = automation.executionTargetId?.trim() + if (!targetId) { + return orphan(AUTOMATION_ORPHAN_ISSUES.malformed) + } + return sshTargetSelector(targetId, automation.executionTargetGeneration, context) +} + +function sshTargetSelector( + targetId: string, + capturedGeneration: number | undefined, + context: AutomationProjectionContext +): AutomationListItemSelector { + const current = context.sshTargetGeneration(targetId) + if (current === undefined) { + return orphan(AUTOMATION_ORPHAN_ISSUES.targetMissing) + } + const captured = sanitizeSshTargetGeneration(capturedGeneration) + if (captured !== undefined && captured !== current) { + return orphan(AUTOMATION_ORPHAN_ISSUES.targetReplaced) + } + return { kind: 'ssh', targetId, targetGeneration: captured ?? current } +} + +/** + * The pin decides the host; the capture only decides whether this incarnation of + * it is the one the record was attached to. A capture a different live + * registration carries came from the pin the workspace has since moved off, so + * it says nothing about this one and is dropped rather than read as a mismatch. + */ +function workspacePinSelector( + automation: Automation, + targetId: string, + context: AutomationProjectionContext +): AutomationListItemSelector { + const repinned = isWorkspaceSshPinRepinned({ + capturedGeneration: automation.executionTargetGeneration, + pin: { targetId, generation: context.sshTargetGeneration(targetId) }, + sshTargetIdForGeneration: context.sshTargetIdForGeneration + }) + return sshTargetSelector( + targetId, + repinned ? undefined : automation.executionTargetGeneration, + context + ) +} + +/** + * Runtime scheduling markers are owned only in runtime storage. A bare `local` + * still needs a resolvable local project before it is evidence of Self. + */ +export function projectAutomationSelector( + automation: Automation, + context: AutomationProjectionContext +): AutomationListItemSelector { + if ( + context.storageAuthority !== 'runtime' && + (automation.schedulerOwner === 'remote_host_service' || + parseExecutionHostId(automation.runContext?.hostId)?.kind === 'runtime') + ) { + return orphan(AUTOMATION_ORPHAN_ISSUES.scheduledElsewhere) + } + if (automation.executionTargetType === 'ssh') { + return sshSelector(automation, context) + } + if (automation.executionTargetType !== 'local') { + return orphan(AUTOMATION_ORPHAN_ISSUES.malformed) + } + const workspaceHost = context.workspaceHost?.(automation) ?? { kind: 'unpinned' } + if (workspaceHost.kind === 'ambiguous') { + return orphan(AUTOMATION_ORPHAN_ISSUES.workspaceHostAmbiguous) + } + // A pinned workspace is where the run actually goes, so the row belongs to that host, not Self. + if (workspaceHost.kind === 'ssh') { + return workspacePinSelector(automation, workspaceHost.targetId, context) + } + const connectionId = context.repoConnectionId(getAutomationRunRepoId(automation)) + if (connectionId === undefined) { + return orphan(AUTOMATION_ORPHAN_ISSUES.projectMissing) + } + // A local record whose project points at an SSH connection contradicts itself. + return connectionId ? orphan(AUTOMATION_ORPHAN_ISSUES.malformed) : { kind: 'self' } +} + +/** The orphan diagnoses that answer "does this record still have one usable host?" */ +export type AutomationCapturedHostIssue = + | typeof AUTOMATION_ORPHAN_ISSUES.targetMissing + | typeof AUTOMATION_ORPHAN_ISSUES.targetReplaced + | typeof AUTOMATION_ORPHAN_ISSUES.workspaceHostAmbiguous + +// Keyed by the union, not a list of it: a list stays valid when the union grows, so a +// fourth diagnosis would compile while never reaching dispatch. This makes omitting it here +// the compile error, not just omitting its message. +const CAPTURED_HOST_ISSUES: Record = { + [AUTOMATION_ORPHAN_ISSUES.targetMissing]: true, + [AUTOMATION_ORPHAN_ISSUES.targetReplaced]: true, + // No single owner is as unrunnable as no owner: dispatching would pick a host the user never chose. + [AUTOMATION_ORPHAN_ISSUES.workspaceHostAmbiguous]: true +} + +function isCapturedHostIssue(issue: string): issue is AutomationCapturedHostIssue { + return Object.hasOwn(CAPTURED_HOST_ISSUES, issue) +} + +/** + * Which host-identity diagnosis stands against the record, if any. + * + * Dispatch reads the list's verdict instead of re-deriving one, so the two can + * never disagree about the host a record belongs to. The diagnosis is returned + * rather than a yes/no because callers must keep the two apart: a gone host and + * a replaced one need different answers for the user, and run coalescing folds + * only on identical text. The other orphan reasons are different questions and + * stay with whoever already answers them — notably `scheduledElsewhere`, which + * is what a remote server's own scheduler is there to run. + */ +export function automationCapturedHostIssue( + automation: Automation, + context: AutomationProjectionContext +): AutomationCapturedHostIssue | null { + const selector = projectAutomationSelector(automation, context) + if (selector.kind !== 'orphan') { + return null + } + return isCapturedHostIssue(selector.issue) ? selector.issue : null +} + +export function automationSelectorMatchesScope( + selector: AutomationListItemSelector, + scope: AutomationListScopeSelector +): boolean { + if (scope.kind === 'ssh') { + return ( + selector.kind === 'ssh' && + selector.targetId === scope.targetId && + selector.targetGeneration === scope.expectedTargetGeneration + ) + } + return selector.kind === scope.kind +} + +/** Projects every stored automation, then narrows to the requested scope. */ +export function projectAutomationList( + automations: readonly Automation[], + context: AutomationProjectionContext, + scope?: AutomationListScopeSelector +): AutomationListResult { + const projected = automations.map((automation) => ({ + automation, + selector: projectAutomationSelector(automation, context) + })) + const orphanCount = projected.filter((entry) => entry.selector.kind === 'orphan').length + const scoped = scope + ? projected.filter((entry) => automationSelectorMatchesScope(entry.selector, scope)) + : projected + return { + automations: scoped.map((entry) => entry.automation), + items: scoped.map((entry) => ({ + automationId: entry.automation.id, + selector: entry.selector, + ...(context.usageSummary ? { usageSummary: context.usageSummary(entry.automation.id) } : {}) + })), + orphanCount + } +} + +/** What a scoped `automationsChanged` event names; carries no incarnation, only the stable host. */ +export type AutomationChangeSelector = + | { kind: 'self' } + | { kind: 'ssh'; targetId: string } + | { kind: 'orphan' } + +export function toAutomationChangeSelector( + selector: AutomationListItemSelector +): AutomationChangeSelector { + return selector.kind === 'ssh' + ? { kind: 'ssh', targetId: selector.targetId } + : { kind: selector.kind } +} + +/** + * The selectors a mutation must publish. A move publishes both source and + * destination; an unrecoverable side degrades the whole publication to one + * unscoped authority event, because a subscriber that never hears about the + * entry it is showing would keep rendering a row that no longer exists. + */ +export function automationChangePublications( + before: AutomationChangeSelector | null, + after: AutomationChangeSelector | null +): (AutomationChangeSelector | undefined)[] { + if (!before || !after) { + return [undefined] + } + const sameHost = + before.kind === after.kind && + (before.kind !== 'ssh' || after.kind !== 'ssh' || before.targetId === after.targetId) + return sameHost ? [after] : [before, after] +} diff --git a/src/shared/automation-owner-conflict.ts b/src/shared/automation-owner-conflict.ts new file mode 100644 index 00000000000..4c3b358ca49 --- /dev/null +++ b/src/shared/automation-owner-conflict.ts @@ -0,0 +1,88 @@ +/** + * The typed conflicts an authority returns when a request's captured owner no + * longer matches its stored automation. + * + * These cross two transports that keep nothing but a string: Electron IPC + * rewraps the message ("Error invoking remote method 'x': Error: …") and an + * older runtime maps unknown error classes to `runtime_error`. So every message + * ends with `: `, the machine-token convention `hasRuntimeRpcErrorCode` + * already matches, and callers strip the tail before display. + */ + +import { hasRuntimeRpcErrorCode } from './runtime-rpc-error-code' + +export const AUTOMATION_OWNER_CONFLICT_CODES = { + /** The stored selector or its SSH generation differs from the captured one. */ + ownerChanged: 'automation_owner_changed', + /** The record's SSH target is no longer registered, so nothing may execute on it. */ + targetRemoved: 'automation_target_removed', + /** The caller omitted a precondition for a record that carries a fenceable SSH owner. */ + fencingRequired: 'automation_owner_fencing_required', + /** The requested create/update destination does not resolve on this authority. */ + invalidDestination: 'automation_destination_invalid' +} as const + +export type AutomationOwnerConflictCode = + (typeof AUTOMATION_OWNER_CONFLICT_CODES)[keyof typeof AUTOMATION_OWNER_CONFLICT_CODES] + +const CONFLICT_MESSAGES: Record = { + [AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged]: + "This automation's host changed. Reload it before continuing.", + [AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved]: + "This automation's SSH host was removed, so it cannot run. Delete it or re-add that host.", + [AUTOMATION_OWNER_CONFLICT_CODES.fencingRequired]: + 'This automation is pinned to an SSH host, so the request must name the host it expects.', + [AUTOMATION_OWNER_CONFLICT_CODES.invalidDestination]: + 'That host is no longer available on this authority. Pick a host and try again.' +} + +export function automationOwnerConflictMessage(code: AutomationOwnerConflictCode): string { + return CONFLICT_MESSAGES[code] +} + +export class AutomationOwnerConflictError extends Error { + readonly code: AutomationOwnerConflictCode + + constructor(code: AutomationOwnerConflictCode) { + super(`${CONFLICT_MESSAGES[code]}: ${code}`) + this.name = 'AutomationOwnerConflictError' + this.code = code + } +} + +const CODE_VALUES: readonly AutomationOwnerConflictCode[] = Object.values( + AUTOMATION_OWNER_CONFLICT_CODES +) + +export function isAutomationOwnerConflictCode( + value: unknown +): value is AutomationOwnerConflictCode { + return typeof value === 'string' && CODE_VALUES.includes(value as AutomationOwnerConflictCode) +} + +/** + * The conflict an authority returned, whatever the transport left of it. + * + * The single classifier for every client: matching `.code` alone reads only the + * hop that preserves the error class, and a flattened `runtime_error` would + * then lose the recovery the user needs. + */ +export function matchAutomationOwnerConflict(error: unknown): AutomationOwnerConflictCode | null { + for (const code of CODE_VALUES) { + if (hasRuntimeRpcErrorCode(error, code)) { + return code + } + } + return null +} + +/** Removes the trailing machine token so a conflict can be shown to a user verbatim. */ +export function stripAutomationOwnerConflictCode(message: string): string { + for (const code of CODE_VALUES) { + const suffix = `: ${code}` + if (message.trimEnd().endsWith(suffix)) { + return message.trimEnd().slice(0, -suffix.length) + } + } + return message +} diff --git a/src/shared/automation-owner-key.test.ts b/src/shared/automation-owner-key.test.ts new file mode 100644 index 00000000000..e81592f4385 --- /dev/null +++ b/src/shared/automation-owner-key.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest' +import { + hostStableKey, + isSameAutomationOwner, + isSameStableAutomationHost, + ownerKey, + parseHostStableKey, + toStableCatalogRef +} from './automation-owner-key' +import type { AutomationOwnerRef, StableAutomationCatalogRef } from './automation-owner-ref' + +const desktopSelf: AutomationOwnerRef = { + authority: { kind: 'desktop' }, + selector: { kind: 'self' } +} + +const desktopSsh = (targetId: string, targetGeneration: number): AutomationOwnerRef => ({ + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId, targetGeneration } +}) + +const runtimeSsh = ( + environmentId: string, + pairingRevision: number, + targetId: string, + targetGeneration: number +): AutomationOwnerRef => ({ + authority: { kind: 'runtime', environmentId, pairingRevision }, + selector: { kind: 'ssh', targetId, targetGeneration } +}) + +describe('automation owner key injectivity', () => { + it('does not let a separator inside a component forge another key', () => { + // Naive concatenation collides here: both produce host:runtime:a:ssh:self. + const environmentIdCarriesSelector: StableAutomationCatalogRef = { + authority: { kind: 'runtime', environmentId: 'a:ssh' }, + selector: { kind: 'self' } + } + const targetIdCarriesSelector: StableAutomationCatalogRef = { + authority: { kind: 'runtime', environmentId: 'a' }, + selector: { kind: 'ssh', targetId: 'self' } + } + expect(hostStableKey(environmentIdCarriesSelector)).not.toBe( + hostStableKey(targetIdCarriesSelector) + ) + }) + + it('does not let a pre-escaped component alias an unescaped one', () => { + expect( + hostStableKey({ + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId: 'a%3Ab' } + }) + ).not.toBe( + hostStableKey({ authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 'a:b' } }) + ) + }) + + it('separates desktop from runtime for the same selector', () => { + expect(ownerKey(desktopSelf)).not.toBe( + ownerKey({ + authority: { kind: 'runtime', environmentId: 'e1', pairingRevision: 1 }, + selector: { kind: 'self' } + }) + ) + }) + + it('separates the same target id under desktop and under a runtime', () => { + expect(ownerKey(desktopSsh('t1', 4))).not.toBe(ownerKey(runtimeSsh('e1', 1, 't1', 4))) + }) + + it('keeps stable and owner keys in separate namespaces', () => { + expect(ownerKey(desktopSelf)).not.toBe(hostStableKey(toStableCatalogRef(desktopSelf))) + }) +}) + +describe('incarnation sensitivity', () => { + it('stable key ignores pairing revision and target generation', () => { + expect(hostStableKey(toStableCatalogRef(runtimeSsh('e1', 1, 't1', 4)))).toBe( + hostStableKey(toStableCatalogRef(runtimeSsh('e1', 9, 't1', 7))) + ) + expect( + isSameStableAutomationHost( + toStableCatalogRef(runtimeSsh('e1', 1, 't1', 4)), + toStableCatalogRef(runtimeSsh('e1', 9, 't1', 7)) + ) + ).toBe(true) + }) + + it('owner key does not ignore them', () => { + expect(isSameAutomationOwner(runtimeSsh('e1', 1, 't1', 4), runtimeSsh('e1', 1, 't1', 4))).toBe( + true + ) + expect(isSameAutomationOwner(runtimeSsh('e1', 1, 't1', 4), runtimeSsh('e1', 1, 't1', 5))).toBe( + false + ) + expect(isSameAutomationOwner(runtimeSsh('e1', 1, 't1', 4), runtimeSsh('e1', 2, 't1', 4))).toBe( + false + ) + }) + + it('drops incarnations when projecting an owner to a stable ref', () => { + expect(toStableCatalogRef(runtimeSsh('e1', 3, 't1', 4))).toEqual({ + authority: { kind: 'runtime', environmentId: 'e1' }, + selector: { kind: 'ssh', targetId: 't1' } + }) + }) +}) + +describe('parseHostStableKey', () => { + const refs: StableAutomationCatalogRef[] = [ + { authority: { kind: 'desktop' }, selector: { kind: 'self' } }, + { authority: { kind: 'desktop' }, selector: { kind: 'orphan' } }, + { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 'weird:id/with spaces' } }, + { authority: { kind: 'runtime', environmentId: 'env:1' }, selector: { kind: 'self' } }, + { authority: { kind: 'runtime', environmentId: 'env 1' }, selector: { kind: 'orphan' } }, + { authority: { kind: 'runtime', environmentId: 'e' }, selector: { kind: 'ssh', targetId: 't' } } + ] + + it('round-trips every catalog ref shape', () => { + for (const ref of refs) { + expect(parseHostStableKey(hostStableKey(ref))).toEqual(ref) + } + }) + + it.each([ + '', + 'host', + 'owner:desktop:self', + 'host:desktop', + 'host:desktop:ssh', + 'host:desktop:ssh:a:b', + 'host:desktop:bogus', + 'host:runtime:self', + 'host:runtime::self', + // non-canonical escaping must not alias the canonical key + 'host:desktop:ssh:a/b' + ])('rejects malformed key %s', (key) => { + expect(parseHostStableKey(key)).toBeNull() + }) +}) diff --git a/src/shared/automation-owner-key.ts b/src/shared/automation-owner-key.ts new file mode 100644 index 00000000000..24e1ae2b655 --- /dev/null +++ b/src/shared/automation-owner-key.ts @@ -0,0 +1,149 @@ +import type { + AutomationAuthorityRef, + AutomationHostSelector, + AutomationOwnerRef, + StableAutomationAuthorityRef, + StableAutomationCatalogRef, + StableAutomationCatalogSelector +} from './automation-owner-ref' + +/** + * The one canonical encoder for automation ownership keys. + * + * Every component is `encodeURIComponent`-escaped before joining, so the + * separator can never appear inside a component and a hostile target ID cannot + * forge another host's key. Display labels, bare IDs, and `JSON.stringify` are + * never keys: labels change, bare IDs collide across authorities, and object + * key order is not part of the language contract. + * + * Stable and owner keys live in separate namespaces (`host:` / `owner:`) so a + * desktop+self stable key can never be mistaken for a desktop+self owner key if + * the two ever share a map. + */ + +const SEPARATOR = ':' +const HOST_STABLE_NAMESPACE = 'host' +const OWNER_NAMESPACE = 'owner' + +function encodePart(value: string): string { + return encodeURIComponent(value) +} + +// Numbers are encoded through the same escaper so exotic literals ('1e+21') stay separator-free. +function encodeNumber(value: number): string { + return encodePart(String(value)) +} + +function decodePart(raw: string): string | null { + let decoded: string + try { + decoded = decodeURIComponent(raw) + } catch { + return null + } + // Why: require the canonical escaping so a non-round-tripping key is rejected rather than aliased. + return decoded && encodePart(decoded) === raw ? decoded : null +} + +function stableAuthorityParts(authority: StableAutomationAuthorityRef): string[] { + return authority.kind === 'desktop' + ? ['desktop'] + : ['runtime', encodePart(authority.environmentId)] +} + +function ownerAuthorityParts(authority: AutomationAuthorityRef): string[] { + return authority.kind === 'desktop' + ? ['desktop'] + : ['runtime', encodePart(authority.environmentId), encodeNumber(authority.pairingRevision)] +} + +function stableSelectorParts(selector: StableAutomationCatalogSelector): string[] { + return selector.kind === 'ssh' ? ['ssh', encodePart(selector.targetId)] : [selector.kind] +} + +function ownerSelectorParts(selector: AutomationHostSelector): string[] { + return selector.kind === 'ssh' + ? ['ssh', encodePart(selector.targetId), encodeNumber(selector.targetGeneration)] + : ['self'] +} + +/** Key for the persisted filter and display slot; excludes every incarnation field. */ +export function hostStableKey(ref: StableAutomationCatalogRef): string { + return [ + HOST_STABLE_NAMESPACE, + ...stableAuthorityParts(ref.authority), + ...stableSelectorParts(ref.selector) + ].join(SEPARATOR) +} + +/** Key for fetched rows, mutations, secondary reads, and commit fences; includes incarnations. */ +export function ownerKey(owner: AutomationOwnerRef): string { + return [ + OWNER_NAMESPACE, + ...ownerAuthorityParts(owner.authority), + ...ownerSelectorParts(owner.selector) + ].join(SEPARATOR) +} + +export function toStableCatalogRef(owner: AutomationOwnerRef): StableAutomationCatalogRef { + const authority: StableAutomationAuthorityRef = + owner.authority.kind === 'desktop' + ? { kind: 'desktop' } + : { kind: 'runtime', environmentId: owner.authority.environmentId } + return owner.selector.kind === 'ssh' + ? { authority, selector: { kind: 'ssh', targetId: owner.selector.targetId } } + : { authority, selector: { kind: 'self' } } +} + +export function isSameAutomationOwner(a: AutomationOwnerRef, b: AutomationOwnerRef): boolean { + return ownerKey(a) === ownerKey(b) +} + +export function isSameStableAutomationHost( + a: StableAutomationCatalogRef, + b: StableAutomationCatalogRef +): boolean { + return hostStableKey(a) === hostStableKey(b) +} + +function parseStableAuthority( + parts: readonly string[] +): { authority: StableAutomationAuthorityRef; rest: readonly string[] } | null { + if (parts[0] === 'desktop') { + return { authority: { kind: 'desktop' }, rest: parts.slice(1) } + } + if (parts[0] !== 'runtime') { + return null + } + const environmentId = parts[1] === undefined ? null : decodePart(parts[1]) + return environmentId + ? { authority: { kind: 'runtime', environmentId }, rest: parts.slice(2) } + : null +} + +function parseStableSelector(parts: readonly string[]): StableAutomationCatalogSelector | null { + if (parts.length === 1 && (parts[0] === 'self' || parts[0] === 'orphan')) { + return { kind: parts[0] } + } + if (parts.length !== 2 || parts[0] !== 'ssh') { + return null + } + const targetId = decodePart(parts[1]) + return targetId ? { kind: 'ssh', targetId } : null +} + +/** Inverse of {@link hostStableKey}; returns null for anything malformed. */ +export function parseHostStableKey(key: string): StableAutomationCatalogRef | null { + const parts = key.split(SEPARATOR) + if (parts[0] !== HOST_STABLE_NAMESPACE) { + return null + } + const parsedAuthority = parseStableAuthority(parts.slice(1)) + if (!parsedAuthority) { + return null + } + const selector = parseStableSelector(parsedAuthority.rest) + return selector + ? ({ authority: parsedAuthority.authority, selector } as StableAutomationCatalogRef) + : null +} diff --git a/src/shared/automation-owner-precondition.test.ts b/src/shared/automation-owner-precondition.test.ts new file mode 100644 index 00000000000..265b3306bc9 --- /dev/null +++ b/src/shared/automation-owner-precondition.test.ts @@ -0,0 +1,307 @@ +import { describe, expect, it } from 'vitest' +import type { Automation } from './automations-types' +import type { AutomationProjectionContext } from './automation-list-scope' +import { AUTOMATION_OWNER_CONFLICT_CODES } from './automation-owner-conflict' +import { + assertAutomationDestination, + assertAutomationOwnerFence, + assertExecutionTargetMatchesDestination +} from './automation-owner-precondition' + +function automation(overrides: Partial = {}): Automation { + return { + id: 'a1', + name: 'Nightly', + prompt: 'go', + precheck: null, + agentId: 'claude', + projectId: 'repo-1', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'new_per_run', + workspaceId: null, + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: 0, + enabled: true, + nextRunAt: 0, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 0, + updatedAt: 0, + ...overrides + } as Automation +} + +const context: AutomationProjectionContext = { + sshTargetGeneration: (targetId) => (targetId === 'ssh-1' ? 7 : undefined), + repoConnectionId: (repoId) => + repoId === 'repo-1' ? null : repoId === 'repo-ssh' ? 'ssh-1' : undefined +} + +const sshRecord = automation({ + executionTargetType: 'ssh', + executionTargetId: 'ssh-1', + executionTargetGeneration: 7, + projectId: 'repo-ssh' +}) + +/** A `local` record its folder workspace pins to SSH: SSH-owned by projection, not by storage. */ +const pinnedLocalRecord = automation({ workspaceId: 'folder:fw-1' }) +const pinnedContext: AutomationProjectionContext = { + ...context, + workspaceHost: () => ({ kind: 'ssh', targetId: 'ssh-1' }) +} + +function expectConflict(run: () => void, code: string): void { + expect(run).toThrowError(expect.objectContaining({ code })) +} + +describe('assertAutomationOwnerFence', () => { + it('passes when the captured owner still matches', () => { + expect(() => + assertAutomationOwnerFence({ + automation: sshRecord, + expectedOwner: { + selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } + }, + operation: 'mutate', + context + }) + ).not.toThrow() + }) + + it('fails closed on a stale SSH generation', () => { + expectConflict( + () => + assertAutomationOwnerFence({ + automation: sshRecord, + expectedOwner: { + selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 6 } + }, + operation: 'mutate', + context + }), + AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged + ) + }) + + it('fails closed when the expected host is a different target', () => { + expectConflict( + () => + assertAutomationOwnerFence({ + automation: sshRecord, + expectedOwner: { + selector: { kind: 'ssh', targetId: 'ssh-2', targetGeneration: 7 } + }, + operation: 'mutate', + context + }), + AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged + ) + }) + + // Optional on the wire is not unenforced: a generation-bearing record refuses + // an ownerless mutation, while a generation-less legacy row stays callable bare. + it('refuses an ownerless mutation of a record that captured a generation', () => { + expectConflict( + () => assertAutomationOwnerFence({ automation: sshRecord, operation: 'mutate', context }), + AUTOMATION_OWNER_CONFLICT_CODES.fencingRequired + ) + }) + + it('keeps legacy mutations compatible on a generation-less SSH record', () => { + const legacySshRecord = automation({ + executionTargetType: 'ssh', + executionTargetId: 'ssh-1', + projectId: 'repo-ssh' + }) + expect(() => + assertAutomationOwnerFence({ automation: legacySshRecord, operation: 'mutate', context }) + ).not.toThrow() + }) + + it('keeps legacy mutations compatible on a self record', () => { + expect(() => + assertAutomationOwnerFence({ automation: automation(), operation: 'mutate', context }) + ).not.toThrow() + }) + + it('still lets an old client read that record', () => { + expect(() => + assertAutomationOwnerFence({ automation: sshRecord, operation: 'read', context }) + ).not.toThrow() + }) + + it('leaves a legacy client unaffected on self records', () => { + expect(() => + assertAutomationOwnerFence({ automation: automation(), operation: 'mutate', context }) + ).not.toThrow() + }) + + // The stored field is absent on a workspace-pinned record while the projection carries a + // generation, so keying the demand off storage let precisely this population write unfenced. + it('keeps legacy workspace-pinned mutations and execution compatible', () => { + for (const operation of ['mutate', 'execute'] as const) { + expect(() => + assertAutomationOwnerFence({ + automation: pinnedLocalRecord, + operation, + context: pinnedContext + }) + ).not.toThrow() + } + }) + + it('still lets an old client read a workspace-pinned record', () => { + expect(() => + assertAutomationOwnerFence({ + automation: pinnedLocalRecord, + operation: 'read', + context: pinnedContext + }) + ).not.toThrow() + }) + + it('accepts the projected owner as the precondition for a workspace-pinned record', () => { + expect(() => + assertAutomationOwnerFence({ + automation: pinnedLocalRecord, + expectedOwner: { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } }, + operation: 'mutate', + context: pinnedContext + }) + ).not.toThrow() + }) + + it('rejects a self precondition against an SSH record', () => { + expectConflict( + () => + assertAutomationOwnerFence({ + automation: sshRecord, + expectedOwner: { selector: { kind: 'self' } }, + operation: 'mutate', + context + }), + AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged + ) + }) + + it('keeps orphan delete available but refuses to dispatch it', () => { + const orphan = automation({ + executionTargetType: 'ssh', + executionTargetId: 'ssh-gone', + executionTargetGeneration: 4 + }) + expect(() => + assertAutomationOwnerFence({ + automation: orphan, + expectedOwner: { selector: { kind: 'orphan' } }, + operation: 'mutate', + context + }) + ).not.toThrow() + expect(() => + assertAutomationOwnerFence({ automation: orphan, operation: 'mutate', context }) + ).not.toThrow() + expectConflict( + () => assertAutomationOwnerFence({ automation: orphan, operation: 'execute', context }), + AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved + ) + }) +}) + +describe('destination validation', () => { + it('accepts an SSH destination whose registration still matches', () => { + expect(() => + assertAutomationDestination( + { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } }, + context + ) + ).not.toThrow() + }) + + it('rejects a ghost or future same-id target', () => { + expectConflict( + () => + assertAutomationDestination( + { selector: { kind: 'ssh', targetId: 'ssh-gone', targetGeneration: 1 } }, + context + ), + AUTOMATION_OWNER_CONFLICT_CODES.invalidDestination + ) + expectConflict( + () => + assertAutomationDestination( + { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 8 } }, + context + ), + AUTOMATION_OWNER_CONFLICT_CODES.invalidDestination + ) + }) + + it('rejects a record that would land on a different host than the caller chose', () => { + expectConflict( + () => + assertExecutionTargetMatchesDestination( + { executionTargetType: 'local', executionTargetId: 'local' }, + { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } } + ), + AUTOMATION_OWNER_CONFLICT_CODES.invalidDestination + ) + expect(() => + assertExecutionTargetMatchesDestination( + { + executionTargetType: 'ssh', + executionTargetId: 'ssh-1', + executionTargetGeneration: 7 + }, + { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } } + ) + ).not.toThrow() + }) + + // The stored fields say `local`, but the pin is where the run goes: reading storage let a + // record dispatch sends over SSH land under a Self destination the user chose. + it('refuses a self destination for a record its workspace pins to SSH', () => { + expectConflict( + () => + assertExecutionTargetMatchesDestination( + { + executionTargetType: 'local', + executionTargetId: 'local', + executionTargetGeneration: 7 + }, + { selector: { kind: 'self' } }, + { targetId: 'ssh-1', generation: 7 } + ), + AUTOMATION_OWNER_CONFLICT_CODES.invalidDestination + ) + }) + + it('accepts the pinned host as the destination for that same record', () => { + expect(() => + assertExecutionTargetMatchesDestination( + { + executionTargetType: 'local', + executionTargetId: 'local', + executionTargetGeneration: 7 + }, + { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } }, + { targetId: 'ssh-1', generation: 7 } + ) + ).not.toThrow() + }) + + it('leaves an unpinned local record landing on self', () => { + expect(() => + assertExecutionTargetMatchesDestination( + { executionTargetType: 'local', executionTargetId: 'local' }, + { selector: { kind: 'self' } } + ) + ).not.toThrow() + }) +}) diff --git a/src/shared/automation-owner-precondition.ts b/src/shared/automation-owner-precondition.ts new file mode 100644 index 00000000000..e41e799a9c0 --- /dev/null +++ b/src/shared/automation-owner-precondition.ts @@ -0,0 +1,167 @@ +/** + * The owner precondition an authority enforces before it reads, mutates, or + * executes one stored automation. + * + * Optional on the wire preserves old-client/new-server compatibility. Current + * clients send the capture and get strict enforcement; legacy clients omit it. + */ + +import type { Automation } from './automations-types' +import type { AutomationExecutionTargetSelection } from './automation-execution-target' +import type { AutomationWorkspaceSshPin } from './automation-workspace-pin' +import { + projectAutomationSelector, + type AutomationListItemSelector, + type AutomationProjectionContext +} from './automation-list-scope' +import { + AUTOMATION_OWNER_CONFLICT_CODES, + AutomationOwnerConflictError +} from './automation-owner-conflict' +import { sanitizeSshTargetGeneration } from './ssh-target-generation' + +export type AutomationOwnerPreconditionSelector = + | { kind: 'self' } + | { kind: 'ssh'; targetId: string; targetGeneration: number } + | { kind: 'orphan' } + +export type AutomationOwnerPrecondition = { + selector: AutomationOwnerPreconditionSelector +} + +/** Where a create or a selector-moving update wants the record to land. */ +export type AutomationDestination = { + selector: { kind: 'self' } | { kind: 'ssh'; targetId: string; targetGeneration: number } +} + +export type AutomationOwnerFenceOperation = 'read' | 'mutate' | 'execute' + +/** + * The precondition a client echoes back to act on a record it just read. + * + * The orphan issue is dropped on purpose: a precondition names the host the + * caller believes owns the record, not the reason that host is unusable. + */ +export function toAutomationOwnerPrecondition( + selector: AutomationListItemSelector +): AutomationOwnerPrecondition { + return { + selector: + selector.kind === 'ssh' + ? { + kind: 'ssh', + targetId: selector.targetId, + targetGeneration: selector.targetGeneration + } + : { kind: selector.kind } + } +} + +export type AutomationOwnerFenceInput = { + automation: Automation + expectedOwner?: AutomationOwnerPrecondition + operation: AutomationOwnerFenceOperation + context: AutomationProjectionContext +} + +function conflict(code: keyof typeof AUTOMATION_OWNER_CONFLICT_CODES): never { + throw new AutomationOwnerConflictError(AUTOMATION_OWNER_CONFLICT_CODES[code]) +} + +export function assertAutomationOwnerFence(input: AutomationOwnerFenceInput): void { + const stored = projectAutomationSelector(input.automation, input.context) + const expected = input.expectedOwner?.selector + if (stored.kind === 'orphan' && input.operation === 'execute') { + conflict('targetRemoved') + } + if (!expected) { + // Optional on the wire is not unenforced: a record fenced to an SSH registration + // may not be mutated or executed by a caller that names no host at all. Only + // reads, Self records, and generation-less legacy SSH rows stay callable bare. + if ( + input.operation !== 'read' && + stored.kind === 'ssh' && + sanitizeSshTargetGeneration(input.automation.executionTargetGeneration) !== undefined + ) { + conflict('fencingRequired') + } + return + } + if (expected.kind !== stored.kind) { + conflict('ownerChanged') + } + if ( + expected.kind === 'ssh' && + stored.kind === 'ssh' && + (expected.targetId !== stored.targetId || expected.targetGeneration !== stored.targetGeneration) + ) { + conflict('ownerChanged') + } +} + +/** A destination is resolved against the saved target registry, never accepted as a free-form value. */ +export function assertAutomationDestination( + destination: AutomationDestination, + context: Pick +): void { + const selector = destination.selector + if (selector.kind === 'self') { + return + } + if (selector.kind !== 'ssh' || !selector.targetId) { + conflict('invalidDestination') + } + if (context.sshTargetGeneration(selector.targetId) !== selector.targetGeneration) { + conflict('invalidDestination') + } +} + +/** + * Where the derived selection actually lands, which is the projected selector + * rather than the stored fields: a `local` target under a workspace pin runs on + * that registration, so the stored type is not the host. + */ +function landingSelector( + selection: AutomationExecutionTargetSelection, + workspaceSshPin: AutomationWorkspaceSshPin | undefined +): { kind: 'self' } | { kind: 'ssh'; targetId: string; targetGeneration: number | undefined } { + if (selection.executionTargetType === 'ssh') { + return { + kind: 'ssh', + targetId: selection.executionTargetId, + targetGeneration: selection.executionTargetGeneration + } + } + if (selection.executionTargetType === 'local' && workspaceSshPin) { + return { + kind: 'ssh', + targetId: workspaceSshPin.targetId, + targetGeneration: selection.executionTargetGeneration ?? workspaceSshPin.generation + } + } + return { kind: 'self' } +} + +/** The host the record actually lands on must be the one the caller chose. */ +export function assertExecutionTargetMatchesDestination( + selection: AutomationExecutionTargetSelection, + destination: AutomationDestination, + /** The SSH target the record's workspace pins it to once the write lands. */ + workspaceSshPin?: AutomationWorkspaceSshPin +): void { + const landing = landingSelector(selection, workspaceSshPin) + const selector = destination.selector + if (selector.kind === 'self') { + if (landing.kind !== 'self') { + conflict('invalidDestination') + } + return + } + if ( + landing.kind !== 'ssh' || + landing.targetId !== selector.targetId || + (landing.targetGeneration ?? selector.targetGeneration) !== selector.targetGeneration + ) { + conflict('invalidDestination') + } +} diff --git a/src/shared/automation-owner-ref.ts b/src/shared/automation-owner-ref.ts new file mode 100644 index 00000000000..ce8bc34ed0c --- /dev/null +++ b/src/shared/automation-owner-ref.ts @@ -0,0 +1,58 @@ +/** + * Canonical ownership identity for automation records. + * + * An automation ID is unique only inside the authority that stores it, so every + * row, request, and action carries the authority (desktop or a paired runtime) + * plus the host selector (self or one SSH target) that produced it. + * + * Two forms exist on purpose: + * - Stable refs survive renames and connection changes; they key the persisted + * host filter and the display slot. + * - Owner refs additionally pin the *incarnation* (`pairingRevision` for a + * runtime, `targetGeneration` for an SSH registration) so data captured from + * an old incarnation can never commit against a new one. + */ + +export type AutomationAuthorityRef = + | { kind: 'desktop' } + | { + kind: 'runtime' + environmentId: string + /** The saved runtime environment's pairing revision; a re-pair invalidates captured data. */ + pairingRevision: number + } + +export type AutomationHostSelector = + | { kind: 'self' } + | { + kind: 'ssh' + targetId: string + /** Durable SSH *registration* incarnation — not a connection attempt counter. */ + targetGeneration: number + } + +export type AutomationOwnerRef = { + authority: AutomationAuthorityRef + selector: AutomationHostSelector +} + +export type StableAutomationAuthorityRef = + | { kind: 'desktop' } + | { kind: 'runtime'; environmentId: string } + +export type StableAutomationHostSelector = { kind: 'self' } | { kind: 'ssh'; targetId: string } + +export type StableAutomationHostRef = { + authority: StableAutomationAuthorityRef + selector: StableAutomationHostSelector +} + +/** A stable host, or the per-authority bucket for records with no executable owner. */ +export type StableAutomationCatalogRef = + | StableAutomationHostRef + | { + authority: StableAutomationAuthorityRef + selector: { kind: 'orphan' } + } + +export type StableAutomationCatalogSelector = StableAutomationCatalogRef['selector'] diff --git a/src/shared/automation-usage-summary.test.ts b/src/shared/automation-usage-summary.test.ts new file mode 100644 index 00000000000..2978d95b88f --- /dev/null +++ b/src/shared/automation-usage-summary.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import type { AutomationRun, AutomationRunUsage } from './automations-types' +import { summarizeAutomationRunUsage } from './automation-usage-summary' + +const makeUsage = (overrides: Partial = {}): AutomationRunUsage => ({ + status: 'known', + provider: 'claude', + model: 'sonnet', + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 3, + cacheWriteTokens: 2, + reasoningOutputTokens: 1, + totalTokens: 21, + estimatedCostUsd: 0.25, + estimatedCostSource: 'api_equivalent', + providerSessionId: 'session-1', + attribution: 'provider_session_time_window', + collectedAt: 1, + unavailableReason: null, + unavailableMessage: null, + ...overrides +}) + +const makeRun = (usage: AutomationRunUsage | null): AutomationRun => + ({ id: 'run-1', automationId: 'a1', usage }) as AutomationRun + +describe('summarizeAutomationRunUsage', () => { + it('aggregates known runs and counts the rest as unavailable', () => { + const summary = summarizeAutomationRunUsage([ + makeRun(makeUsage()), + makeRun(makeUsage({ estimatedCostUsd: null })), + makeRun(makeUsage({ status: 'unavailable' })), + makeRun(null) + ]) + + expect(summary).toEqual({ + knownRuns: 2, + unavailableRuns: 2, + inputTokens: 20, + outputTokens: 10, + cacheTokens: 10, + reasoningOutputTokens: 2, + totalTokens: 42, + estimatedCostUsd: 0.25, + lastRunStatus: null, + lastRunAt: null + }) + }) + + it('reports an unknown cost rather than zero when no run has one', () => { + const summary = summarizeAutomationRunUsage([makeRun(makeUsage({ estimatedCostUsd: null }))]) + expect(summary.estimatedCostUsd).toBeNull() + expect(summary.knownRuns).toBe(1) + }) + + it('summarizes an empty retained history without inventing usage', () => { + expect(summarizeAutomationRunUsage([])).toEqual({ + knownRuns: 0, + unavailableRuns: 0, + inputTokens: 0, + outputTokens: 0, + cacheTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + estimatedCostUsd: null, + lastRunStatus: null, + lastRunAt: null + }) + }) + + it('projects the newest retained run so list filters never fetch history', () => { + const summary = summarizeAutomationRunUsage([ + { ...makeRun(makeUsage()), createdAt: 1, status: 'completed', dispatchedAt: 11 }, + { ...makeRun(makeUsage()), createdAt: 3, status: 'dispatch_failed', dispatchedAt: 33 }, + { ...makeRun(makeUsage()), createdAt: 2, status: 'completed', dispatchedAt: 22 } + ]) + expect(summary.lastRunStatus).toBe('dispatch_failed') + expect(summary.lastRunAt).toBe(33) + }) +}) diff --git a/src/shared/automation-usage-summary.ts b/src/shared/automation-usage-summary.ts new file mode 100644 index 00000000000..78aafdfa4fc --- /dev/null +++ b/src/shared/automation-usage-summary.ts @@ -0,0 +1,65 @@ +import type { AutomationRun, AutomationRunStatus } from './automations-types' + +export type AutomationUsageSummary = { + knownRuns: number + unavailableRuns: number + inputTokens: number + outputTokens: number + cacheTokens: number + reasoningOutputTokens: number + totalTokens: number + estimatedCostUsd: number | null + /** Newest retained run's status, so list filters never fetch run history. Optional: older projections omit it. */ + lastRunStatus?: AutomationRunStatus | null + lastRunAt?: number | null +} + +/** Bounded aggregate over an authority's retained runs; never fetches history. */ +export function summarizeAutomationRunUsage( + runs: readonly AutomationRun[] +): AutomationUsageSummary { + let knownRuns = 0 + let unavailableRuns = 0 + let inputTokens = 0 + let outputTokens = 0 + let cacheTokens = 0 + let reasoningOutputTokens = 0 + let totalTokens = 0 + let estimatedCostUsd = 0 + let hasKnownCost = false + let latest: AutomationRun | null = null + + for (const run of runs) { + if (!latest || run.createdAt > latest.createdAt) { + latest = run + } + const usage = run.usage + if (!usage || usage.status !== 'known') { + unavailableRuns++ + continue + } + knownRuns++ + inputTokens += usage.inputTokens ?? 0 + outputTokens += usage.outputTokens ?? 0 + cacheTokens += (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0) + reasoningOutputTokens += usage.reasoningOutputTokens ?? 0 + totalTokens += usage.totalTokens ?? 0 + if (usage.estimatedCostUsd !== null) { + estimatedCostUsd += usage.estimatedCostUsd + hasKnownCost = true + } + } + + return { + knownRuns, + unavailableRuns, + inputTokens, + outputTokens, + cacheTokens, + reasoningOutputTokens, + totalTokens, + estimatedCostUsd: hasKnownCost ? estimatedCostUsd : null, + lastRunStatus: latest?.status ?? null, + lastRunAt: latest ? (latest.dispatchedAt ?? latest.startedAt ?? latest.createdAt ?? null) : null + } +} diff --git a/src/shared/automation-workspace-pin.ts b/src/shared/automation-workspace-pin.ts new file mode 100644 index 00000000000..91cbebef42a --- /dev/null +++ b/src/shared/automation-workspace-pin.ts @@ -0,0 +1,42 @@ +/** + * The SSH target a stored automation's workspace pins it to. + * + * A folder workspace can execute on an SSH host while the automation's project + * is local, and the pin is where the run actually goes — so it, not the project, + * decides that record's host. The list projection, the create/update capture and + * the load-time backfill all have to agree on that answer, so it is resolved + * here once rather than re-derived per caller. + */ + +import type { Automation } from './automations-types' +import type { AutomationWorkspaceHost } from './automation-list-scope' +import { + resolveFolderWorkspaceHost, + type FolderWorkspaceHostState +} from './folder-workspace-execution-host' +import { parseWorkspaceKey } from './workspace-scope' + +/** A pinned SSH target plus the generation its registration currently carries. */ +export type AutomationWorkspaceSshPin = { targetId: string; generation: number | undefined } + +export function resolveAutomationWorkspaceHost( + state: FolderWorkspaceHostState, + workspaceId: Automation['workspaceId'] +): AutomationWorkspaceHost { + const scope = parseWorkspaceKey(workspaceId ?? '') + if (scope?.type !== 'folder') { + return { kind: 'unpinned' } + } + const host = resolveFolderWorkspaceHost(state, scope.folderWorkspaceId) + // A workspace that is gone proves nothing about the host, so the repo still decides. + return host.kind === 'missing' ? { kind: 'unpinned' } : host +} + +/** The pinned SSH target id, if the workspace names one. */ +export function resolveAutomationWorkspaceSshTargetId( + state: FolderWorkspaceHostState, + workspaceId: Automation['workspaceId'] +): string | undefined { + const host = resolveAutomationWorkspaceHost(state, workspaceId) + return host.kind === 'ssh' ? host.targetId : undefined +} diff --git a/src/shared/automation-workspace-repin.ts b/src/shared/automation-workspace-repin.ts new file mode 100644 index 00000000000..e53eed1e536 --- /dev/null +++ b/src/shared/automation-workspace-repin.ts @@ -0,0 +1,37 @@ +/** + * Evidence that a folder workspace was re-pointed at a different SSH host. + * + * A workspace-pinned record captures only a generation, so a capture that + * disagrees with the pin's current registration is ambiguous on its face: the + * workspace may have moved to another host, or the pinned host may have been + * removed and re-added under the same id. Those need opposite answers — follow + * the pin, or stay fenced — so the divergence is resolved from evidence rather + * than guessed. + * + * Generations come from one counter and are never reissued, so at most one + * registration ever carries a given generation. A *live* registration carrying + * the capture is therefore proof of which pin the record was attached to, and + * proof it is not this one. No live carrier proves nothing, and the positive- + * evidence rule leaves the capture alone there: an unfenced re-adopt would hand + * the schedule to whatever machine now answers to the pinned id. + */ + +import type { AutomationWorkspaceSshPin } from './automation-workspace-pin' +import { sanitizeSshTargetGeneration } from './ssh-target-generation' + +/** The registration a generation was allocated to, if one still carries it. */ +export type SshTargetIdForGeneration = (generation: number) => string | undefined + +export function isWorkspaceSshPinRepinned(input: { + capturedGeneration: number | undefined + pin: AutomationWorkspaceSshPin | undefined + sshTargetIdForGeneration: SshTargetIdForGeneration | undefined +}): boolean { + const captured = sanitizeSshTargetGeneration(input.capturedGeneration) + const pin = input.pin + if (captured === undefined || pin?.generation === undefined || captured === pin.generation) { + return false + } + const capturedTargetId = input.sshTargetIdForGeneration?.(captured) + return capturedTargetId !== undefined && capturedTargetId !== pin.targetId +} diff --git a/src/shared/automations-types.ts b/src/shared/automations-types.ts index 82ebcb2dbae..e3c661f976e 100644 --- a/src/shared/automations-types.ts +++ b/src/shared/automations-types.ts @@ -108,6 +108,10 @@ export type Automation = { projectId: string executionTargetType: AutomationExecutionTargetType executionTargetId: string + /** Why: pins the SSH registration incarnation this record was attached to, so a + * removed-and-re-added target reusing the id can't silently adopt it. Absent on + * local records, on legacy records, and on orphans whose target is gone. */ + executionTargetGeneration?: number schedulerOwner: AutomationSchedulerOwner workspaceMode: AutomationWorkspaceMode workspaceId: string | null @@ -156,6 +160,12 @@ export type AutomationRun = { /** Why: run titles must stay unique once retention prunes old runs, so the * number can no longer be derived from how many runs are currently kept. */ runNumber?: number + /** Why: a target that cannot resolve refuses every occurrence, so consecutive + * identical refusals fold into this record instead of one row each. Counts the + * occurrences the record stands for; absent means one. */ + occurrenceCount?: number + /** `scheduledFor` of the most recently folded occurrence; absent until one folds. */ + lastOccurrenceAt?: number } export type AutomationCreateInput = { diff --git a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt index dd12763f3b8..fcf8258d6b3 100644 --- a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt +++ b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt @@ -35,7 +35,6 @@ src/main/ai-vault/session-scanner-service-client.ts src/main/ai-vault/session-scanner-service-spawn.ts src/main/ai-vault/session-scanner-service-test-child.ts src/main/app-icon.ts -src/main/automations/external-manager.ts src/main/automations/precheck-runner.ts src/main/browser/agent-browser-bridge.ts src/main/browser/browser-cookie-import.ts diff --git a/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt b/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt index 6f5f3d32101..116fe6b16e3 100644 --- a/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt +++ b/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt @@ -8,7 +8,6 @@ cli/runtime/launch.ts main/agent-hooks/managed-hook-owner-identity.ts main/agent-hooks/managed-hook-runtime.ts main/app-icon.ts -main/automations/external-manager.ts main/automations/precheck-runner.ts main/browser/browser-cookie-import.ts main/claude-accounts/keychain.ts diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 4683c93c004..a6698d0108d 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -445,6 +445,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState { workspaceSession: getDefaultWorkspaceSession(), workspaceSessionsByHostId: {}, sshTargets: [], + sshTargetGenerationCounter: 0, deletedSshConfigAliases: [], sshRemotePtyLeases: [], sshPtyConsumerRecoveries: [], @@ -479,6 +480,7 @@ export function getDefaultUIState(): PersistedUIState { workspaceHostScope: 'all', visibleWorkspaceHostIds: null, workspaceHostOrder: [], + automationHostFilter: { kind: 'all' }, manualRepoOrder: [], showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES, hideDefaultBranchWorkspace: false, diff --git a/src/shared/external-automation-scope.ts b/src/shared/external-automation-scope.ts new file mode 100644 index 00000000000..0e9fe753a5c --- /dev/null +++ b/src/shared/external-automation-scope.ts @@ -0,0 +1,155 @@ +/** + * Scoped external-automation requests. + * + * External managers are a desktop-only surface, so every scoped call carries the + * captured desktop `AutomationOwnerRef` it was built from. The provider target is + * *derived* from that owner and never accepted alongside it, so a caller cannot + * name one host's owner while asking for another host's manager. + * + * The host-changed failure reuses `AutomationOwnerConflictError` rather than + * introducing a second conflict vocabulary; the codes below cover only the + * failures that are specific to this surface. + */ + +import { + AUTOMATION_OWNER_CONFLICT_CODES, + AutomationOwnerConflictError +} from './automation-owner-conflict' +import type { AutomationOwnerRef } from './automation-owner-ref' +import type { + ExternalAutomationAction, + ExternalAutomationProvider, + ExternalAutomationTarget +} from './automations-types' + +export const EXTERNAL_AUTOMATION_PROVIDERS = [ + 'hermes', + 'openclaw' +] as const satisfies readonly ExternalAutomationProvider[] + +export function isExternalAutomationProvider(value: unknown): value is ExternalAutomationProvider { + return ( + typeof value === 'string' && + (EXTERNAL_AUTOMATION_PROVIDERS as readonly string[]).includes(value) + ) +} + +export const EXTERNAL_AUTOMATION_SCOPE_CODES = { + /** The provider is not on the allowlist, so no command or relay call may be attempted. */ + providerNotAllowed: 'external_automation_provider_not_allowed', + /** Only the desktop authority owns external managers; runtime authorities must not be tunnelled. */ + authorityNotSupported: 'external_automation_authority_not_supported', + /** The SSH target is an Orca implementation detail and stays out of user-facing surfaces. */ + targetHidden: 'external_automation_target_hidden', + /** The relay answered `-32601`: it is healthy but has no external-runs method. */ + runsUnsupported: 'external_automation_runs_unsupported' +} as const + +export type ExternalAutomationScopeCode = + (typeof EXTERNAL_AUTOMATION_SCOPE_CODES)[keyof typeof EXTERNAL_AUTOMATION_SCOPE_CODES] + +const SCOPE_MESSAGES: Record = { + [EXTERNAL_AUTOMATION_SCOPE_CODES.providerNotAllowed]: + 'That external automation provider is not supported.', + [EXTERNAL_AUTOMATION_SCOPE_CODES.authorityNotSupported]: + 'External automation managers are available only on this computer and its SSH hosts.', + [EXTERNAL_AUTOMATION_SCOPE_CODES.targetHidden]: + 'That host is managed by Orca and does not expose external automations.', + [EXTERNAL_AUTOMATION_SCOPE_CODES.runsUnsupported]: + 'This host does not report external automation run history.' +} + +/** + * Carries its code as a trailing `: ` token, matching the automation owner + * conflicts, because Electron IPC and older runtimes preserve nothing but the message. + */ +export class ExternalAutomationScopeError extends Error { + readonly code: ExternalAutomationScopeCode + + constructor(code: ExternalAutomationScopeCode) { + super(`${SCOPE_MESSAGES[code]}: ${code}`) + this.name = 'ExternalAutomationScopeError' + this.code = code + } +} + +const SCOPE_CODE_VALUES: readonly ExternalAutomationScopeCode[] = Object.values( + EXTERNAL_AUTOMATION_SCOPE_CODES +) + +export function isExternalAutomationScopeCode( + value: unknown +): value is ExternalAutomationScopeCode { + return ( + typeof value === 'string' && SCOPE_CODE_VALUES.includes(value as ExternalAutomationScopeCode) + ) +} + +/** The captured incarnation no longer matches the current SSH registration. */ +export function externalAutomationHostChangedError(): AutomationOwnerConflictError { + return new AutomationOwnerConflictError(AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged) +} + +/** The captured SSH host is gone, so nothing may be probed or launched on it. */ +export function externalAutomationTargetRemovedError(): AutomationOwnerConflictError { + return new AutomationOwnerConflictError(AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved) +} + +/** Every scoped request; the target is derived from `owner`, never sent beside it. */ +export type ScopedExternalAutomationRequest = { + owner: AutomationOwnerRef + provider: ExternalAutomationProvider +} + +export type ScopedExternalManagerListRequest = ScopedExternalAutomationRequest & { + /** Skips a cached entry that is still fresh; a probe is scheduled either way when absent. */ + refresh?: boolean +} + +export type ScopedExternalManagerRunsRequest = ScopedExternalAutomationRequest & { + jobId: string + page: number + pageSize: number +} + +export type ScopedExternalManagerMutationFields = { + name: string + prompt: string + schedule: string + workdir: string | null +} + +export type ScopedExternalManagerCreateRequest = ScopedExternalAutomationRequest & + ScopedExternalManagerMutationFields + +export type ScopedExternalManagerUpdateRequest = ScopedExternalManagerCreateRequest & { + jobId: string +} + +export type ScopedExternalManagerActionRequest = ScopedExternalAutomationRequest & { + jobId: string + action: ExternalAutomationAction +} + +/** Target IDs stay identifiers: this is the only place a selector becomes a target. */ +export function externalAutomationTargetForOwner( + owner: AutomationOwnerRef +): ExternalAutomationTarget { + return owner.selector.kind === 'ssh' + ? { type: 'ssh', connectionId: owner.selector.targetId } + : { type: 'local' } +} + +/** + * Manager identity for a scope. The authority is implicit rather than encoded: + * this surface is desktop-only, and the existing IDs are already persisted in + * job, run, and dialog keys that older renderers still parse. + */ +export function externalAutomationManagerId( + owner: AutomationOwnerRef, + provider: ExternalAutomationProvider +): string { + return owner.selector.kind === 'ssh' + ? `${provider}:ssh:${owner.selector.targetId}` + : `${provider}:local` +} diff --git a/src/shared/folder-workspace-execution-host.test.ts b/src/shared/folder-workspace-execution-host.test.ts new file mode 100644 index 00000000000..d2f84fc7a45 --- /dev/null +++ b/src/shared/folder-workspace-execution-host.test.ts @@ -0,0 +1,175 @@ +/** + * The pin cases are the ones that used to diverge: main resolved the repo and + * said local while the renderer resolved the workspace and said SSH. + */ + +import { describe, expect, it } from 'vitest' +import type { FolderWorkspace } from './folder-workspace-types' +import type { ProjectGroup } from './project-group-types' +import type { Repo } from './repo-types' +import { + resolveFolderWorkspaceHost, + type FolderWorkspaceHostState +} from './folder-workspace-execution-host' + +function workspace(overrides: Partial = {}): FolderWorkspace { + return { + id: 'fw-1', + projectGroupId: 'group-1', + name: 'App', + folderPath: '/work/app', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0, + ...overrides + } +} + +function repo(overrides: Partial & Pick): Repo { + return { + displayName: overrides.id, + badgeColor: '#000', + addedAt: 0, + ...overrides + } as Repo +} + +function state(overrides: Partial = {}): FolderWorkspaceHostState { + return { + folderWorkspaces: [workspace()], + projectGroups: [ + { + id: 'group-1', + name: 'Work', + parentPath: '/work', + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 0, + updatedAt: 0 + } satisfies ProjectGroup + ], + repos: [repo({ id: 'repo-1', path: '/work/app/repo', projectGroupId: 'group-1' })], + ...overrides + } +} + +describe('folder workspace execution host', () => { + it('answers with the workspace pin even when every repo under it is local', () => { + const resolved = resolveFolderWorkspaceHost( + state({ + folderWorkspaces: [workspace({ executionHostId: 'ssh:box' })] + }), + 'fw-1' + ) + + expect(resolved).toEqual({ kind: 'ssh', targetId: 'box' }) + }) + + it('honours a local pin over an SSH repo', () => { + const resolved = resolveFolderWorkspaceHost( + state({ + folderWorkspaces: [workspace({ executionHostId: 'local' })], + repos: [ + repo({ + id: 'repo-1', + path: '/work/app/repo', + projectGroupId: 'group-1', + connectionId: 'box' + }) + ] + }), + 'fw-1' + ) + + expect(resolved).toEqual({ kind: 'local' }) + }) + + it('reports a scope that mixes local and SSH repos as ambiguous', () => { + const resolved = resolveFolderWorkspaceHost( + state({ + repos: [ + repo({ id: 'repo-1', path: '/work/app/a', projectGroupId: 'group-1' }), + repo({ + id: 'repo-2', + path: '/work/app/b', + projectGroupId: 'group-1', + connectionId: 'box' + }) + ] + }), + 'fw-1' + ) + + expect(resolved).toEqual({ kind: 'ambiguous' }) + }) + + it('reports two SSH connections as ambiguous', () => { + const resolved = resolveFolderWorkspaceHost( + state({ + repos: [ + repo({ + id: 'repo-1', + path: '/work/app/a', + projectGroupId: 'group-1', + connectionId: 'box' + }), + repo({ + id: 'repo-2', + path: '/work/app/b', + projectGroupId: 'group-1', + connectionId: 'other' + }) + ] + }), + 'fw-1' + ) + + expect(resolved).toEqual({ kind: 'ambiguous' }) + }) + + it('resolves a single SSH connection', () => { + const resolved = resolveFolderWorkspaceHost( + state({ + repos: [ + repo({ + id: 'repo-1', + path: '/work/app/a', + projectGroupId: 'group-1', + connectionId: 'box' + }) + ] + }), + 'fw-1' + ) + + expect(resolved).toEqual({ kind: 'ssh', targetId: 'box' }) + }) + + // Nothing normalizes connection ids on write, so a blank one is local on both sides. + it('treats a blank connection id as local rather than as a host named " "', () => { + const resolved = resolveFolderWorkspaceHost( + state({ + repos: [ + repo({ id: 'repo-1', path: '/work/app/a', projectGroupId: 'group-1', connectionId: ' ' }) + ] + }), + 'fw-1' + ) + + expect(resolved).toEqual({ kind: 'local' }) + }) + + it('separates a workspace that is gone from one that resolves to local', () => { + expect(resolveFolderWorkspaceHost(state(), 'fw-missing')).toEqual({ kind: 'missing' }) + expect(resolveFolderWorkspaceHost(state({ repos: [] }), 'fw-1')).toEqual({ kind: 'local' }) + }) +}) diff --git a/src/shared/folder-workspace-execution-host.ts b/src/shared/folder-workspace-execution-host.ts new file mode 100644 index 00000000000..0e83a20469f --- /dev/null +++ b/src/shared/folder-workspace-execution-host.ts @@ -0,0 +1,135 @@ +/** + * Resolves which host a folder workspace executes on. + * + * Main and the renderer both have to answer this — the list projects a record's + * host from it, dispatch targets one from it — so the answer lives here rather + * than being re-derived per side and drifting. The workspace's own + * `executionHostId` pin wins: a pinned workspace runs there whatever its repos + * say, which is exactly the case that used to read as local. + * + * `ambiguous` is a distinct answer, not a missing one: a scope that spans a + * local repo and an SSH one has no single host, and callers must fail closed + * with something visible rather than defaulting to local. + */ + +import type { FolderWorkspace } from './folder-workspace-types' +import type { ProjectGroup } from './project-group-types' +import type { Repo } from './repo-types' +import { isPathInsideOrEqual } from './cross-platform-path' +import { getProjectGroupSubtreeIds } from './project-groups' +import { parseExecutionHostId } from './execution-host' + +export type FolderWorkspaceHostState = { + folderWorkspaces: readonly FolderWorkspace[] + projectGroups: readonly ProjectGroup[] + repos: readonly Repo[] +} + +export type FolderWorkspaceHost = + | { kind: 'missing' } + | { kind: 'local' } + | { kind: 'ssh'; targetId: string } + | { kind: 'ambiguous' } + +/** Reads a stored connection id: blank is local, since no write path normalizes it. */ +export function normalizeConnectionId(value: string | null | undefined): string | null { + return value?.trim() || null +} + +function getFolderScopeCandidateRepos(args: { + folderPath: string + projectGroupId: string + connectionId: string | null + projectGroups: readonly ProjectGroup[] + repos: readonly Repo[] +}): Repo[] { + const groupIds = getProjectGroupSubtreeIds(args.projectGroups, args.projectGroupId) + const groupRepos = args.repos.filter( + (repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId) + ) + const pathRepos = args.repos.filter( + (repo) => + !(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) && + isPathInsideOrEqual(args.folderPath, repo.path) + ) + if (args.connectionId) { + return [ + ...groupRepos, + ...pathRepos.filter((repo) => normalizeConnectionId(repo.connectionId) === args.connectionId) + ] + } + if (groupRepos.length === 0) { + return pathRepos + } + const groupConnectionIds = new Set( + groupRepos.map((repo) => normalizeConnectionId(repo.connectionId)) + ) + return [ + ...groupRepos, + ...pathRepos.filter((repo) => groupConnectionIds.has(normalizeConnectionId(repo.connectionId))) + ] +} + +export function findFolderWorkspaceCandidateRepos( + state: FolderWorkspaceHostState, + folderWorkspaceId: string +): Repo[] { + const workspace = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId) + if (!workspace) { + return [] + } + const group = state.projectGroups.find((entry) => entry.id === workspace.projectGroupId) + return getFolderScopeCandidateRepos({ + folderPath: workspace.folderPath, + projectGroupId: workspace.projectGroupId, + connectionId: normalizeConnectionId(workspace.connectionId ?? group?.connectionId), + projectGroups: state.projectGroups, + repos: state.repos + }) +} + +export function resolveFolderWorkspaceHost( + state: FolderWorkspaceHostState, + folderWorkspaceId: string +): FolderWorkspaceHost { + const workspace = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId) + if (!workspace) { + return { kind: 'missing' } + } + const explicitHost = parseExecutionHostId(workspace.executionHostId) + if (explicitHost) { + return explicitHost.kind === 'ssh' + ? { kind: 'ssh', targetId: explicitHost.targetId } + : { kind: 'local' } + } + const scopeConnectionId = normalizeConnectionId( + workspace.connectionId ?? + state.projectGroups.find((entry) => entry.id === workspace.projectGroupId)?.connectionId + ) + const candidateRepos = findFolderWorkspaceCandidateRepos(state, folderWorkspaceId) + let hasLocalRepo = false + const connectionIds = new Set() + for (const repo of candidateRepos) { + const connectionId = normalizeConnectionId(repo.connectionId) + if (connectionId) { + connectionIds.add(connectionId) + } else { + hasLocalRepo = true + } + } + if (scopeConnectionId) { + const hasDifferentSshConnection = [...connectionIds].some( + (connectionId) => connectionId !== scopeConnectionId + ) + return hasLocalRepo || hasDifferentSshConnection + ? { kind: 'ambiguous' } + : { kind: 'ssh', targetId: scopeConnectionId } + } + if (candidateRepos.length === 0 || connectionIds.size === 0) { + return { kind: 'local' } + } + if (hasLocalRepo || connectionIds.size > 1) { + return { kind: 'ambiguous' } + } + return { kind: 'ssh', targetId: [...connectionIds][0] } +} diff --git a/src/shared/folder-workspaces.test.ts b/src/shared/folder-workspaces.test.ts new file mode 100644 index 00000000000..c0b023c6b77 --- /dev/null +++ b/src/shared/folder-workspaces.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { normalizeFolderWorkspaces } from './folder-workspaces' +import type { ProjectGroup } from './project-group-types' + +const folderGroup = { + id: 'group-1', + name: 'Projects', + parentPath: '/tmp/projects', + connectionId: null +} as unknown as ProjectGroup + +describe('normalizeFolderWorkspaces host attribution', () => { + it('drops a stored executionHostId instead of round-tripping it', () => { + const [workspace] = normalizeFolderWorkspaces( + [ + { + id: 'ws-1', + projectGroupId: 'group-1', + name: 'Nightly', + folderPath: '/tmp/projects/nightly', + connectionId: null, + executionHostId: 'runtime:env-7' + } + ], + [folderGroup] + ) + + // A runtime-scoped stamp names an authority the desktop store does not own, and it + // carries no generation to fence on — persisting it would recreate the divergence #12 fixed. + expect(workspace).toBeDefined() + expect(workspace.executionHostId).toBeUndefined() + expect(Object.keys(workspace)).not.toContain('executionHostId') + }) + + it('keeps connectionId as the durable host pin', () => { + const [pinned] = normalizeFolderWorkspaces( + [ + { + id: 'ws-2', + projectGroupId: 'group-1', + name: 'Pinned', + folderPath: '/tmp/projects/pinned', + connectionId: 'ssh-box', + executionHostId: 'local' + } + ], + [folderGroup] + ) + + expect(pinned.connectionId).toBe('ssh-box') + expect(pinned.executionHostId).toBeUndefined() + }) + + it('inherits the group connection when the workspace omits one', () => { + const [inherited] = normalizeFolderWorkspaces( + [{ id: 'ws-3', projectGroupId: 'group-1', name: 'Inherited' }], + [{ ...folderGroup, connectionId: 'ssh-group' } as ProjectGroup] + ) + + expect(inherited.connectionId).toBe('ssh-group') + }) +}) diff --git a/src/shared/folder-workspaces.ts b/src/shared/folder-workspaces.ts index 97409bd271d..32617981912 100644 --- a/src/shared/folder-workspaces.ts +++ b/src/shared/folder-workspaces.ts @@ -57,6 +57,9 @@ export function normalizeFolderWorkspaces( const linkedTaskSourceContext = normalizeStoredTaskSourceContext(raw.linkedTaskSourceContext) const creatorProvenance = normalizeWorkspaceCreatorProvenance(raw.creatorProvenance) seen.add(raw.id) + // Why: `executionHostId` is deliberately NOT persisted. It is a fetch-time renderer + // stamp that can name a `runtime:*` authority the desktop store does not own, and it + // carries no generation to fence on. `connectionId` below is the durable pin main projects from. workspaces.push({ id: raw.id, projectGroupId: raw.projectGroupId, diff --git a/src/shared/pairing-local-ui-fields.test.ts b/src/shared/pairing-local-ui-fields.test.ts index 41545abb378..ab837782783 100644 --- a/src/shared/pairing-local-ui-fields.test.ts +++ b/src/shared/pairing-local-ui-fields.test.ts @@ -6,6 +6,7 @@ describe('pairing-local UI fields', () => { // preload, so a field added here without wiring every seam would otherwise ship silently. it('census: the set is exactly the fields no pairing may exchange', () => { expect([...PAIRING_LOCAL_UI_FIELDS]).toEqual([ + 'automationHostFilter', 'hideWorkspacesFromOtherDevices', 'manualRepoOrder', 'workspaceHostOrder' diff --git a/src/shared/pairing-local-ui-fields.ts b/src/shared/pairing-local-ui-fields.ts index 453a16eb0ee..55865416c01 100644 --- a/src/shared/pairing-local-ui-fields.ts +++ b/src/shared/pairing-local-ui-fields.ts @@ -6,6 +6,9 @@ import type { PersistedUIState } from './persisted-ui-state-types' // the receiver's answer with one computed for somebody else. They must be stripped on the way to a // host (ui.set) and pinned to the local value on the way back (ui.get). export const PAIRING_LOCAL_UI_FIELDS = [ + // Its hostKey names authorities only the writing client can resolve — a paired + // client's `desktop` is a different machine — and old hosts reject the unknown key. + 'automationHostFilter', 'hideWorkspacesFromOtherDevices', 'manualRepoOrder', 'workspaceHostOrder' diff --git a/src/shared/persisted-state-types.ts b/src/shared/persisted-state-types.ts index ee6d1f97fa7..6266f8bc08f 100644 --- a/src/shared/persisted-state-types.ts +++ b/src/shared/persisted-state-types.ts @@ -90,6 +90,8 @@ export type PersistedState = { /** Per-execution-host session partitions for non-'local' hosts (ssh:/runtime:); 'local' stays in workspaceSession so pre-partition builds keep working. */ workspaceSessionsByHostId?: Partial> sshTargets: SshTarget[] + /** Highest SSH target registration generation issued; prevents identity reuse after rollback. */ + sshTargetGenerationCounter?: number /** SSH config aliases the user deleted; suppresses re-import from ~/.ssh/config so a deleted host doesn't reappear. */ deletedSshConfigAliases: string[] /** Identity records for removed SSH targets so a re-added host can re-adopt workspaces orphaned on the old target id. */ diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts index 1db7f8e5dfc..2d5f635477c 100644 --- a/src/shared/persisted-ui-state-types.ts +++ b/src/shared/persisted-ui-state-types.ts @@ -22,6 +22,7 @@ import type { WorktreeCardProperty } from './ui-chrome-types' import type { WorkspaceStatusDefinition } from './worktree/types' +import type { PersistedAutomationHostFilter } from './automation-host-filter' export type PersistedUIState = { lastActiveRepoId: string | null @@ -49,6 +50,8 @@ export type PersistedUIState = { visibleWorkspaceHostIds?: VisibleWorkspaceHostIds /** User-defined sidebar order for host sections; missing/new hosts append in discovered order. */ workspaceHostOrder?: WorkspaceHostOrder + /** Automations page host filter. Stores only a canonical host key; invalid values degrade to all hosts. */ + automationHostFilter?: PersistedAutomationHostFilter /** Desktop-owned all-host repo order; host-qualified identities keep a manual cross-host interleaving while each host owns its local permutation. */ manualRepoOrder?: ManualRepoOrderEntry[] /** Deprecated legacy positive-form setting. Ignored on hydration. */ diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index f7db5474bff..0c4e45b5bcd 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -125,6 +125,15 @@ export const WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY = 'worktree.visibility-defaults.v1' as const export const WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY = 'worktree.visibility-source-defaults.v1' as const +// Why: older hosts drop automation.list's selector and answer with the whole authority, so a scoped client must not read that as one host's rows. +export const AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY = + 'automation.list-host-scope.v1' as const +export const AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE = + 'Filtering automations by host requires a newer Orca server. Update the HUB and try again.' +// Why: without server-side owner preconditions a mutation could run against a host the user never saw, so unfenced rows stay view-only. +export const AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY = 'automation.owner-fencing.v1' as const +export const AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE = + 'Editing automations on this host requires a newer Orca server. Update the HUB and try again.' // Generic native clients include the CLI and must not claim Electron-only page // placement support. @@ -132,7 +141,8 @@ export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, - WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY + WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY ] as const // Electron clients can decode client-hosted page placement; becoming a page @@ -197,7 +207,9 @@ export const RUNTIME_CAPABILITIES = [ SKILL_UPLOAD_CAPABILITY, SKILL_MANAGEMENT_CAPABILITY, SKILL_INSTALL_PROVIDERS_CAPABILITY, - SKILL_DELETE_CAPABILITY + SKILL_DELETE_CAPABILITY, + AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY ] as const export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {}) diff --git a/src/shared/remote-runtime-client-capabilities.ts b/src/shared/remote-runtime-client-capabilities.ts index cfdc0360c7e..5efaf0aed9d 100644 --- a/src/shared/remote-runtime-client-capabilities.ts +++ b/src/shared/remote-runtime-client-capabilities.ts @@ -1,5 +1,6 @@ import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, @@ -18,6 +19,7 @@ export function remoteRuntimeClientCapabilities( SKILL_INSTALL_RESULT_V2_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, ...additionalCapabilities ]) ) diff --git a/src/shared/runtime-client-events.ts b/src/shared/runtime-client-events.ts index c849f2f6630..a9d92b5a57d 100644 --- a/src/shared/runtime-client-events.ts +++ b/src/shared/runtime-client-events.ts @@ -25,6 +25,13 @@ export type RuntimeClientEvent = ptyIds: string[] terminalHandles: string[] } + // Why: automation stores are authority-owned, so clients cannot learn about a + // run/usage/definition write without the owning authority announcing it. + | { + type: 'automationsChanged' + selector?: { kind: 'self' } | { kind: 'ssh'; targetId: string } | { kind: 'orphan' } + reason?: 'definition' | 'run' | 'usage' + } | { type: 'linearLinkedIssueUpdated' worktreeId: string @@ -52,6 +59,13 @@ export type RuntimeClientEventStreamMessage = export type RuntimeActivateWorktreeEvent = Extract +export type AutomationsChangedEvent = Extract + +/** Publisher payload; `selector` scoping is added by a later host-scope step. */ +export type AutomationsChangedPayload = Omit + +export type PublishAutomationsChanged = (payload: AutomationsChangedPayload) => void + export function toRuntimeActivateWorktreeEvent( repoId: string, worktreeId: string, diff --git a/src/shared/runtime-rpc-error-code.ts b/src/shared/runtime-rpc-error-code.ts new file mode 100644 index 00000000000..328df8402ab --- /dev/null +++ b/src/shared/runtime-rpc-error-code.ts @@ -0,0 +1,66 @@ +/** + * Classifies a runtime RPC failure by its machine token. + * + * Shared rather than renderer-owned because every client reads the same + * flattened shapes: Electron IPC rewraps the message ("Error invoking remote + * method 'x': Error: …") and a host that does not know an error class maps it + * to `runtime_error` with the token left on the message. A client that matches + * only `.code` therefore classifies a subset of what it receives. + */ + +// Why: transports re-wrap the token into a longer message and drop the cause, so the +// token classifies only as the whole message or after a real message boundary (": " or a +// newline) — prose that merely trails off in the token must not classify. +const CODE_TOKEN_BOUNDARY = /(?:: |\n)[ \t]*$/ + +function endsWithCodeToken(text: string, expectedCode: string): boolean { + const trimmed = text.trimEnd() + if (!trimmed.endsWith(expectedCode)) { + return false + } + const prefix = trimmed.slice(0, -expectedCode.length) + return prefix.trim() === '' || CODE_TOKEN_BOUNDARY.test(prefix) +} + +export function hasRuntimeRpcErrorCode(error: unknown, expectedCode: string): boolean { + const seen = new Set() + let current = error + while (!seen.has(current)) { + if (typeof current === 'string') { + return endsWithCodeToken(current, expectedCode) + } + if (!current || typeof current !== 'object') { + return false + } + seen.add(current) + const candidate = current as { + cause?: unknown + code?: unknown + message?: unknown + error?: { code?: unknown; message?: unknown } + response?: { error?: { code?: unknown; message?: unknown } } + } + // Machine tokens are checked before messages so a subclass with a human-readable message still classifies by code. + if ( + candidate.code === expectedCode || + candidate.error?.code === expectedCode || + candidate.response?.error?.code === expectedCode + ) { + return true + } + const messages = [ + candidate.message, + candidate.error?.message, + candidate.response?.error?.message + ] + if ( + messages.some( + (message) => typeof message === 'string' && endsWithCodeToken(message, expectedCode) + ) + ) { + return true + } + current = candidate.cause + } + return false +} diff --git a/src/shared/ssh-target-generation.test.ts b/src/shared/ssh-target-generation.test.ts new file mode 100644 index 00000000000..c65aa56f549 --- /dev/null +++ b/src/shared/ssh-target-generation.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { + nextSshTargetGeneration, + resolveSshTargetGenerationHighWaterMark, + sanitizeSshTargetGeneration +} from './ssh-target-generation' + +describe('sanitizeSshTargetGeneration', () => { + it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, '3', null, undefined, {}])( + 'rejects %p', + (value) => { + expect(sanitizeSshTargetGeneration(value)).toBeUndefined() + } + ) + + it('accepts issued generations', () => { + expect(sanitizeSshTargetGeneration(1)).toBe(1) + expect(sanitizeSshTargetGeneration(42)).toBe(42) + }) +}) + +describe('resolveSshTargetGenerationHighWaterMark', () => { + it('never reissues a generation an automation already captured', () => { + // The counter rolled back to 2, but an automation is fenced on 9. + const highWaterMark = resolveSshTargetGenerationHighWaterMark({ + persistedCounter: 2, + targetGenerations: [1, 2], + capturedGenerations: [9] + }) + expect(highWaterMark).toBe(9) + expect(nextSshTargetGeneration(highWaterMark)).toBe(10) + }) + + it('never reissues a generation a stored target already carries', () => { + expect( + resolveSshTargetGenerationHighWaterMark({ + persistedCounter: 0, + targetGenerations: [7], + capturedGenerations: [] + }) + ).toBe(7) + }) + + it('keeps the persisted counter when it leads', () => { + expect( + resolveSshTargetGenerationHighWaterMark({ + persistedCounter: 12, + targetGenerations: [3], + capturedGenerations: [5] + }) + ).toBe(12) + }) + + it('ignores corrupt values on every input', () => { + expect( + resolveSshTargetGenerationHighWaterMark({ + persistedCounter: Number.NaN, + targetGenerations: [undefined, -4, 2.5], + capturedGenerations: [null] + }) + ).toBe(0) + expect(nextSshTargetGeneration(0)).toBe(1) + }) +}) diff --git a/src/shared/ssh-target-generation.ts b/src/shared/ssh-target-generation.ts new file mode 100644 index 00000000000..1696789b1d0 --- /dev/null +++ b/src/shared/ssh-target-generation.ts @@ -0,0 +1,57 @@ +/** + * Monotonic allocation of SSH target *registration* generations. + * + * A generation identifies one registration incarnation of a target ID: it + * advances when a target is created, deleted and re-created, or explicitly + * re-adopted. Connect, reconnect, and status transitions never advance it — + * that is `SshConnectionState.connectionGeneration`, a different counter. + * + * The persisted counter is the *highest generation ever issued*. On load it is + * reloaded as a high-water mark over the counter, every stored target, and + * every generation an automation already captured, so a counter lost to a + * rollback can never reissue a generation an automation is still fenced on. + */ + +export const FIRST_SSH_TARGET_GENERATION = 1 + +/** Accept only values that could have been issued; anything else is treated as absent. */ +export function sanitizeSshTargetGeneration(value: unknown): number | undefined { + return typeof value === 'number' && + Number.isSafeInteger(value) && + value >= FIRST_SSH_TARGET_GENERATION + ? value + : undefined +} + +export type SshTargetGenerationHighWaterInput = { + /** Highest generation the persisted counter claims to have issued. */ + persistedCounter?: number | null + /** Generations stamped on stored SSH targets. */ + targetGenerations: Iterable + /** Generations captured by stored automations — the reason a rollback cannot be trusted. */ + capturedGenerations: Iterable +} + +function highest(values: Iterable, seed: number): number { + let result = seed + for (const value of values) { + const sanitized = sanitizeSshTargetGeneration(value) + if (sanitized !== undefined && sanitized > result) { + result = sanitized + } + } + return result +} + +/** Highest generation considered already issued; the next allocation is this + 1. */ +export function resolveSshTargetGenerationHighWaterMark( + input: SshTargetGenerationHighWaterInput +): number { + const seed = sanitizeSshTargetGeneration(input.persistedCounter) ?? 0 + return highest(input.capturedGenerations, highest(input.targetGenerations, seed)) +} + +export function nextSshTargetGeneration(highWaterMark: number): number { + const sanitized = sanitizeSshTargetGeneration(highWaterMark) ?? 0 + return sanitized + 1 +} diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index fd6a9a3bcb0..3474f1e0380 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -53,10 +53,18 @@ export type SshTarget = { /** Reuse a system OpenSSH connection across setup commands. Undefined means * enabled; false is an explicit per-target compatibility opt-out. */ systemSshConnectionReuse?: boolean + /** Durable registration incarnation. Advances on create / re-create / explicit + * re-adopt only, so automations fenced on an old registration cannot run on a + * later target that happens to reuse the id. Never advanced by connect state. */ + generation?: number } +/** Renderer-authored target fields; registration generations are allocated and owned by main. */ +export type SshTargetCreateInput = Omit +export type SshTargetUpdateInput = Partial + /** Public target identity safe to mirror to a paired client. */ -export type SshTargetSummary = Pick +export type SshTargetSummary = Pick /** Identity of a removed SSH target, recorded so that re-adding the same host * can re-point orphaned repos/worktrees from the old (deleted) target id to diff --git a/tests/e2e/helpers/startup-exec-readiness-oracle.ts b/tests/e2e/helpers/startup-exec-readiness-oracle.ts index 0ef9a7eff67..577702c8ea0 100644 --- a/tests/e2e/helpers/startup-exec-readiness-oracle.ts +++ b/tests/e2e/helpers/startup-exec-readiness-oracle.ts @@ -76,6 +76,10 @@ function count(text: string, marker: string): number { return text.split(marker).length - 1 } +function isTransientPtyLivenessError(error: unknown): boolean { + return error instanceof Error && error.message.includes('terminal_liveness_unavailable') +} + async function expectSingleOwningPty( page: Page, worktreeId: string, @@ -86,17 +90,24 @@ async function expectSingleOwningPty( await expect .poll( async () => { - const listed = await callStartupExecRuntime( - page, - 'terminal.list', - { - worktree: `id:${worktreeId}`, - requireFreshPtyLiveness: true + try { + const listed = await callStartupExecRuntime( + page, + 'terminal.list', + { + worktree: `id:${worktreeId}`, + requireFreshPtyLiveness: true + } + ) + return listed.terminals + .filter((candidate) => candidate.tabId === tabId) + .map((candidate) => ({ handle: candidate.handle, ptyId: candidate.ptyId })) + } catch (error) { + if (isTransientPtyLivenessError(error)) { + return [] } - ) - return listed.terminals - .filter((candidate) => candidate.tabId === tabId) - .map((candidate) => ({ handle: candidate.handle, ptyId: candidate.ptyId })) + throw error + } }, { timeout: 30_000 } ) diff --git a/tests/e2e/ssh-terminal-parking.spec.ts b/tests/e2e/ssh-terminal-parking.spec.ts index 85a45e8b270..2be9092212c 100644 --- a/tests/e2e/ssh-terminal-parking.spec.ts +++ b/tests/e2e/ssh-terminal-parking.spec.ts @@ -99,6 +99,23 @@ test.describe('SSH terminal hidden view parking', () => { message: 'SSH pad output did not finish before parking' }) .toBe(true) + // The renderer can paint a chunk before the main-owned model ingests it. + // Wait for that model before parking, which is the source this test verifies. + await expect + .poll( + () => + orcaPage.evaluate(async (ptyId) => { + const snapshot = await window.api.pty.getMainBufferSnapshot(ptyId, { + scrollbackRows: 5_000 + }) + return snapshot?.data ?? '' + }, sshPtyId), + { + timeout: 60_000, + message: 'SSH headless model did not ingest the pad before parking' + } + ) + .toContain(`${marker}_PAD_DONE:`) await parkHiddenTabBehindDecoy(orcaPage, remote.worktreeId, sshTabId, { parkDelayMs: PARKING_DELAY_MS From 3fbed6612eb31345024d5d72ecdb2fa9c4f282d3 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:31:56 -0700 Subject: [PATCH 002/152] docs: remove tracked design plans (#16663) --- .gitignore | 3 - ...-skill-sharing-implementation-checklist.md | 2 - docs/agent-skill-sharing-installation-plan.md | 1601 ----------------- docs/ai-vault-process-isolation-plan.md | 596 ------ docs/automations-all-hosts-design.md | 530 ------ docs/mobile-relay-ux-findings.md | 442 ----- 6 files changed, 3174 deletions(-) delete mode 100644 docs/agent-skill-sharing-installation-plan.md delete mode 100644 docs/ai-vault-process-isolation-plan.md delete mode 100644 docs/automations-all-hosts-design.md delete mode 100644 docs/mobile-relay-ux-findings.md diff --git a/.gitignore b/.gitignore index a46e4f060f8..efaee0a2e38 100644 --- a/.gitignore +++ b/.gitignore @@ -98,9 +98,6 @@ docs/** !docs/readme/** !docs/STYLEGUIDE.md !docs/agent-skill-sharing-implementation-checklist.md -!docs/agent-skill-sharing-installation-plan.md -!docs/ai-vault-process-isolation-plan.md -!docs/automations-all-hosts-design.md !docs/mobile-terminal-shortcut-bar.md !docs/reference/ !docs/reference/git-compatibility.md diff --git a/docs/agent-skill-sharing-implementation-checklist.md b/docs/agent-skill-sharing-implementation-checklist.md index 2ff54d16a7e..91eecf55990 100644 --- a/docs/agent-skill-sharing-implementation-checklist.md +++ b/docs/agent-skill-sharing-implementation-checklist.md @@ -314,8 +314,6 @@ Windows/WSL run and native-Windows staging lifecycle; this does not treat WSL se identical to macOS. The user-driven signed-in desktop and real-host production journey, supported Windows SSH, and the quarantine lifecycle deletion remain. The shared staging data plane is asleep. -Source plan: [Agent skill sharing and installation plan](./agent-skill-sharing-installation-plan.md). - This checklist turns the architecture plan into ordered implementation and release work. A checked item means evidence exists in code, tests, reviewed infrastructure, or release documentation; it does not mean the surrounding phase is complete. diff --git a/docs/agent-skill-sharing-installation-plan.md b/docs/agent-skill-sharing-installation-plan.md deleted file mode 100644 index bfda3cab054..00000000000 --- a/docs/agent-skill-sharing-installation-plan.md +++ /dev/null @@ -1,1601 +0,0 @@ -# Agent skill sharing and installation plan - -Status: implementation and validation in progress. - -Last updated: 2026-08-12. - -Implementation checklist: -[Agent skill sharing implementation checklist](./agent-skill-sharing-implementation-checklist.md). - -Architecture decision: -[Agent skill sharing upstream boundary](./reference/agent-skill-sharing-upstream-boundary.md). - -Provider registry: -[Agent skill provider paths](./reference/agent-skill-provider-paths.md). - -## Decision summary - -Orca will own a focused Skill Bundle packaging and installation pipeline for private sharing. A -bundle contains one or many skills; sharing one skill is the same flow with one selected item. - -The community [`vercel-labs/skills`](https://github.com/vercel-labs/skills) project will -remain a behavioral reference, not a runtime dependency or source donor. Its npm package -exposes a CLI rather than a supported programmatic API, and its install/update behavior does -not provide the transaction, provenance, or private-package semantics Orca needs. - -Orca will: - -1. Package one or many local skills into one immutable, content-addressed artifact and durable - link. -2. Store private artifacts behind unlisted, revocable Orca Cloud share records. -3. Treat possession of an active share link as recipient authorization, then resolve it into a - short-lived download grant. -4. Execute installation on the machine that will use the skill. -5. Validate, stage, commit, and verify the package without silently overwriting local work. -6. Let the recipient install all or a selected subset, with conflicts and results reported per - skill. -7. Install each selected skill once into `.agents/skills` and add aliases only for detected agents - that need provider-specific paths. -8. Record bundle/version provenance independently from the community CLI's lockfiles. -9. Keep every share unlisted: bundles are reachable only through their durable link and never - appear in search, browse, organization-library, or marketplace surfaces. -10. Give authenticated owners a private active-link inventory for copy and revocation; this is a - management surface, not recipient discovery. - -The portable archive root follows Agent Plugins 1.0.0 for a skills-only package. Orca does not -present Skill Bundles as a new executable plugin system: MCP servers, hooks, processes, connectors, -and permissions are out of scope. Loose-skill installation remains the universal/default delivery -path, with provider-native plugin adapters generated only where they improve compatibility. - -The first release will support Orca package sources only. Arbitrary Git, npm, and community -registry sources remain delegated to existing tools until there is a product need to own -that larger package-manager surface. - -## Current execution status - -The Orca implementation is on `skills-share` through `2a0b7b2acf`; no Orca pull request exists. -The local working tree also contains the install-IPC compatibility fix and the in-progress Skills -UI overhaul. Cloud bundle ingestion, bearer links, recovery, load testing, production bootstrap, -OIDC smoke, and monitor ownership merged through `stablyai/orca-cloud#361`. Final infrastructure -hardening merged in `#364` as `57c4958979fc32ed4753959cea45edd3a7c8775c`. - -Production is live. Two reviewed targeted Terraform phases added the dedicated app deploy identity, -restricted both GitHub WIF providers to exact workflows, added resource-scoped API/Auth/monitor and -Relay bindings, then removed five broad legacy bindings. A separate exact plan updated only the -bearer request-log exclusion and all eight skill alert policies; the alerts use the dedicated -`Orca skill sharing alerts` email channel. No full production plan was applied because it contains -unrelated Relay drift. - -Production deploy run `31661421728` promoted `orca-cloud-api-00025-qup` at 100% traffic from merged -`main`, using immutable digest -`sha256:e4f044105ff2345574bdceb36eec1629761428cc2edf9d743939390b857d61e0`. Candidate and canonical -authenticated artifact and skill smoke passed, `/health` and `/ready` are green, and the direct -unlisted landing route returns only generic install copy plus the Orca deep link with `no-store`, -`no-referrer`, and restrictive CSP headers. Monitor execution -`orca-cloud-skill-storage-monitor-lfd26` passed on the same digest and emitted only aggregate -inventory. The implementation checklist records the remaining desktop and physical-host gates. - -The final reliability hardening gives every skill Cloud request a default deadline, makes bundle -extraction cancellable and recoverable through the durable extraction journal, bounds package and -preview fanout, atomically caps concurrent share preparation, removes abandoned private staging, -and waits for SSH transaction recovery before install management. Paired clients advertise the -additive install-result capability so cancellation remains cancellation across mixed versions. -Regression coverage includes a real killed child process with partially extracted private bytes -and bounded 17-skill remote preview fanout. Full Node, CLI, and web typechecks plus repository lint -and reliability gates pass at that baseline. - -The final combined physical `windows 2` run at `44d1266641` passed 449 tests across 67 files with -17 intentional platform skips while native-Windows, real-process, and Ubuntu 24.04 WSL coverage -were enabled together. Windows harness fixes at `97b831dd17` made recovery evidence path-semantic -and time-deterministic. The run exposed and fixed a WSL alias edge at `44d1266641`: when native UNC -`lstat` misses an existing distro symlink, Orca now verifies the alias through WSL and uses -`ln -sT` for creation. The Windows checkout was clean and WSL `/tmp` had no test debris. Only -Ubuntu 24.04 is installed; second-distro permutations are post-launch rather than a first-release -gate. - -The release workflow now runs the exact unpacked Linux package under Electron-as-Node in an -Ubuntu 20.04 container and requires packaged `node-pty` to spawn `/bin/sh`. A physical x64 build at -`f1dccb4f42` passed the existing 18-binary ABI scan and this load/spawn smoke. A native ARM64 build -at `abe92d565b` then passed the same scan for all 18 bundled binaries and emitted -`orca-node-pty-floor-ok` from an ARM64 Ubuntu 20.04 container on native `aarch64` hardware. The -isolated ARM64 GCE builder had no service account or scopes, remained clean at the exact source -commit, and was deleted with its auto-delete boot disk after verification. The physical runs also -caught and corrected assumptions about the Linux executable name and packaged runtime-module -location before the gate protects a real release. - -Cloud verification now requires a disposable PostgreSQL 16/17 migration-and-restore drill. An old -migration process remains connected while a future-additive process advances the schema; both -write successfully, the database is dumped and restored, and both write again against the restored -copy. PR run `31624484826` and merged-main run `31624717796` passed all code, Terraform, and -database jobs. The drill is localhost-only, cleans both schemas and its backup, and does not touch -staging or production services. - -The local redesign now has an independently implemented Agent Plugins 1.0.0 skills-only bundle -manifest, deterministic one-or-many archive creation, bounded bundle extraction, additive bundle -install contracts, per-skill conflict/results orchestration, and bundle provenance on installed -skill receipts. The recipient dialog now recognizes bundle versions, defaults to all skills, -supports subset selection, previews aggregate destination state, keeps conflicts local by default, -and groups per-skill outcomes with failed-only retry. Local and paired-runtime bundle grants use the -additive bundle RPC with staged-upload fallback. SSH now uses a separately advertised additive -bundle relay method with the same direct-download and staged-upload fallback; older hosts are -rejected before transfer. The original single-skill path remains active for legacy shares while -bundle management migrates. - -Cloud bundle ingestion accepts both envelopes during migration and stores the detailed bundle -manifest with the immutable version. Active share IDs are the recipient credential, owner -operations remain authenticated, and legacy ACL rows remain only as migration storage. A -Terraform-managed Cloud Logging exclusion prevents per-link Cloud Run request URLs from being -stored while privacy-safe route-template telemetry remains available. - -The latest resilience pass adds bounded convergence after a lost final install response for both -paired runtimes and SSH. A staged retry creates a new upload instead of reusing a consumed upload. -SSH now carries only schema-validated structured skill failures through optional JSON-RPC -`error.data`, while older peers continue to ignore the field and newer clients fall back safely -when it is absent. Invalid gzip input has a stable archive category, and deterministic `EACCES` -and `ENOSPC` injection proves failed updates preserve the previous installed version. - -Desktop skill operations now have privacy-bounded local diagnostic spans for package creation, -Cloud upload and finalization, grant download, client-mediated runtime and SSH transfer, single -and bundle installation, provider placement, transaction rollback settlement, and startup -recovery. They record aggregate bytes/files, phase duration, OS, destination and transport, -conflict/topology/mechanism outcomes, copy fallback, capability absence, recovery/orphan counts, -and bounded error categories without paths, skill names, contents, share URLs, grants, host IDs, -or credentials. Bundle summaries are aggregate and cap error-category cardinality at 32. This is -not product telemetry and changes no RPC, persisted state, or remote opcode, so old clients and -hosts remain compatible. The release suite passes 427 tests, full typecheck/lint pass, and the -real-process macOS crash-recovery matrix passes all 17 boundaries; physical platform reruns remain -tracked in the checklist. - -The dedicated staging bucket, IAM, database, principal, enabled secret version, metrics, dashboard, -and alerts exist in `onorca-cloud-staging`. The complete skill-infrastructure target is zero-diff. -After `#314`, the targeted Auth/API plan also reports zero intended changes. Known shared Cloud SQL -and artifact-bucket drift was excluded and never applied. Staging was awakened only through guarded -run `31534564230`. Sleep run `31536547916` returned all three cells to zero; read-only status run -`31537525219` independently confirmed SQL policy `NEVER`, zero MIG targets, and zero managed Cloud -Run active-revision minimums. - -The API release workflow now deploys an immutable no-traffic candidate with the complete artifact -and skill environment plus exactly one approved skill-database secret. It verifies the inherited -Terraform runtime identity, Cloud SQL attachment, scaling, resources, volumes, mounts, ports, and -probes before promotion, and rolls back to a re-smoked prior revision on failure. Staging now has -all four controls enabled; production keeps all four disabled. Database migration startup uses a -transaction-scoped advisory lock; transactional rollback and eight concurrent callers pass against -PostgreSQL 16 and 17. - -Cloud PR `#344` passed both required checks and merged as `fcf8655a`. It adds privacy-bounded -migration-ready and migration-failure metrics, a migration lifecycle dashboard panel, and a -zero-tolerance migration-failure alert. A read-only targeted staging plan creates only those -metrics and alert, updates the existing skill dashboard, reruns descriptor propagation, and -destroys no infrastructure; staging remained asleep and nothing was applied. - -Cloud PR `#345` passed both required checks and merged as `06a5c729`. It completes the initial API -dashboard coverage with explicit finalization-saturation and archive-rejection series and displays -successful and failed operations as rates. Malformed compression errors now become the bounded -`skill_package_archive_invalid` category before they reach persistence, responses, or telemetry. - -Cloud PR `#346` passed both required checks and merged as `8199c048`. The Cloud Run dashboard and -alerts now cover route-template-scoped p99 skill latency, server errors, CPU, memory, and active -instance pressure. Alerts require five minutes above 80% p99 CPU/memory, more than 80% of the -configured instance ceiling, five-second interactive p99, or 30-second finalization p99; no bearer -URL or identifier becomes a metric label. - -Cloud PR `#347` passed both required checks and merged as `65320475`. It adds a six-hour aggregate -storage inventory job, fixed-namespace bytes and object-count metrics, overdue-quarantine and job -failure alerts, and storage dashboard panels. The monitor identity has only -`storage.objects.list`; its scheduler identity can invoke only that job. A reviewed targeted -staging apply added 18 observability resources, updated the existing dashboard, and replaced only -the inert metric-propagation timer. The post-apply targeted plan is zero-diff. A manual execution -completed successfully and emitted only aggregate published and quarantine measurements, with -zero overdue objects and no identifiers or paths. SQL remained `NEVER`/`STOPPED`, all three Relay -MIG targets remained zero, and production remained disabled and untouched. - -A separate read-only production prerequisite check confirmed that -`onorca-cloud-skill-packages` is currently unallocated, the production skill database and secret -names do not collide, and the intended API service and runtime identity already exist. Cloud Run -quota headroom exceeds the existing 20-instance API ceiling; V1 adds no database instance and -requires no quota increase. No production resource was planned or changed. - -Auth deploy run `31535179937` promoted `orca-cloud-auth-staging-00017-dug` with exact staging-only -GitHub OIDC constraints, healthy JWKS, and zero deployment-window errors. API run `31535438327` -promoted `orca-cloud-api-staging-00042-hef` with `authenticatedSmoke: true` and `skillSmoke: true`. -The ten-minute, non-refreshable smoke principals exercised artifact CRUD and skill upload, -finalize, immutable versions, recipient/outsider authorization, local and remote grants, rollback, -expiry, revocation, deletion, and signed-object cleanup. Structured skill logs exposed only -privacy-safe request metadata and produced zero sensitive-value matches. - -Cloud PR `#320` passed PR run `31564069382`, merged-`main` run `31564235724`, and a targeted -staging logging-exclusion plan of one addition, zero changes, and zero deletions. Guarded wake run -`31564370807` prepared staging. Deploy run `31564803943` promoted -`orca-cloud-api-staging-00051-yoq` at 100% traffic with immutable image digest -`sha256:511c0196511d2079bd9138092ad3cf065304b46a089b02d8df9318e0ae2e656a`. Candidate and canonical -smoke covered owner upload/finalize/share creation/inventory/revocation and anonymous preview, -local/remote download grants, version selection, expiry, uniform unavailable responses, package -deletion, and cleanup. Requests with no Authorization header and an invalid header returned the -same non-cached `404`; the serving revision produced zero errors, and Cloud Logging retained zero -per-link platform request logs. Cloud PR `#329` and desktop run `31569902499` subsequently passed -the complete browser-free publish/install/conflict/update/rollback/revoke/remove/delete journey. -Cloud PR `#330` merged as `8045c85dad`; deploy `31579844413` promoted revision -`orca-cloud-api-staging-00057-kat`, and recovery smoke `31580071168` proved generation-aware GCS -soft-delete restoration, transactional reference repointing, bearer download, and cleanup. -Guarded sleep `31580339694` restored SQL to `NEVER`/`STOPPED` and all Relay MIG targets to zero. -Load run `31585710645` then exercised 12 concurrent 30-skill bundles at roughly 3.95 MiB extracted -per bundle. Explicit saturation bounded ten requests while two completed; API, Cloud SQL, instance, -and latency metrics stayed far below their reviewed thresholds. PR `#335` made retry settlement -cleanup-safe, and run `31586684354` removed the exact failed-wave package set before two -manifest-proven quarantine generations were deleted with GCS preconditions. Guarded sleep -`31587083752` then returned SQL to `NEVER`/`STOPPED`, all three stable Relay MIGs to zero, and every -active Cloud Run revision minimum to zero. Guarded wake `31589384191` later restored only the two -configured Relay cells, and Auth deploy `31589963244` promoted -`orca-cloud-auth-staging-00025-zuz`. The browser-free physical `windows 2` staging journey then -passed publish, native Windows install, update, independent local version selection, rollback, -revocation preservation, removal, and Cloud deletion using encrypted credential run -`31591275227`; its ciphertext artifact and one-time local key material were deleted immediately. -Guarded wake `31603249983` later restored only the two configured cells. A disposable -no-service-account Ubuntu 20.04/glibc 2.31 VM passed the full live SSH staging lifecycle in 31.3 -seconds, including host-owned install, update, rollback, revocation preservation, removal, and -Cloud deletion. The encrypted credential artifact, one-time keys, Orca SSH target, remote install, -and VM were removed. Guarded sleep `31604391897` passed and independent reads confirmed SQL -`NEVER`/`STOPPED` plus all three Relay MIGs at stable target zero. Guarded wake `31605729090` then -enabled the same topology for an isolated headless paired host with a separate home/profile. The -full paired-runtime staging lifecycle passed in 12.5 seconds without local fallback, and its -environment, install, Cloud package, encrypted credential artifact, and one-time keys were -removed. Guarded sleep `31606600532` passed with the same independently verified low-cost state. -The owner accepted the additional live physical WSL Cloud journey as duplicate release evidence on -2026-08-12 after the combined 449-test Windows/WSL matrix and native-Windows Cloud lifecycle passed; -this does not collapse WSL semantics into macOS. Remaining staging gates are physical SSH -macOS/Windows and the time-gated quarantine lifecycle deletion. The Windows/WSL release matrix is -green. -Guarded wake retry `31630802215` succeeded before that decision. The first cleanup attempt -`31631288043` failed closed during Terraform provider download on an upstream `503`, before its -mutation step; guarded sleep retry `31631379891` succeeded. Independent reads verified SQL -`NEVER`/`STOPPED`, all three Relay MIGs stable and reached at target zero with no active actions, -and zero minimum instances on each active API, Auth, and Relay revision. -Guarded wake `31634214856` later restored the configured topology for an isolated native macOS -ARM64 OpenSSH target whose home, keys, and relay state lived under one temporary root. The full -publish/install/update/rollback/revoke/remove/delete staging lifecycle passed in 20.1 seconds -without local fallback. The run also corrected the test oracle to support valid absolute POSIX SSH -homes outside `/home`. The encrypted GitHub artifact was deleted immediately; the SSH service, -relay, install, temporary home, and one-time key material were removed. Guarded sleep -`31635145830` passed, and independent reads confirmed SQL `NEVER`/`STOPPED`, every MIG stable at -zero with no active actions, and API/Auth/Relay minimum scale zero. Supported Windows SSH and the -time-gated quarantine deletion remain open. - -Cloud PR `#355` removed internal publisher and organization identifiers from recipient responses, -passed code, Terraform, and PostgreSQL 16/17 checks, and merged as -`b3213bd34d1b224d8a3b11527eceaac883965400`. Guarded wake `31639920301` prepared staging. Deploy -`31640677572` promoted `orca-cloud-api-staging-00060-qay` at 100% traffic with immutable digest -`sha256:31cb0a91a7abf1f82e4de08bd31e98fbd71519adc731a005fc95f60480658f73`; authenticated and skill -candidate/canonical smoke passed. Its unrelated post-promotion storage-monitor image update lacked -`iam.serviceAccounts.actAs`; the prior monitor image remains serving and unchanged. Guarded sleep -`31640935616` passed in 8m51s; independent reads verified SQL `NEVER`/`STOPPED`, all three Relay -MIGs stable and reached at target zero, and the API at minimum scale zero while revision -`00060-qay` retained 100% traffic. - -Production deployment completed on 2026-08-12. The exact skill-only Terraform apply created the -private bucket, database, secret, least-privilege identities, request-log exclusion, metrics, -alerts, and dashboard with 40 additions and zero updates, replacements, or deletions. Disabled-route -bootstrap run `31648015091` migrated the schema and promoted `orca-cloud-api-00016-yem` while all -four controls remained false. Cloud PR `#357` enabled all four controls in one launch. Cloud PR -`#358` replaced the missing long-lived production smoke secret with an exchange restricted to the -exact production `main` workflow, GitHub environment, dispatch event, repository/owner IDs, and -audience; issued principals expire in ten minutes and have no refresh credentials. Auth run -`31649381596` passed candidate and canonical health. API run `31650178315` passed candidate and -canonical authenticated artifact plus skill lifecycle smoke, promoted -`orca-cloud-api-00022-maq` to 100% traffic, and completed the aggregate monitor image update. All -four skill controls are true, the serving revision reported zero deployment-window errors, and -both API and Auth health are green. - -Cloud PR `#359` codified the deploy identity's `roles/iam.serviceAccountUser` grant only on the -read-only skill storage monitor identity. Its exact production apply added one resource with zero -changes or deletions, and the repeat targeted plan was zero-diff. Manual execution -`orca-cloud-skill-storage-monitor-bmm4q` completed successfully. Cloud PRs `#360` and `#361` keep -Terraform ownership of the monitor shape while assigning its immutable image and Cloud Run client -metadata to the release workflow; the exact production monitor plan is zero-diff after the live -image advance. The development Orca instance -`skill-sharing-manual` now points at `login.onorca.dev` and `cloud-api.onorca.dev`; sign-in and the -desktop/real-host production lifecycle remain user-driven validation. - -A final renderer lifecycle review fenced managed-install inventory by request generation so a -slower previous machine cannot overwrite the newly selected machine's list. It also moved bundle -busy propagation into the initiating event instead of mirroring state through an effect. The -renderer skill/settings slice passed 122 tests across 18 files plus all Node/CLI/web typechecks, -focused lint, changed-code quality, max-lines, diff, and localization gates. - -## Research baseline - -The upstream assessment used `vercel-labs/skills` commit -`c6f69c631292444cc541ac6d91e2226b0ff247da`. - -The portable bundle assessment used -[`agentplugins/agent-plugins-spec`](https://github.com/agentplugins/agent-plugins-spec) commit -`bd383552095128f6effe895b9257cfd580a6d179`, specifically `spec/1.0.0.md`. The local reviewed clone -is `/Users/jinwoo/refs/misc/agent-plugins-spec`; the example repository is -`/Users/jinwoo/refs/misc/agent-plugins-example`. Orca independently implements the reviewed -behavior. The specification prose is CC BY 4.0 and its schemas/software are Apache 2.0, so Orca -will not copy the prose or vendor the schemas unless the corresponding notices are deliberately -handled. - -Useful upstream behavior: - -- A canonical `.agents/skills/` location. -- A data set of agent-specific global and project paths. -- Relative symlink handling, Windows junction behavior, and copy fallback. -- Archive path, entry-count, and extracted-byte limits. -- Tests for aliases, broken links, and provider-specific placement. - -Behavior Orca will not inherit: - -- Deleting the destination before the replacement is ready. -- Updating without checking whether installed files were modified. -- Direct read/modify/write lockfile updates without durable publication. -- Reinstalling the canonical directory once per selected agent. -- Treating an expiring signed artifact URL as package identity. -- Coupling installation to prompts, terminal output, telemetry, and process exits. - -Orca will not copy upstream source, tests, fixtures, or registry entries. Provider paths will be -implemented independently from official provider documentation and verified installations. Under -that constraint, Orca does not incorporate upstream copyrightable material and needs no upstream -attribution. Revisit this decision if implementation work ever proposes copying material. - -## Goals - -- Share one or many private skills with a teammate through one durable, intuitive link. -- Select and review large bundles, including groups of 30 skills, without repetitive dialogs. -- Install on the local computer or any compatible connected Orca runtime. -- Support global scope and folder-workspace/project scope. -- Work on macOS, Linux, and Windows. -- Preserve executable bits and all files in the skill package. -- Prevent archive traversal, symlink escapes, special files, case collisions, and resource - exhaustion. -- Never silently replace an unowned or locally modified skill. -- Recover from interruption during package download, extraction, placement, alias creation, - or provenance publication. -- Keep share authorization separate from short-lived blob access. -- Support immutable versions, updates, removal, and eventual team-wide reconciliation. -- Remain compatible with independently updated desktop clients and remote Orca servers. -- Treat ordinary folder workspaces as first-class; Git is not required. -- Preserve enough portable metadata for export, SSH/remote transfer, and offline validation. - -## Non-goals for the first release - -- Reimplementing all `skills` CLI source parsing and provider integrations. -- Supporting all upstream agent paths on day one. -- Publishing package bytes through a permanent or directly addressable blob URL. -- Automatically installing a shared skill on every organization machine. -- Merging local modifications with a newer shared package version. -- Treating installation as a security sandbox. A skill contains instructions and scripts and - must be presented to the user as code from its author. -- Shipping a full plugin runtime, MCP servers, hooks, processes, connectors, or portable - permissions. -- Requiring a provider-native plugin installer; unsupported agents still receive loose skills. -- End-to-end encryption from Orca Cloud operators in the initial trust model. Private means - unlisted and accessible through an active bearer link, with authenticated owner management. - Application-level package encryption can be added later without changing package identity. - -## Product experience - -### Sharing - -The existing Skills page has three header actions: **Share skills**, **Install from link**, and -**Manage**. **Share skills** enters selection mode across installed and workspace skills. Search -and filter changes retain selections; **Select all results** is explicit and Orca never silently -selects everything. The existing per-card share action remains a one-skill shortcut. - -Ineligible skills are disabled with a visible reason. Duplicate skill names must be resolved before -publication because they would collide in both the archive and destination roots. - -The review dialog shows: - -- Bundle name and selected skill list. -- Skill, file, and byte counts. -- Included scripts and executable files, with an expandable review for each skill. -- The signed-in profile that will own and manage the share. -- A clear warning that anyone with the unlisted link can inspect and install the bundle. -- Optional version label and release notes. - -After confirmation, Orca validates and packages the exact bytes shown in the preview. Progress is -named **Preparing skills**, **Uploading**, **Verifying**, and **Publishing link**. Completion returns -one durable URL such as: - -```text -https://app.orca.dev/skills/share/ -``` - -The URL identifies a revocable bearer share record, not a blob-storage object or permanent GCS -grant. Recipients do not sign in: possession of an active, unexpired link authorizes inspection and -a short-lived download grant. Revocation blocks future resolution and grants, while already -installed copies remain. Publishing, listing owned shares, version management, revocation, and -deletion still require the owner to sign in. - -### Installing - -Opening a deep link launches Orca and inspects the bundle without installing it. Preview identifies -the immutable version, release notes, skill count, scripts, and executable files. V1 deliberately -omits publisher identity from the recipient response and UI; internal creator and tenant records -remain private authorization and management data. The recipient can select all or a subset of the -contained skills. - -The destination picker supports the local machine, paired runtimes, WSL, and SSH, in global or -workspace scope. Before commit, the preview groups selected skills as new, unchanged, updates, or -conflicts and shows detected-agent coverage. Conflicts are handled per skill in one review surface, -not one dialog per conflict. **Keep local** is the default; replacement is explicit. The primary -action says **Install N skills**. - -Installation shows aggregate progress plus the current skill. Results are grouped as installed, -unchanged, kept local, and failed; failed items can be retried without repeating successful work. -An incompatible selected runtime receives an update-required result before transfer. - -**Manage** separates: - -- Installed bundles: update, rollback, install on another machine, inspect skills, and remove. -- Shared bundles: copy link, revoke, publish version, and delete the Cloud package. - -Settings includes a **Share Skills** section, parallel to **Artifacts**, explaining unlisted-link -behavior, retention, revocation, and the fact that installed copies are unaffected. It links to the -Skills page for publishing and management; it does not introduce a searchable catalog or a second -sharing workflow. - -Bundle update or removal never silently destroys locally modified skills. - -The UI must follow `docs/STYLEGUIDE.md` and use existing design tokens and shadcn primitives. - -## Target architecture - -```text -Selected skill folders - | - v -Package builder -- validates and creates immutable artifact - | - v -Orca Cloud -- private blob + version metadata + revocable bearer share - | - v -Download grant -- short-lived and scoped to one immutable digest - | - v -Destination runtime - | - +-- package ingress - +-- package inspection - +-- per-skill selection and install planning - +-- staged transaction - +-- provider placement reconciliation - +-- bundle and per-skill provenance publication - +-- post-install discovery -``` - -Cloud download, client-mediated transfer, and a local package file all terminate at the same -validated package-ingress boundary. Installation logic does not know which transport supplied -the bytes. - -## Package format - -Use a versioned tar archive so executable modes can be represented without a platform-specific -side channel. The extracted archive root is an Agent Plugins 1.0.0-compatible, skills-only plugin: - -```text -plugin.json -skills/ -├── skill-a/ -│ └── SKILL.md -├── skill-b/ -│ └── SKILL.md -└── ... -dev.orca.skill-sharing/ -└── manifest.json -``` - -Conceptual manifest: - -```ts -type SkillBundleManifestV1 = { - schemaVersion: 1 - packageId: string - versionId: string - bundleName: string - description: string - createdAt: string - skills: Array<{ - id: string - name: string - description: string - digest: string - files: Array<{ - path: string - size: number - executable: boolean - sha256: string - }> - }> - bundleDigest: string -} -``` - -Rules: - -- `packageId` is stable across versions; `versionId` identifies one immutable publication. -- `plugin.json` uses the canonical Agent Plugins 1.0.0 `$schema`, a valid portable plugin name, - bundle version, description, and optional standard metadata. Orca validates from locally owned - rules and never fetches the schema while loading. -- Every immediate `skills//SKILL.md` is required and must parse successfully. -- Each skill digest uses normalized relative paths, executable state, classification, and file - identity. The bundle digest commits to the ordered skill identities and their globally unique - archive paths. -- The installed folders receive only the selected children of `skills/`. -- Bundle identity, access, immutable versions, upload/download, update/rollback, and Cloud deletion - are bundle-level. Conflict handling, provenance, selection, local-modification protection, and - results are skill-level. -- Paths use `/` in the manifest and are converted with the executing host's path API. -- LF and CRLF non-executable text share one normalized package identity, while each immutable - archive retains and hashes its exact source bytes; executable and binary files always use exact - bytes for identity. -- Names are normalized once during publication. Installation never silently renames a conflict. -- The archive has no outer `bundle/` wrapper, so its root remains directly consumable by agents - that implement Agent Plugins. -- Orca-specific integrity and version data lives in `dev.orca.skill-sharing/manifest.json` rather - than a top-level custom field or GCS metadata alone. It therefore survives export, SSH/remote - transfer, and offline validation and can describe every skill and file. -- GCS custom metadata stays compact and operational. PostgreSQL stores only the package, share, - version, ownership, and lifecycle records needed to resolve a known link or manage an owned - bundle. Legacy ACL records may remain during migration but never gate bearer-link access. -- Orca constructs a fresh staging root. If imported content already contains - `dev.orca.skill-sharing`, Orca accepts only an appropriate recognized schema and rejects unknown, - malformed, or conflicting contents; it never overwrites the namespace silently. -- V1 rejects symlinks and special files. A later package-builder feature may safely dereference - internal links after proving they remain within the source root. -- Existing limits remain the starting contract: depth 16, 2,048 entries, 512 files, 4 MiB per - file, and 32 MiB total extracted bytes. Compressed download size also receives an explicit cap. -- Archive and manifest schema changes are additive or introduced as a new schema version. - -The package builder observes every selected source, copies it into a fresh private staging root, -observes the staged copies again, and only packages them if every identity agrees. The preview -shown to the user is bound to that final bundle digest. - -The original unpublished `manifest.json + skill/` staging format has no production compatibility -commitment. Orca will replace it before launch and migrate or discard staging-only records. Remote -bundle RPCs use a new `skills.install.bundle.v1` capability and additive methods so older peers do -not interpret bundle requests as single-skill requests. - -## GCP deployment plan - -### Existing production baseline - -Read-only inspection of the active `onorca-cloud` project on 2026-08-11 found: - -| Resource | Existing state relevant to skill sharing | -| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Cloud Run | `orca-cloud-api`, `orca-cloud-auth`, `orca-cloud-relay`, and `orca-cloud-relay-fence` run in `us-central1` | -| API service | `orca-cloud-api` uses its own service account, scales from 0 to 20 instances, has a 300-second timeout, and already owns artifact-sharing endpoints | -| Object storage | `onorca-cloud-artifacts` is a `US` bucket with uniform bucket-level access, public-access prevention, exact CORS for `https://share.onorca.dev`, and seven-day soft delete | -| Database | `orca-cloud-auth-db` is PostgreSQL 17 in `us-central1`, regional/high-availability, with backups and point-in-time recovery | -| Database contents | The instance currently contains `orca_auth` and `orca_relay` databases; there is no skill database | -| IAM | `orca-cloud-api@onorca-cloud.iam.gserviceaccount.com` owns objects in the existing artifact bucket | -| APIs | Cloud Run, Cloud Storage, Cloud SQL Admin, IAM Credentials, Secret Manager, Logging, Monitoring, and Pub/Sub are enabled | -| Excluded services | Firestore and Cloud Asset Inventory are disabled; Cloud Tasks is not enabled | - -The GCP inspection was read-only. Commands that offered to enable disabled APIs were declined, and -no resources or IAM policies were changed. - -### V1 infrastructure decision - -Use the existing project, API service, authentication service, PostgreSQL instance, deployment -pipeline, and Terraform state. Add a dedicated package bucket and database instead of putting -private skill packages into the existing general artifact namespace. - -The V1 control plane remains in `orca-cloud-api`. Package validation is bounded and streaming, so a -separate worker service is not justified at the initial 40 MiB compressed-package ceiling. Protect -the existing API workload with a small per-instance package-finalization semaphore and return a -retryable response when that lane is full. - -Split package processing into a dedicated Cloud Run service later if finalize latency or CPU usage -interferes with artifact sharing. Do not reduce the entire API service's current concurrency merely -to accommodate one endpoint family. - -Firestore is not required. Skill metadata belongs in PostgreSQL beside the existing Orca Cloud -identity model. Cloud Tasks and Pub/Sub are not required for V1 because upload expiry is enforced -by a GCS lifecycle rule and package finalization stays synchronous and bounded. - -### Required GCP changes - -All production changes are declared in the existing Orca Cloud Terraform configuration. Do not -create durable resources manually with `gcloud`. - -| Change | Proposed production value | -| ------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| GCS bucket | `onorca-cloud-skill-packages`, subject to Terraform name validation | -| Location | `US`, matching existing shared-artifact storage; revisit per-region buckets when data residency becomes a product requirement | -| Access | Uniform bucket-level access and public-access prevention enforced | -| Soft delete | Seven days, matching the current artifact bucket | -| Object versioning | Disabled; package keys are immutable and GCS generations are recorded | -| Temporary lifecycle | Delete `uploads/` objects after one day | -| CORS | Exact approved Orca share/app origins; no wildcard origin | -| Database | `orca_skills` on `orca-cloud-auth-db` | -| Database principal | Dedicated `orca_skills_app` user with access only to `orca_skills` | -| Database secret | `orca-cloud-skills-database-url` in Secret Manager | -| Cloud Run service | Extend `orca-cloud-api`; mount the existing Cloud SQL instance and inject only the skill database secret | -| Package signer | Reuse `orca-cloud-api@onorca-cloud.iam.gserviceaccount.com` for V1 | -| Metrics/logging | Extend existing Cloud Logging and Monitoring configuration with skill endpoint dashboards and alerts | - -The bucket is separate because skill packages need different authorization, retention, signed -download, and lifecycle rules from public/shared artifacts. It also provides a clean IAM kill -switch without disrupting existing artifact links. - -### Bucket layout - -```text -gs://onorca-cloud-skill-packages/ - uploads//package.tar.gz - packages/v1/tenants//sha256///package.tar.gz -``` - -Rules: - -- `uploads/` is quarantine storage and is never downloadable through a share. -- Upload IDs are random, single-use, tenant-bound, and expire in PostgreSQL as well as GCS. -- Final package keys are derived from a one-way owner-tenant hash and the validated archive - SHA-256. Raw tenant identifiers never appear in object names. -- The database records the final object's GCS generation, compressed size, archive SHA-256, and - package digest. -- Final objects are never overwritten. Promotion uses a generation-match precondition. -- If a tenant's final key already exists, finalization verifies its archive SHA-256 and logical - package digest metadata before reusing it. -- Deduplication is tenant-scoped in both GCS and PostgreSQL, so another tenant's matching archive - cannot change publication timing or reveal that the digest already exists. -- GCS object versioning remains off; immutable keys plus stored generations prevent accidental - replacement, while seven-day soft delete provides operator recovery. -- Deleting the final database reference deletes the object only after a transaction proves no - package version still references it. GCS soft delete then supplies the recovery window. - -### Upload implementation - -Use a V4 signed POST policy rather than a signed unrestricted PUT. The POST policy enforces: - -- Exact quarantine object key. -- `application/vnd.orca.skill+tar+gzip` content type. -- A content-length range capped at 40 MiB. -- Required upload ID and expected archive SHA-256 metadata. -- A 15-minute expiration. - -The compressed limit is slightly above the 32 MiB extracted-file limit to allow tar metadata and -incompressible input. The package builder still enforces the lower extracted-content limits. - -Upload sequence: - -1. `orca-cloud-api` authenticates the user through the existing auth integration. -2. The API checks organization membership, package quota, per-user rate limits, and concurrent - upload limits. -3. It inserts a pending upload row and returns a signed POST policy for the unique quarantine key. -4. The client uploads directly to GCS; package bytes do not traverse Cloud Run. -5. The client calls finalize with the upload ID and package manifest identity. -6. The API reads GCS object metadata and rejects unexpected size, content type, key, generation, - or tenant binding. -7. The API streams the object once to calculate archive SHA-256 and validate the archive envelope, - manifest, path set, file limits, and package digest. It never buffers the whole package. -8. The API promotes the validated object to its immutable content-addressed key with an - if-absent generation precondition. -9. A PostgreSQL transaction publishes the immutable package version and marks the upload complete. -10. The quarantine object is deleted. The one-day lifecycle remains the backstop for abandoned - uploads. - -Run at most a small fixed number of finalizations concurrently per API instance. A full lane -returns `429` or `503` with `Retry-After`; it does not queue unbounded package bytes or work. - -### Download grants - -After resolving an active bearer share, `orca-cloud-api` creates a V4 signed GET URL with: - -- Exact final object key and stored generation. -- Five-minute expiration. -- Response content type and attachment filename. -- No list or write permission. - -The URL is the short-lived grant. The durable share URL is never signed directly and cannot access -GCS. Revocation cannot invalidate an already-issued GCS URL, so the five-minute lifetime is the -maximum revocation delay. - -The destination runtime accepts download URLs only from the configured GCS origin for the Orca -skill bucket, rejects credential-bearing redirects to any other host, enforces the expected byte -count while streaming, and verifies both archive SHA-256 and package digest before installation. - -### IAM - -Apply least privilege at the dedicated bucket and service-account level: - -- Grant the API service account `roles/storage.objectUser` on the skill-package bucket, not a - project-wide storage role. -- Grant the API service account `roles/cloudsql.client` for the existing instance. -- Allow the API service account to use IAM Credentials `signBlob` for itself so it can generate V4 - policies and URLs without a downloaded service-account key. The Terraform binding is scoped to - that service account. -- Grant Secret Manager accessor only for `orca-cloud-skills-database-url` and any future - skill-specific secret. -- Keep bucket IAM free of `allUsers` and `allAuthenticatedUsers`. -- Do not give the desktop, remote runtime, or user identity direct bucket IAM. They receive only a - time-bound signed operation after application authorization. -- Keep the existing deployment service account's impersonation/deploy permissions unchanged - except where Terraform needs to manage the new resources. - -No long-lived GCP key is shipped in Orca or stored on a remote runtime. - -### PostgreSQL schema - -Create migrations for: - -- `skill_packages`: stable bundle identity, owner organization, slug/name, creator, timestamps, - and deletion state. -- `skill_package_versions`: immutable version, package digest, archive SHA-256, GCS object key and - generation, byte and skill counts, release notes, creator, publication state, and the validated - bundle manifest needed to inspect a known share without downloading its private archive. -- `skill_package_uploads`: tenant/user binding, quarantine key and generation, expected identities, - expiration, finalization state, and failure category. -- `skill_package_acl`: legacy organization or user access records retained for migration until a - later schema cleanup; these do not authorize or deny bearer-share resolution. -- `skill_share_records`: unpredictable share ID, package ID, optional pinned version, expiration, - revocation state, creator, and timestamps. -- `skill_package_audit_events`: bounded security/lifecycle event metadata without package contents - or signed URLs. - -Required constraints and indexes include: - -- Unique package slug within its owning organization. -- Unique immutable version ID and package digest identity. -- Tenant-scoped archive identity and final GCS object key/generation pair. -- Share lookup by unpredictable ID and active/revoked state. -- No package, version, or contained-skill search/list index; share links are intentionally unlisted. -- Owner lookup for authenticated management operations. -- Pending-upload lookup by owner and expiration. -- Foreign keys that prevent removing a blob reference while a published version uses it. - -Share resolution and share-scoped download grants accept no user credential: an unpredictable, -active, unexpired share ID is the bearer credential. Anonymous requests receive the same -non-disclosing `404` for missing, expired, revoked, or deleted shares and remain subject to -distributed per-IP abuse limits. Package/version management, share creation, owner listing, -revocation, and deletion require an authenticated owner. - -### Cloud Run changes - -Add skill endpoints to `orca-cloud-api` and configure: - -```text -ORCA_SKILL_PACKAGE_BUCKET=onorca-cloud-skill-packages -ORCA_SKILL_PACKAGE_MAX_COMPRESSED_BYTES=41943040 -ORCA_SKILL_PACKAGE_UPLOAD_TTL_SECONDS=900 -ORCA_SKILL_PACKAGE_DOWNLOAD_TTL_SECONDS=300 -ORCA_SKILL_PACKAGE_FINALIZE_CONCURRENCY= -ORCA_SKILLS_DATABASE_URL= -``` - -Use the existing auth base URL and CORS-origin configuration. Do not add a second authentication -implementation for skills. - -Every API image rollout must stamp the complete Terraform-owned literal environment and exact -`ORCA_SKILLS_DATABASE_URL` secret reference in one no-traffic candidate. Before smoke or promotion, -compare the candidate with both the reviewed release values and the serving Terraform runtime -shape. Reject any unexpected secret or environment entry, changed service account, Cloud SQL -attachment, scaling bound, resource limit, volume, mount, port, or probe. This keeps ordinary image -deploys from clearing the skill database secret or silently detaching Cloud SQL. - -The API streams upload validation with fixed buffers under its existing 512 MiB memory limit. Load -test the finalize lane before choosing its fixed concurrency. Keep the current Cloud Run instance -scaling bounds initially, then adjust only from observed CPU, latency, database connection, and -error metrics. - -Recommended endpoint family: - -```text -POST /v1/skill-packages/uploads -POST /v1/skill-packages/uploads//finalize -POST /v1/skill-packages -GET /v1/skill-packages/ -GET /v1/skill-packages//versions -POST /v1/skill-packages//shares -DELETE /v1/skill-shares/ -GET /v1/skill-shares/ -POST /v1/skill-shares//download-grants -``` - -Finalize may create the package/version in one call; the separate package endpoint remains useful -when publishing a new version under an existing stable package identity. All mutating endpoints -accept idempotency keys. - -### CORS and public routing - -- Route the endpoint family through the existing Orca Cloud API hostname and deployment path. -- Add only exact production and approved development app/share origins to the skill bucket's CORS - policy. -- Allow `POST` for signed upload policy submission and `GET`/`HEAD` for signed download. -- Expose only required response headers such as `Content-Type`, `Content-Length`, `ETag`, - `x-goog-generation`, and `x-goog-hash`. -- Do not expose the GCS bucket through a public custom domain. -- Keep Cloud Run ingress and authentication consistent with existing API endpoints; package - authorization remains mandatory at the application layer. - -### Retention, deletion, and recovery - -- Pending upload database rows expire after 15 minutes and are safe to delete after one day. -- GCS deletes quarantine objects after one day regardless of database cleanup success. -- Published objects have no age-based deletion rule. -- Package deletion first marks metadata deleted and revokes shares, then deletes an object only - when no retained version references it. -- Seven-day GCS soft delete allows operator recovery from accidental object deletion. -- PostgreSQL backups and point-in-time recovery cover metadata; restoration procedures must restore - database rows and object generations consistently. -- Organization retention and legal deletion requirements override product rollback retention. - -### Monitoring and operational gates - -Add dashboards and alerts for: - -- Upload grant, finalize, share resolution, and download-grant request rate. -- Authorization denials and rate-limit outcomes. -- Finalize duration, semaphore saturation, archive rejection category, and digest mismatch. -- Cloud Run 5xx rate, CPU, memory, instance count, and request latency for skill routes. -- GCS quarantine bytes, published bytes, object count, and lifecycle deletion failures. -- PostgreSQL connection count, storage, query latency, migration status, and failed transactions. -- Signed URL creation failures and IAM Credentials errors. -- Orphan metadata/object reconciliation count. - -Budget alerts cover GCS stored bytes and egress, Cloud Run CPU/request growth, and Cloud SQL storage. -Logs contain package/version IDs and error categories, not manifests, filenames, share URLs, signed -policies, signed URLs, or ACL membership. - -### Provisioning and deployment sequence - -1. Confirm the existing Orca Cloud Terraform worktree and staging environment are current. -2. If there is no isolated staging GCP project, create or designate one before testing package - upload; do not exercise new lifecycle/IAM behavior first in `onorca-cloud` production. -3. Add the bucket, lifecycle, CORS, IAM, database, database user, secret, Cloud SQL attachment, API - configuration, dashboards, alerts, and budget thresholds to Terraform. -4. Run Terraform formatting, validation, and a reviewed plan. The expected production plan contains - no replacement of the existing artifact bucket, Cloud SQL instance, or Cloud Run services. -5. Apply the reviewed skill-only infrastructure plan in staging without shared-resource drift. -6. Deploy `orca-cloud-api` with all skill controls disabled. When the database secret is present, - startup applies PostgreSQL migrations without registering the skill routes. -7. Verify the migration-ready event, zero error logs, ordinary artifact behavior, and `404` skill - route boundary before changing any control. -8. Enable route registration for the staging identity and run upload/finalize/download tests, - including expired policies, anonymous bearer resolution, oversized uploads, corrupt archives, deduplication, - revocation, and object cleanup. -9. Run a staging desktop-to-runtime installation and verify logs contain no grants or private - contents. -10. Apply the reviewed production Terraform plan. -11. Run migrations and deploy the API with availability disabled. -12. After every release gate passes, enable the feature for all accounts in one launch. -13. Observe at least one complete soft-delete, upload-expiry, update, rollback, and revoke journey, - retaining separate kill switches for upload grants, download grants, and remote installation. - -Production verification uses read-only commands such as: - -```bash -gcloud storage buckets describe gs://onorca-cloud-skill-packages --project=onorca-cloud -gcloud run services describe orca-cloud-api --region=us-central1 --project=onorca-cloud -gcloud sql databases list --instance=orca-cloud-auth-db --project=onorca-cloud -gcloud iam service-accounts get-iam-policy \ - orca-cloud-api@onorca-cloud.iam.gserviceaccount.com --project=onorca-cloud -``` - -Never print Secret Manager payloads or signed policies/URLs during verification. - -## Installation scopes and destinations - -### Global scope - -The canonical root is resolved on the destination host: - -```text -/.agents/skills/ -``` - -Provider-specific aliases are created only for detected agents that do not consume the canonical -root. - -### Workspace scope - -The canonical root is: - -```text -/.agents/skills/ -``` - -The workspace may be a Git worktree or a plain folder. Resolution uses the workspace identity -owned by the executing runtime; a client-provided path alone is not trusted for a remote target. - -Project installation does not create or modify a checked-in desired-state lockfile in V1. A -separate team-sync feature may later add an explicit, reviewable project manifest. - -### WSL scope - -WSL is a distinct execution target. Home resolution, path operations, package extraction, and -installation run inside the selected distro. The Windows client must not construct Linux home -paths or mutate the distro through translated Windows paths. - -### SSH-only scope - -An SSH target without an Orca runtime cannot execute the runtime RPC directly. Before GA, add a -host-side installation command invoked through the existing SSH execution and file-transfer -providers. The desktop uploads the immutable package through SFTP, invokes the bundled Orca -installer on the SSH host, and receives the same structured result as runtime RPC. - -If the required host component is unavailable, Orca reports the limitation and provides a local -package download or command; it never installs into the desktop's home as a fallback. - -## Provider destination strategy - -Orca owns a small data-only registry for agents Orca supports. Each entry describes: - -- Orca agent ID. -- Whether it reads the universal `.agents/skills` root. -- Global provider-specific path resolver when required. -- Project provider-specific path resolver when required. -- Detection evidence required before creating the path. -- Supported alias mechanism by platform. - -The registry is authored from official agent documentation and Orca's own verified installations. -Upstream may reveal that a compatibility case exists, but its implementation, path table, and -tests are not copied or transformed into Orca code. - -Policy: - -1. Always create one canonical copy. -2. Do nothing else for universal agents. -3. Create an alias for a detected non-universal agent. -4. On Windows, prefer a directory junction when supported. -5. Fall back to a verified independent copy only when aliases are unavailable. -6. Record every placement and its topology. -7. Never create configuration roots for dozens of undetected agents. -8. Never replace an unowned provider-specific copy without an explicit decision. - -Provider releases and official documentation are reviewed periodically. Orca's registry changes -only through normal review and platform tests; no upstream synchronization script owns it. - -The portable root does not imply every agent can install it as a native plugin. Cursor can consume -the root `plugin.json` package directly. Claude expects `.claude-plugin/plugin.json`; Codex and -ChatGPT expect `.codex-plugin/plugin.json`; the Codex IDE extension does not currently support -plugins. Orca therefore installs loose skills by default and may generate provider-native adapters -for detected compatible clients: - -- Cursor: use the portable package directly. -- Claude: generate `.claude-plugin/plugin.json` from validated bundle metadata. -- Codex/ChatGPT: generate `.codex-plugin/plugin.json` from validated bundle metadata. -- Other or unsupported agents: install the selected loose skills. - -Native plugin installation never becomes the only path. Provenance connects every loose installed -skill to its source bundle and immutable version. - -## Installer components - -Create narrow modules with explicit responsibilities: - -### Shared contracts - -- `src/shared/skill-package-manifest.ts`: bundle manifest, portable plugin manifest, and canonical - validation. -- `src/shared/skill-install-contract.ts`: bundle selection, per-skill preview, conflict, placement, - and result types. -- `src/shared/skill-install-capability.ts`: runtime capability name and compatibility message. - -### Main/runtime implementation - -- `src/main/skills/skill-package-creation.ts`: source observation, stable staging, and archive - creation. -- `src/main/skills/skill-package-extraction.ts`: bounded archive inspection and extraction. -- `src/main/skills/skill-package-download.ts`: grant validation, host allowlisting, streaming, and - compressed-byte limits. -- `src/main/skills/skill-install-destinations.ts`: global, workspace, WSL, and provider path - resolution. -- `src/main/skills/skill-provider-destinations.ts`: Orca-owned provider registry. -- `src/main/skills/skill-install-planner.ts`: current-state inspection and conflict decisions. -- `src/main/skills/skill-install-transaction.ts`: locking, staging, commit journal, rollback, and - recovery. -- `src/main/skills/skill-placement-reconciliation.ts`: aliases, junctions, and verified copy - fallback. -- `src/main/skills/skill-install-provenance.ts`: bounded receipts and recovery data. -- `src/main/skills/skill-install-service.ts`: orchestration and structured results. - -Reuse `observeSkillPackage` and the existing topology/freshness logic directly. Reuse the design -of plugin staging, provenance, lock serialization, and Windows rename retries, but do not import -plugin-domain modules into the skill domain. Promote a primitive only when both domains can name -and test the same contract accurately. - -### Cloud client - -- Skill upload grant creation and finalization. -- Authenticated share creation and revocation; anonymous share resolution. -- Download grant creation. -- Owned-package management and exact package/version lookup by ID; no browse or search API. -- Authenticated owner-only active-link inventory for cross-device copy and revocation; no public or - recipient inventory. -- Manager-only package details include ownership and active, unexpired share records; anonymous - recipients never receive management metadata. - -Exact files follow the repository that owns Orca Cloud APIs; desktop contracts stay provider -neutral. - -### Runtime and IPC - -- Extend the existing skill RPC registration with preview/install/update/remove methods. -- Register the same operations through desktop IPC. -- Route every request to the runtime that owns the selected machine and workspace. -- Keep package transfer separate from installation so direct download and chunked relay converge - on one staged file. - -### Renderer - -- Multi-select bundle share review and unlisted-link warning. -- Share completion dialog with copyable durable link. -- Active-link copy/revocation, immutable-version deletion, and Cloud-package deletion with explicit - confirmation and copy explaining that installed copies remain local. -- Settings → Share Skills owner inventory so newly published links remain manageable even when the - source skill has no managed-install receipt. -- Selective install preview with destination, scope, coverage, and per-skill conflict state. -- Aggregate install progress and grouped per-skill outcomes. -- Installed/shared bundle management, update, rollback, removal, and incomplete-coverage actions on - the Skills page. - -## Install request and result contract - -Conceptual request: - -```ts -type SkillBundleInstallRequest = { - operationId: string - package: { - packageId: string - versionId: string - bundleDigest: string - compressedBytes: number - } - selectedSkillIds: string[] - ingress: - | { kind: 'download-grant'; url: string; expiresAt: string } - | { kind: 'staged-upload'; uploadId: string } - | { kind: 'local-file'; path: string } - destination: - | { scope: 'global'; environmentId?: string } - | { scope: 'workspace'; worktreeId?: string; folderWorkspaceId?: string } - conflictResolutions?: Record< - string, - 'keep-local' | 'replace-unmodified' | 'replace-and-discard-local' - > -} -``` - -`local-file.path` is accepted only across an in-process trusted boundary. It is not exposed as an -arbitrary remote RPC path. - -Conceptual result: - -```ts -type SkillBundleInstallResult = { - operationId: string - packageId: string - versionId: string - bundleDigest: string - status: 'complete' | 'partial' | 'failed' - skills: Array<{ - skillId: string - name: string - status: 'installed' | 'updated' | 'unchanged' | 'kept-local' | 'failed' - canonicalPath?: string - placements: Array<{ - provider: string - path: string - topology: 'canonical-copy' | 'provider-alias' | 'independent-copy' - status: 'installed' | 'unchanged' | 'skipped' | 'failed' - errorCategory?: string - }> - conflict?: { - kind: 'modified' | 'unowned' | 'external-link' | 'name-collision' - existingDigest?: string - } - errorCategory?: string - }> -} -``` - -The response never includes a download grant or credentials. - -## Installation algorithm - -### 1. Admission - -- Validate all request fields, package identifiers, selected skill IDs, and per-skill decisions. -- Resolve the destination through host-owned runtime state. -- Confirm the requested scope is writable and contained in an allowed home or workspace root. -- Reject an expired grant before network access. -- Deduplicate retries using `operationId`, package/version, selected skill set, and destination. - -### 2. Lock - -- Acquire locks for selected canonical destinations in deterministic skill-name order. -- Use an atomic lock-directory or exclusive-file creation supported on all target platforms. -- Record a random owner token and start time. -- Recover a stale lock only after validating its journal and owner liveness policy. -- Bound lock wait time and return a retryable busy result. - -An in-process promise chain alone is insufficient because the desktop, headless runtime, CLI, or -SSH helper may run concurrently. - -### 3. Ingress - -- Stream the archive into a bounded temporary file. -- For grants, require HTTPS when the configured cloud endpoint is HTTPS and restrict redirects and - final hosts to approved Orca storage origins. -- Abort when compressed bytes exceed the manifest contract. -- Hash while streaming and compare the archive identity supplied by Cloud. -- Delete partial bytes on cancellation, expiration, disconnect, or failure. - -### 4. Extract and inspect - -- Create extraction staging on the same filesystem as the canonical destination. -- Reject absolute paths, `..`, drive prefixes, NUL bytes, links, devices, FIFOs, sockets, - encrypted entries, duplicate normalized paths, and case-fold collisions. -- Enforce compressed, extracted, entry, file, depth, and per-file limits during extraction. -- Validate `manifest.json` before trusting file metadata. -- Observe `skill/` with `observeSkillPackage` and compare every file and the package digest. -- Parse `SKILL.md` and require its identity to agree with the package name. - -### 5. Inspect current state - -Classify the canonical destination and all requested provider placements. - -Canonical outcomes: - -- Missing: install is allowed. -- Same requested digest: no-op; continue to placement repair. -- Matches Orca receipt and remains unmodified: update is allowed. -- Differs from its Orca receipt: return a modified conflict. -- Exists without Orca provenance: return an unowned conflict. -- External or broken link: return a topology conflict. - -Provider outcomes: - -- Correct alias or matching Orca-owned fallback copy: no-op. -- Missing: create after canonical commit. -- Broken Orca-owned alias: repair. -- Unowned or modified placement: leave untouched and report incomplete coverage unless the user - explicitly chooses replacement. - -The preview and final install repeat current-state inspection. A state change between preview and -commit invalidates the preview and requires a new decision. - -### 6. Prepare commit - -- Copy extracted `skill/` into a hidden sibling staging directory. -- Preserve executable modes. -- Observe the copy and require the expected digest again. -- Write a durable transaction journal before moving the current destination. -- Keep the replacement and backup on the destination filesystem so directory renames do not cross - volumes. - -### 7. Commit canonical copy - -- If an existing destination is approved for replacement, rename it to the journal's backup path. -- Rename the verified staging directory into the canonical path. -- Use bounded Windows retry behavior for antivirus/indexer `EPERM`, `EACCES`, and `EBUSY` races. -- Observe the installed path and require the requested digest. -- On failure, restore the backup and retain enough journal state for startup recovery. - -Directory replacement is crash-recoverable rather than assumed to be a single atomic overwrite on -every platform. - -### 8. Reconcile provider placements - -- Create aliases relative to their real parent directory on POSIX. -- Use directory junctions with absolute targets on Windows. -- Detect parent directories that are already symlinked to canonical storage. -- If an alias mechanism fails, create and verify an independent copy when policy allows. -- Reconcile each placement idempotently. - -Canonical success is retained when one provider placement fails. The result is `partial`, and the -UI offers retry. Rolling back a valid universal installation because one optional alias failed -would make recovery less reliable. - -### 9. Publish provenance - -Write a bounded, versioned receipt outside the installed skill folder containing: - -- Package and version IDs. -- Expected package digest. -- Scope and destination identity. -- Canonical path identity. -- Provider placements and topologies. -- Previous version identity for interrupted-update recovery. -- Installation timestamp and Orca host/runtime identity. - -Do not store credentials, download URLs, share bearer values, or package contents. - -Serialize receipt updates, write them durably through a temporary file and rename, and support -reconstruction from per-install provenance if the aggregate index is corrupt. - -After the receipt is durable, mark the journal complete and delete transactional backups and -staging bytes. - -### 10. Verify and publish result - -- Run skill discovery on the destination target. -- Confirm the canonical skill and successful provider placements are observable. -- Return observed status and error categories rather than relying on process exit alone. -- Invalidate renderer skill caches and refresh the Skills page. - -## Update behavior - -1. Resolve the latest accessible immutable version from Cloud. -2. Compare its digest with the receipt. -3. Observe installed bytes before offering the update. -4. If bytes match the receipt, use the normal installation transaction. -5. If bytes were modified, offer: - - Keep local version. - - Publish local bytes as a new immutable version when exactly one non-missing Orca-managed - install matches the skill name and scope; Cloud rechecks ownership of that stable package ID. - - Replace and discard local changes after explicit confirmation. -6. Reconcile all recorded aliases and fallback copies. -7. Preserve the previous package version in Cloud; rollback is a normal install of that immutable - version. - -Do not invoke `npx skills update` for Orca-owned packages or write entries that cause the community -CLI to claim ownership of them. - -## Removal behavior - -1. Read Orca provenance and observe every recorded placement. -2. Remove only aliases that still point to the recorded canonical path. -3. Remove independent copies only when their bytes still match their recorded digest, unless the - user explicitly confirms discarding modifications. -4. Remove the canonical copy only when it is Orca-owned, unmodified, and no retained placement or - receipt depends on it. -5. Publish the provenance change durably. -6. Leave an unowned or changed destination untouched and report what remains. - -Cloud package deletion and local removal are separate actions. Revoking a share prevents new -downloads but does not silently delete already installed local files. - -## Remote, SSH, and mixed-version compatibility - -Add a static runtime capability: - -```text -skills.install.v1 -``` - -Clients check it before sending installation RPCs. Older servers continue supporting discovery -but do not show remote install actions. A missing capability produces an update-required message. - -Adding new RPC methods and a static capability does not require a runtime protocol bump. New -fields remain optional until all supported peers understand them. No new terminal stream opcode is -needed. - -Recommended RPC surface: - -- `skills.install.preview` -- `skills.install.beginUpload` -- `skills.install.uploadChunk` -- `skills.install.commitUpload` -- `skills.install.cancelUpload` -- `skills.install.execute` -- `skills.install.update` -- `skills.install.remove` - -Upload sessions are bounded by count, bytes, idle lifetime, and chunk size. Offsets are monotonic; -retries either repeat an acknowledged chunk idempotently or restart the upload. Disconnect and -cancellation release staging bytes. Installation RPC retries are also bounded. Because operation -IDs make commit idempotent, a direct install can converge after its response is lost; a staged -install rebuilds the upload before retrying because the first host attempt may already have -consumed it. - -Client cancellation is carried through the local runtime-call queue and every paired request -transport. A queued call is removed before it starts; an in-flight one-shot request closes only its -socket; cached and shared-control requests release only their own request state so unrelated users -of the connection continue. Late responses for cancelled shared requests are retired and ignored. -This is local transport behavior and does not change the mixed-version wire contract; the existing -operation-ID cancellation RPC remains responsible for converging host-side installation work. - -SSH Relay errors retain JSON-RPC numeric codes on the wire. Known skill failures add optional, -schema-validated `error.data`; arbitrary handler data is never published. This is additive for -mixed versions and gives SSH the same stable error categories as native and paired installation -without exposing package contents or credentials. - -The runtime that executes installation owns: - -- Home and config-directory resolution. -- Workspace/folder identity resolution. -- WSL distro selection. -- Agent detection and provider paths. -- Filesystem mutation and recovery. - -The calling client owns: - -- User authentication and share authorization. -- Destination selection. -- Obtaining a grant or providing package chunks. -- Rendering preview, progress, conflicts, and results. - -## Security and privacy controls - -The detailed threat register and residual release gates live in -[`docs/reference/agent-skill-sharing-threat-model.md`](./reference/agent-skill-sharing-threat-model.md). - -- Treat `SKILL.md` and packaged scripts as executable code for trust messaging. -- Show version, digest, file summary, and executable files before install without exposing internal - publisher or organization identifiers. -- Require the exact active bearer share ID for recipient resolution and every download grant; - require authentication for publication and owner management. -- Bind grants to one package version, digest, maximum byte count, and short expiration. -- Validate grant and redirect hosts to prevent server-side request forgery. -- Never execute package scripts during installation. -- Reject links and special files before they can be published or extracted. -- Keep package staging owner-only where platform permissions support it. -- Avoid logging file contents, share tokens, signed URLs, or organization-private names. -- Audit authorization and lifecycle events without storing skill contents in telemetry. -- Apply organization deletion and retention policy to metadata and blobs. -- Rate-limit uploads, downloads, share resolution, and remote transfer sessions. -- Use constant-shape authorization failures so private package existence is not disclosed. - -## Failure and recovery model - -The transaction journal records enough state to classify interrupted operations: - -- Download only: delete partial archive. -- Extracted but not committed: delete staging. -- Existing destination moved to backup: restore backup. -- New destination placed but receipt absent: verify new digest, then either finish receipt - publication or restore backup according to journal state. -- Receipt published but journal incomplete: verify receipt and installed digest, then finalize. -- Alias reconciliation interrupted: preserve canonical installation and retry reconciliation. - -Recovery runs before a new operation for the same destination and during bounded startup cleanup. -It never removes an unknown path based only on a filename pattern; the journal owner token, -destination containment, and expected identities must all agree. - -## Testing strategy - -### Package tests - -- Deterministic manifest and digest generation. -- Source changes during packaging. -- CRLF/LF identity behavior. -- Executable mode preservation. -- Missing or malformed `SKILL.md`. -- Excessive depth, entries, files, individual size, total size, and compressed size. -- Traversal, absolute paths, Windows drive paths, Unicode/case collisions, duplicate paths, - symlinks, hardlinks, devices, FIFOs, sockets, and encrypted entries. -- Truncated and checksum-invalid archives. -- Invalid gzip bytes with a stable non-retryable archive failure. - -### Transaction tests - -- Fresh install, identical reinstall, clean update, and explicit replacement. -- Modified and unowned conflicts. -- Existing canonical or provider paths that are files, directories, links, or broken links. -- Failure before and after every journal transition. -- Backup restoration and idempotent recovery. -- Concurrent desktop, runtime, and CLI attempts. -- Cancellation during download, extraction, copy, commit, and alias reconciliation. -- Antivirus-style Windows rename contention. -- Permission failures and read-only destinations. -- Permission and disk-capacity failure injection that preserves the prior installed version. -- Alias failure with verified copy fallback. -- Independent copy drift during update and removal. -- Lost final-response retry for direct installs and staged-transfer rebuild before retry. - -### Target matrix - -- macOS ARM64 and x64 behavior where available. -- Linux at the supported Ubuntu 20.04/glibc 2.31 floor. -- Windows with junction success, junction denial, copy fallback, and long paths. -- WSL with multiple distros and distro-owned home resolution. -- Local Git worktree and plain folder workspace. -- Paired remote runtimes in both client-newer and server-newer combinations. -- SSH-only macOS/Linux/Windows targets where supported by existing providers. -- Remote target with no outbound Cloud connectivity using chunked transfer. - -### Cloud tests - -- Anonymous active-link resolution, expiry, revocation, and non-disclosing missing-link failures. -- Owner authentication for package and share management. -- Upload finalization digest mismatch. -- Blob deduplication without cross-tenant information leakage. -- Quota and rate-limit behavior. -- Durable share resolving to the intended immutable version. -- Version update and rollback. - -### End-to-end journeys - -1. Share on machine A, install globally on machine B, launch a detected agent, and discover the - skill. -2. Share on machine A, install into a folder workspace on a connected remote runtime, and discover - it only in that workspace. -3. Modify the installed copy, publish an update, and prove Orca refuses silent replacement. -4. Disconnect during commit, reconnect, recover, and obtain either the complete old or complete - new version. -5. Revoke a share, prove new installation fails, and prove an existing local installation remains. - -## Observability - -Record bounded operational metrics: - -- Package byte/file counts and stage durations. -- Download versus client-mediated transfer selection. -- Install outcomes by category. -- Conflict categories. -- Alias, junction, and copy-fallback rates. -- Recovery and rollback counts. -- Runtime capability absence. -- Error categories by operating system and target kind. - -Do not record skill contents, file names beyond approved aggregate categories, private share URLs, -download grants, access lists, or raw local paths. - -User-facing logs show phase, destination label, placement outcome, and actionable error. Sensitive -network values are redacted at creation rather than scrubbed after logging. - -## Delivery phases - -### Phase 0: contracts and vertical spike - -Deliver: - -- Package manifest schema. -- Local package builder and bounded extractor. -- A programmatic install call from a validated staging directory into a temporary canonical root. -- Fresh install and modified-conflict tests on macOS, Linux, and Windows CI. -- Decision record confirming upstream is behavioral-reference-only and no material is copied. - -Exit criteria: - -- No `npx` or Node installation dependency outside Orca's own runtime. -- Same bytes produce the same digest on every target platform. -- Invalid archive classes fail before destination mutation. - -Estimate: 2–3 engineer-days for the spike, followed by design review. - -### Phase 1: production local installer - -Deliver: - -- Cross-process lock. -- Durable journal and recovery. -- Canonical install, update, and removal. -- Provenance receipts and aggregate index recovery. -- Provider registry for Orca's primary detected agents. -- POSIX aliases, Windows junctions, and verified copy fallback. -- Structured preview/result contracts. -- CLI-only developer harness for deterministic integration tests. - -Exit criteria: - -- Failure injection at every commit boundary preserves either the previous or requested complete - package. -- Local modifications are never silently replaced. -- Folder workspaces and global scope pass the platform matrix. - -Estimate: 5–8 engineer-days. - -### Phase 2: private Cloud sharing - -Deliver: - -- Reviewed Terraform for the dedicated private GCS bucket, lifecycle, CORS, bucket-scoped IAM, - service-account signing, `orca_skills` database, database secret, Cloud SQL attachment, - monitoring, and budgets. -- PostgreSQL migrations for package, version, upload, share, ownership, and audit records, with - legacy ACL storage tolerated during migration. -- Upload/finalize APIs using bounded V4 signed POST policies and private content-addressed GCS - objects. -- Version, bearer-share, revocation, five-minute download grant, quota, and audit behavior. -- Share preview and owner-management UI. -- Install preview and local-machine installation UI. -- Durable links and short-lived grants. -- Update availability and version rollback. - -Exit criteria: - -- A copied active durable link can be inspected and installed without recipient sign-in. -- Revocation immediately blocks new grants. -- The installed receipt identifies the immutable package without persisting a grant. -- Production provisioning changes are Terraform-owned and do not replace existing artifact, - Cloud SQL, or Cloud Run resources. - -Estimate: 1–2 engineer-weeks across desktop and Cloud work. - -### Phase 3: paired Orca runtime installation - -Deliver: - -- `skills.install.v1` capability. -- Runtime preview/install/update/remove methods. -- Direct runtime download. -- Bounded client-mediated upload fallback. -- Destination-machine and remote-workspace selection. -- Mixed-version UI and compatibility tests. - -Exit criteria: - -- Installation executes on the selected host and uses that host's home, workspace, and detected - agents. -- Older hosts are not called and receive a clear update-required state. -- A host without outbound internet installs through the authenticated chunked transfer path. - -Estimate: 4–7 engineer-days. - -### Phase 4: WSL and SSH completion - -Deliver: - -- Distro-owned package ingress and installer execution. -- SSH package upload and host-side structured installer invocation. -- Cross-target cancellation and cleanup. -- WSL/SSH provider detection and path coverage. -- Real-host end-to-end coverage. - -Exit criteria: - -- No desktop path is substituted for a WSL, SSH, or runtime path. -- SSH and WSL return the same result contract as native installation. -- Failure and cancellation leave no untracked staging bytes outside bounded recovery retention. - -Estimate: 4–7 engineer-days, depending on host-helper availability. - -### Phase 5: multi-machine management and reconciliation - -Deliver: - -- “Install on another machine” and multi-machine progress. -- Optional desired-version policy for selected personal or organization machines. -- Drift and missing-install reconciliation. -- Explicit project desired-state manifest if validated by product usage. -- Direct machine-to-machine transfer as an alternative to Cloud persistence if required. - -Exit criteria: - -- Reconciliation remains opt-in and never overwrites local modifications. -- Offline machines converge after reconnect without sharing durable credentials. -- Organization removal, user departure, and package retention have documented semantics. - -Estimate: separate product milestone after first-release usage data. - -## Release gates - -Before enabling Cloud or remote installation by default: - -- Threat-model review covers package ingestion, grants, SSRF, archive extraction, local path - containment, and instruction/script trust. -- Cross-platform CI covers the package and transaction suites. -- Real Windows validates junction and copy fallback. -- Real WSL and SSH validate host-owned paths. -- Mixed-version remote tests cover old client/new server and new client/old server. -- Download/upload cancellation and app/runtime crash recovery are exercised. -- Telemetry and diagnostic bundles are verified not to contain grants or private contents. -- Share deletion, revocation, organization departure, and retention behavior are documented. -- The UI identifies the author and executable content before installation. -- A kill switch can disable new Cloud grants and remote installs without affecting discovery or - already installed skills. - -## Effort summary - -For one engineer familiar with Orca: - -| Scope | Estimate | -| ----------------------------------- | -------------------------------------------: | -| Disposable canonical-copy prototype | 1–2 days | -| Production local installer | 5–8 days | -| Cloud sharing and local install UX | 1–2 weeks | -| Paired runtime support | 4–7 days | -| WSL and SSH completion | 4–7 days | -| Polished first release | Approximately 3–5 engineer-weeks | -| Full community CLI parity | 6–10 weeks plus ongoing registry maintenance | - -The recommended first release ends after Phase 4. Phase 5 should follow observed demand rather -than delaying private sharing for organization-wide policy features. - -## Implementation principles - -- One validated installer core, regardless of source or destination transport. -- One canonical copy, with provider placements reconciled separately. -- Immutable Cloud versions and mutable local installations are distinct concepts. -- An unpredictable durable share ID is a revocable bearer credential, not a permanent blob URL. -- Installed bytes, not child-process exit codes, determine success. -- Unknown or modified local state fails closed. -- Remote hosts own their paths and mutations. -- Upstream behavior informs compatibility but does not define Orca's internal architecture. diff --git a/docs/ai-vault-process-isolation-plan.md b/docs/ai-vault-process-isolation-plan.md deleted file mode 100644 index f9be048a12a..00000000000 --- a/docs/ai-vault-process-isolation-plan.md +++ /dev/null @@ -1,596 +0,0 @@ -# AI Vault process isolation architecture and implementation plan - -Status: implemented behind the documented desktop/runtime kill switch, with local build, parity, Electron, and A/B validation complete. - -Last updated: 2026-08-09. - -## Decision summary - -AI Vault will remain part of the integrated Orca renderer, but its host-side work will move behind a persistent service-process boundary. - -The target has three rules: - -1. The desktop and Orca runtime route local Vault scans and title resolution to one lazy, supervised Vault service process per host process. -2. The SSH relay routes Vault work to a relay-side Vault service process. The relay event loop that handles PTYs must not scan or parse Vault data. -3. The renderer publishes completed Vault results at low priority after terminal input is quiet. xterm and the Vault panel remain in the same renderer. - -This is deliberately not a separate `WebContentsView`, window, or embedded app. The measurements below show bounded CPU contention under an exaggerated scan, but no renderer long tasks and no evidence that a second Chromium renderer is justified. - -The public Electron IPC, runtime RPC, and SSH relay method names and result meanings remain unchanged in the initial migration. The process boundary is host-internal. - -## Goals - -- A Vault scan, parser fault, cache fault, or memory spike cannot crash the terminal daemon, relay PTY loop, Electron main process, or runtime host. -- Terminal input remains responsive while a local, runtime, or SSH Vault refresh is active. -- Vault work has bounded concurrency, queue depth, memory, result size, cancellation latency, and wall time. -- The service can be restarted without restarting the app, runtime, relay, or terminals. -- Existing local, folder-workspace, WSL, runtime, and SSH behavior remains compatible. -- Mixed-version remote peers continue to work. -- The architecture is conventional and debuggable: integrated UI, thin routers, dedicated background services. - -## Non-goals - -- Treating the service process as a security sandbox. It runs trusted Orca code with the same user identity. -- Rewriting the scanner or changing session discovery semantics during the process migration. -- Moving xterm into another renderer. -- Adding a process per window, worktree, repository, or SSH request. -- Removing the old-relay compatibility fallback in the same change. -- Making pagination a public wire dependency before mixed-version fallback behavior exists. - -## Current architecture and coupling - -| Path | Current execution | Remaining coupling | -| --------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| Desktop local scan and title resolution | Persistent `worker_threads.Worker` from `session-scanner-worker-spawn.ts` | Separate V8 isolate, but the same OS process, priority class, failure domain, and overall memory accounting as Electron main | -| Orca runtime scan and title resolution | The same worker client used by runtime RPC methods | Same-process CPU, memory, and lifecycle coupling with the runtime host | -| SSH relay scan and title resolution | `AiVaultHandler` calls the remote scanner and title reader inside `relay.ts` | Vault discovery, reads, parsing, cache work, and PTY routing share one event loop and process | -| Old SSH relay fallback | Desktop main crawls the host through the SSH filesystem provider | Compatibility path can consume desktop and SSH multiplexer work; complete remote isolation is impossible without a new relay | -| Renderer result publication | `setScanResult(result)` and `setSessions(result.sessions)` immediately | Deserialization, projection, filtering, grouping, and React rendering share the renderer thread with xterm | - -The current local worker already provides meaningful isolation. It allows one active request, bounds the queue at 16, supports cancellation, and uses 130-second scan and 15-second title timeouts. The migration should preserve those properties instead of replacing them with an unbounded child-process API. - -The terminal backend is already a detached daemon process. A Vault service beside it fits the existing process model; it does not require a terminal redesign. - -## Feature-parity contract - -The migration changes execution ownership only. Unless a separate product change is approved, the service-on and worker-fallback paths must be observably equivalent after normalizing `scannedAt` and process metrics. - -| Capability | Required behavior after migration | Owner | -| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -| Agent discovery | Preserve every `AI_VAULT_AGENTS` source: Claude, Codex, Hermes, Pi, OMP, Prime Agent, Cursor, Gemini, Antigravity, Rovo Dev, Copilot, OpenCode, Grok, OpenClaw, Devin, Droid, and Kimi | Service scanner, using existing source adapters | -| Session result | Preserve every `AiVaultSession` field, including host/platform identity, paths, Codex home, timestamps, previews, token/message counts, queued/recoverable state, subagent count, resume command, and subagent metadata | Service result builder; router validates/restamps only where it does today | -| Scope and depth | Preserve workspace/project/all scoping, guaranteed older in-scope sessions, 250/500/1000/unlimited depth, sorting, deduplication, and issue rows | Service per-host scan; router multi-host merge; renderer view projection | -| Host routing | Preserve local, all-host, individual SSH, and individual runtime selection with the same per-host time budgets and partial-failure issue behavior | Router | -| Dynamic roots | Preserve environment overrides, managed/per-account Codex homes, runtime Codex homes, WSL default and Orca-owned homes, and every platform-specific agent root | Router resolves dynamic homes per request; service discovers within them | -| Refresh semantics | Preserve TTL reuse, force refresh, request-token cancellation, force preemption, coalescing, window-focus refresh, and new-agent-session refresh throttling | Router coordinator plus service cancellation | -| Title synchronization | Preserve local, SSH, and runtime Claude/Codex title resolution and the existing input-quiet tab-title gate | Service title operation plus existing host routing | -| Subagent expansion | Preserve local-only Claude and OMP child listing, path containment, status, issue rows, and retry behavior. Runtime/SSH/web remain empty until a separately negotiated feature exists | Service interactive operation; router retains current host gate | -| First-prompt copy | Preserve local-only on-demand full prompt parsing, OpenCode row lookup, truncation safety, injected-turn suppression, and preview fallback. Runtime/SSH/web remain preview-only | Service interactive operation; router retains current host gate | -| Resume | Preserve provider-specific resume commands, Codex-home preparation, account targeting, execution-host targeting, platform quoting, OMP path resume, drag/drop, and new-tab launch | Existing router/renderer paths; they consume an unchanged session result | -| Logs and live state | Preserve View Log/Open Log, reveal/copy path, working-directory action, read-only editor behavior, live tail, original-pane jump, and worktree jump | Existing filesystem/editor/native-chat paths; outside the service migration | -| Delete | Preserve local-only gating, liveness checks, OS trash/WSL deletion, companion artifacts, idempotency, and rejection reasons | Trusted router executor; invalidate router and service caches on success | -| View behavior | Preserve search, agent toggles, sort/group, hide-empty/recoverable-empty logic, persisted view options, collapsed groups, and host/scope controls | Renderer; unchanged apart from publication scheduling | -| Compatibility | Preserve current web/runtime limitations, old-relay fallback, mixed-version result meaning, and unavailable-host issue rows | Router and existing public transports | - -Add a backend-equivalence suite that runs the same golden corpus through the current worker and the service process, normalizes only `scannedAt`, and deep-compares the complete results. The corpus must cover every agent, OpenCode SQLite and legacy formats, append-only incremental parsing, recoverable empty sessions, Claude/OMP subagents, managed Codex homes, duplicate Codex roots, WSL-shaped roots, scope inclusion, corrupt/partial files, and issue overflow. - -No field may be dropped to reduce IPC size during the migration. Any later payload reduction requires an independently versioned compatibility plan because resume, drag/drop, log actions, and project ownership consume fields that may not be visibly rendered in the list. - -## Target topology - -```text -Desktop / runtime host - - Integrated renderer - ├─ xterm UI ─────────── terminal IPC/router ───────── terminal daemon - └─ AI Vault panel ───── Vault IPC/RPC router ──────── Vault service process - │ - └─ host merge + compatibility routing - -SSH execution host - - Desktop ── existing SSH Vault RPC ── relay router ───── Vault service sidecar - └────────────────── PTY handler/event loop -``` - -The two relay branches share only the relay's bounded request/response routing. Filesystem discovery, transcript reads, JSON parsing, SQLite access, parse caches, and title caches execute in the sidecar. - -## Process ownership - -- Desktop: one Vault service per Electron main process, shared by every window and workspace. -- Orca runtime: one Vault service per runtime host process, shared by all connected clients. -- SSH relay: one Vault service per live relay daemon, shared across relay reconnects and requests. -- WSL: preserve current source ownership initially. The Windows local service invokes the existing WSL-aware adapters; do not add a process per distro in this migration. -- Old relay: retain the bounded desktop fallback only when the relay method is unavailable. A new relay whose sidecar fails must return an issue instead of scanning inline on the relay loop. - -## Service responsibilities - -The service owns: - -- Source discovery and filesystem metadata reads. -- Transcript streaming and parsing for all supported agents. -- OpenCode SQLite reads and its nested worker lifecycle. -- In-memory and persisted session parse caches. -- The bounded title index and transcript title resolution. -- On-demand Claude/OMP subagent listing and full first-user-prompt extraction. -- Scan parse concurrency and cooperative cancellation. -- Result construction, deduplication, sorting, and internal paging. -- Per-request metrics that contain counts and timings, not session content. - -The router owns: - -- Electron IPC, runtime RPC, or relay method registration. -- Authentication and connection ownership already enforced by that transport. -- Public argument validation and execution-host selection. -- Resolution of dynamic host inputs such as managed Codex homes and WSL distro homes; resolved roots are passed with each scan so a long-lived service cannot retain stale account configuration. -- Multi-host result merging. -- Request coalescing and mapping caller cancellation to the service request. -- Validation of service responses before publishing them across a public boundary. -- Compatibility fallback selection. -- Local deletion, liveness checks, OS trash integration, and cache-invalidation commands sent to the service after a successful mutation. - -The renderer owns only view state, filtering/grouping of the returned page, user actions, and low-priority result publication. - -## Internal process protocol - -Add a versioned, private protocol in `src/main/ai-vault/session-scanner-service-protocol.ts`. It is not a remote wire protocol. - -Parent to service: - -```ts -type ServiceRequest = - | { type: 'init'; protocol: 1; host: HostDescriptor; cache: CacheOptions } - | { type: 'request'; id: number; operation: 'scan'; options: ScanOptions } - | { type: 'request'; id: number; operation: 'titles'; requests: TitleRequest[] } - | { type: 'request'; id: number; operation: 'subagents'; request: SubagentRequest } - | { type: 'request'; id: number; operation: 'firstPrompt'; request: FirstPromptRequest } - | { type: 'invalidate'; paths: string[]; generation: number } - | { type: 'cancel'; id: number } - | { type: 'shutdown' } -``` - -Service to parent: - -```ts -type ServiceResponse = - | { type: 'ready'; protocol: 1; pid: number; capabilities: ServiceCapabilities } - | { type: 'result'; id: number; operation: 'scan'; value: ScanPage; metrics: ScanMetrics } - | { type: 'result'; id: number; operation: 'titles'; value: TitlesResult } - | { type: 'result'; id: number; operation: 'subagents'; value: SubagentListResult } - | { type: 'result'; id: number; operation: 'firstPrompt'; value: FirstPromptResult } - | { type: 'invalidated'; generation: number } - | { type: 'error'; id: number; code: ServiceErrorCode; message: string; retryable: boolean } - | { type: 'fatal'; code: ServiceErrorCode; message: string } -``` - -Protocol rules: - -- Validate every incoming and outgoing message. Ignore an unknown response ID and terminate on a protocol-version mismatch. -- Use monotonically increasing safe-integer request IDs scoped to the child lifetime. -- Preserve the existing shared limits for request counts, scope-path count and length, title requests, and session depth. -- Keep one cache-mutating lane for scans and title resolution, plus one independent interactive-read lane for subagent and first-prompt reads. Title resolution stays serialized with scans because both mutate the shared parse/title caches; the independent operations do not. Queue at most 16 calls across both lanes. -- Coalesce equivalent scans. Replace an older queued background scan with the newest one. -- Queue priority is first-prompt/subagent reads, queued title reads, forced foreground refresh, initial foreground load, then background refresh. Priority never preempts an active request; explicit cancellation does. -- Keep the existing 130-second scan and 15-second title deadlines. A timeout terminates and replaces the service because a timed-out parser cannot be assumed healthy. -- Cancellation rejects the caller immediately, sends `cancel`, and gives the service 2 seconds to acknowledge or finish. The service is terminated if it remains stuck. -- Continue returning the last good renderer result when a refresh fails. -- Invalidation is a control message, not queued scan work. The parent invalidates its result/host caches immediately and waits for the service acknowledgement before allowing a non-forced post-delete list result to reuse service cache state. -- Instrument serialized response size. Warn at 8 MiB. Do not reject legacy unlimited results until internal paging and public fallback are implemented. - -The first implementation may reuse the current plain-object Node IPC serialization. The payload does not require handles, sockets, or transferable buffers. - -## Lifecycle and supervision - -### Startup - -- Start lazily on the first scan or title request. -- Resolve an explicit entry path; packaged Electron must use `app.asar.unpacked` because `ELECTRON_RUN_AS_NODE=1` bypasses Electron's asar loader. -- Fork with `stdio: ['ignore', 'ignore', 'pipe', 'ipc']`, `ELECTRON_RUN_AS_NODE=1`, and `windowsHide: true` on Windows. -- Require a `ready` message within 5 seconds. A request does not enter its operation timeout until the service is ready. -- Pipe a bounded stderr tail into the existing diagnostic log. Never allow child output backpressure to stall the child. - -### Steady state - -- Reuse the process and caches across calls. -- Keep current parse concurrency at eight for the first A/B. Process isolation is not permission to increase concurrency. -- Set best-effort below-normal priority with `os.setPriority(child.pid, PRIORITY_BELOW_NORMAL)`. Failure to lower priority is observable but not fatal. -- Start with `--max-old-space-size=384`. Treat an out-of-memory exit as a service failure; keep terminals and the last Vault snapshot alive. -- Exit after 10 minutes with no active or queued request. Persisted parse-cache state makes a later cold process cheaper. - -### Faults and restart - -- Reject the active request on exit, disconnect, malformed protocol, or timeout. -- Retry queued work in a new process only when it is safe and has not been cancelled. -- Back off at 250 ms, 1 second, then 5 seconds. -- Open a 60-second circuit breaker after three unexpected exits in 60 seconds. Manual refresh may make one explicit restart attempt. -- On idle or orderly app/runtime/relay shutdown, flush any debounced parse-cache persistence, send the final acknowledgement, and exit. The parent waits at most 2 seconds, then terminates. Never hold host shutdown indefinitely. - -## Resource and quality-of-service policy - -The process boundary supplies crash and heap isolation. Priority and bounds supply responsiveness. - -- CPU: below-normal process priority, scan concurrency eight, one scan at a time. -- Memory: 384 MiB V8 old-space starting cap, 4,096 title-cache entries, current parse-cache eviction, and response-size telemetry. -- Queue: 16 calls, coalescing, background replacement, explicit priority. -- I/O: stream transcripts; do not read an entire store into memory. Preserve current per-file streaming behavior. -- UI: defer result publication until terminal input has been quiet for 100 ms, with a 1-second maximum deferral so the panel cannot starve. -- React: publish inside `startTransition`; keep the previous snapshot visible while the transition is pending. -- Unlimited history: retain behavior during migration. Add internal pages before enforcing a hard serialized-result cap. - -Process priority is best effort and cross-platform. Correctness must not depend on a particular scheduler implementation. - -## Renderer publication - -`ai-vault-session-refresh.ts` should stop applying a completed result immediately. - -Add `ai-vault-session-publication-gate.ts` with this behavior: - -1. Cache the validated result immediately so another caller can reuse it. -2. If no terminal has received input in the last 100 ms, publish now inside `startTransition`. -3. Otherwise retain only the newest pending result and wait for quiet. -4. Publish after 1 second even if input continues, using a transition and one bounded page. -5. Cancel a pending publication when the scope, host, or component request token changes. - -Reuse the existing terminal-input quiet signal used by AI Vault tab-title synchronization rather than adding global key listeners per panel. - -A separate Vault renderer remains a contingency only if post-service measurements show repeated renderer tasks over 50 ms that cannot be removed with publication scheduling, paging, and list virtualization. - -## Desktop and runtime implementation - -Create these concrete modules: - -- `src/main/ai-vault/session-scanner-service-entry.ts`: process entry, init/ready handshake, request dispatch, cancellation, cache initialization, and shutdown. -- `src/main/ai-vault/session-scanner-service-client.ts`: queue, deadlines, cancellation, validation, and restart policy. -- `src/main/ai-vault/session-scanner-service-spawn.ts`: lazy shared client and public scan/title functions. -- `src/main/ai-vault/session-scanner-service-entry-path.ts`: dev, E2E, and packaged path resolution. -- `src/main/ai-vault/session-scanner-service-protocol.ts`: message types, schemas, and error codes. -- `src/main/ai-vault/session-scanner-service-priority.ts`: best-effort process priority policy. - -Reuse the scanner and cache modules without moving their business logic. Replace imports in: - -- `src/main/ai-vault/cached-session-list.ts` -- `src/main/ai-vault/session-title-resolver.ts` -- `src/main/ipc/ai-vault-subagent-list.ts` -- `src/main/ai-vault/session-first-user-prompt-read.ts` -- `src/main/ipc/ai-vault-delete.ts` for acknowledged service-cache invalidation after a successful delete -- `src/main/ipc/ai-vault.ts` -- `src/main/runtime/rpc/methods/ai-vault.ts` - -Promote the parse-cache flush currently exposed only for tests into an internal production shutdown function. Idle and orderly service exits must await it within the shutdown grace period. - -Keep the current worker implementation behind the rollout switch until the new process passes packaging and fault-injection coverage. Remove it in a later cleanup change, not in the initial migration. - -## SSH relay sidecar implementation - -Create: - -- `src/relay/ai-vault-service-entry.ts`: bundled Node sidecar entry using the remote scanner and title reader. -- `src/relay/ai-vault-service-client.ts`: relay-owned supervisor and internal protocol adapter. -- `src/relay/ai-vault-service-priority.ts`: the same best-effort QoS policy without Electron imports. - -Change `src/relay/ai-vault-handler.ts` so it validates public params and forwards them to the sidecar. It must no longer import or call `scanRemoteAiVaultSessions` or `readAiVaultSessionTitlesFromFiles` in the relay process. - -`relay.ts` creates the client once, injects it into `AiVaultHandler`, and shuts it down with relay lifecycle. The PTY handler does not depend on sidecar readiness. - -If the sidecar cannot start or crashes: - -- Return a normal `AiVaultListResult` containing a host issue and no new sessions. -- Do not scan inline in the relay. -- Keep all PTYs, reconnect state, and other relay methods available. -- Allow the next manual refresh to cross the restart circuit breaker once. - -Build `relay-ai-vault-service.js` for every current relay platform in `config/scripts/build-relay.mjs`. Include its bytes in the immutable relay content hash so a sidecar-only change deploys a new relay directory. The bundle must target Node 18 and rely only on Node built-ins plus already supported optional externals. - -## Remote compatibility - -The first implementation adds no public method, stream opcode, required field, or changed result meaning. - -- Current desktop with new relay: calls the existing Vault relay methods; the new relay handles them through its sidecar. -- New desktop with current relay: calls the same methods and receives the same result shape. -- New desktop with an older relay lacking the method: uses the existing budgeted SSH filesystem fallback. -- Old desktop with new relay: unknown internal sidecar details never cross the wire. - -Do not make a new client depend on internal paging fields from an older host. If public paging is added later, use optional request/result fields and fall back to the existing complete-result call when the host does not return paging capability. - -The old-relay fallback is the explicit isolation exception. Keep its total budget and cancellation. Do not broaden when it is selected. Once relay adoption data shows the fallback is rare, it can be reduced or removed in a separate compatibility decision. - -## Folder workspaces, WSL, and paths - -- Scope paths remain opaque filesystem paths. Do not require a `.git` directory or worktree metadata. -- Continue using `path.join` and the existing host-platform path adapters. -- Keep Windows batch and shell selection unrelated to terminal-shell preference. -- Preserve WSL host identity and cache scoping; capability and cache state must not leak between distros or native Windows. -- An SSH service reads only the remote host's paths. A desktop service must not interpret remote paths as local paths. -- Title requests must continue through the existing readable-path resolution and allowed-root validation before opening a transcript. -- Subagent and first-prompt requests remain local-only and must validate renderer-provided paths against the same agent-source roots used for discovery, including managed and WSL roots. Tightening path validation must ship with parity fixtures so legitimate current roots are not rejected. - -## Security boundary - -The service is a performance and reliability boundary, not a privilege boundary. - -- It has the same OS user and must access the same agent stores. -- Do not expose its IPC channel to renderer code or the network. -- The parent passes validated options; the service validates again before filesystem access. -- Do not log prompts, titles, transcript paths, environment values, or raw service messages. -- Preserve symlink and allowed-root checks for title reads. -- Inherit only the environment needed for current agent-home discovery and process operation. Document any removed variables with cross-platform tests before tightening further. - -## Packaging - -Desktop packaging changes: - -- Add `session-scanner-service-entry` to the main inputs in `electron.vite.config.ts`. -- Add `out/main/session-scanner-service-entry.js` to `asarUnpack` in `config/electron-builder.config.cjs`. -- Reuse the existing unpacked entry-path pattern used by other forked processes. -- Add a packaged smoke test that resolves the real unpacked path, starts the service, scans one fixture, and shuts down. - -Relay packaging changes: - -- Bundle the sidecar beside `relay.js` and `relay-watcher.js` for every supported OS/architecture. -- Include it in `.version` content hashing and deploy completeness assertions. -- Verify reconnect and versioned-install cleanup keep the matching relay and sidecar together. -- No new native module is required, so the Linux glibc floor should remain unchanged. Continue running the packaging verifier. - -## Observability - -Add spans and counters for: - -- Service start, ready latency, PID, restart reason, circuit-breaker state, and idle exit. -- Queue depth, queue wait, priority class, coalesced calls, and dropped background calls. -- Discovery duration, parse duration, candidates, bytes read, cache reuse, incremental parses, full parses, and result count. -- Parent/child serialization duration and serialized bytes. -- Child RSS and heap-used snapshots at request completion. -- Renderer result receipt, quiet-wait duration, transition publication duration, and displayed session count. -- Relay sidecar failures separately from PTY and relay-loop health. - -Metrics must use counts, byte sizes, and durations only. Paths and transcript-derived content stay out of logs. - -## Verification matrix - -The design is not ready for default-on until each layer below passes with both the worker fallback and process backend where applicable. - -| Layer | Required coverage | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Pure parser | Existing fixtures for every agent and format; incremental append, corrupt/partial input, title precedence, usage, previews, queued prompts, recoverable empties, and subagent counts | -| Backend equivalence | Worker versus process deep equality for complete list, title, subagent, and first-prompt results; only `scannedAt` and internal metrics may differ | -| Cache | Cold/warm/incremental scans, persistence restart, atomic save failure, corrupt cache, app-version mismatch, delete generation guard, service invalidation acknowledgement, and idle-exit flush | -| Queue/lifecycle | Both logical lanes, priority, coalescing, 16-call bound, cancellation before/while queued/active, deadlines, malformed IPC, crash, OOM, backoff, circuit breaker, and shutdown | -| Desktop Electron | Visible list, scope/host/depth controls, refresh/cancel, first-prompt copy, Claude/OMP expansion, delete, View/Open Log, live tail, resume, drag/drop, and service-crash terminal typing through Electron/CDP | -| Runtime/web | List/title/resume parity, host restamping, managed Codex homes, unchanged no-op cancellation limitation, and unchanged unavailable subagent/first-prompt/delete behavior | -| SSH relay | All-agent remote list, title resolution, scope truncation, sidecar crash/timeout/OOM, PTY typing/output/reconnect continuity, old-relay fallback, and both mixed-version directions | -| Windows/WSL | Native and per-distro roots, UNC paths, WSL deletion, priority failure fallback, hidden child windows, packaged entry path, and host-isolated cache/source state | -| Linux/package | Ubuntu 20.04/glibc floor, Node 18 relay bundle, packaged entry smoke, sidecar deploy/hash completeness, and no undeclared native dependency | -| Folder workspace | Workspace/project scope, ownership mapping, resume target, path actions, and deletion without assuming Git metadata | - -The Electron feature-parity run must assert visible behavior rather than only inspecting the store. Service fault tests additionally assert PTY markers so a Vault failure cannot pass while silently disrupting terminals. - -## Failure behavior - -| Failure | User-visible Vault behavior | Terminal behavior | -| ---------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------- | -| Service fails to start | Keep last result and show a retryable host issue | Unchanged | -| Service crashes during scan | Reject refresh, restart under backoff, keep last result | Unchanged | -| Service exceeds memory cap | Treat as crash and reduce confidence telemetry; do not remove the cap silently | Unchanged | -| Scan or cancellation hangs | Kill and replace service after the deadline/grace | Unchanged | -| Queue is full | Coalesce or drop background work; foreground call gets a clear retryable error | Unchanged | -| Relay sidecar unavailable | Remote Vault host shows an issue | Relay PTYs and reconnect remain available | -| Renderer publication is superseded | Drop the stale result and publish the newest scope only | Unchanged | -| Old relay has no Vault method | Use the existing bounded desktop fallback | Existing compatibility limitation remains | - -## Benchmark methodology - -The new `bench:ai-vault-typing` command launches a fresh Electron app through Playwright's Electron/CDP path and uses an isolated home. - -Each iteration: - -1. Keeps the Vault panel mounted in both arms. -2. Seeds 300 new, newer-mtime Codex JSONL transcripts in the isolated home. -3. Alternates arm order to reduce warm-up bias. -4. Runs paced raw-mode terminal echo typing as the control. -5. Runs the same typing while programmatically starting a forced visible Vault refresh as treatment. -6. Verifies the newest seeded title is visible, proving the full scan and UI path completed. - -The renderer records keydown-to-xterm-parse, keydown-to-xterm-render, long tasks, timer drift, animation-frame gaps, refresh duration, and missing echoes without polling or serializing the terminal buffer for each key. - -Raw reports are written to the ignored `tests/tools/benchmarks/results/` directory. - -## Current measurements - -Machine: Apple M5 Pro, 64 GiB RAM, macOS 26.5.1. These are local-host measurements on the current worker-thread architecture after the recently merged performance work. - -Pre-implementation parity baseline: - -- 90 Vault-focused unit/integration test files passed: 778 tests covering parsers, caches, routing, deletion, resume, renderer projection, subagents, first prompts, and runtime contracts. -- Electron/CDP parity spot-check passed for long View Log tail stability, single-file session deletion, and Claude companion-directory deletion: 3 tests. -- The representative, stress, and final benchmark smoke runs all completed with visible Vault results and zero missing terminal echoes. - -### Representative pass - -Configuration: 3 iterations, 300 new sessions per iteration, 128 KiB assistant payload per session, 100 keys per arm at 30 ms cadence, 118.4 MB total seeded data. - -| Metric | Control | Refresh active | -| ------------------------------ | -------------------: | ------------------: | -| Parse p50 / p95 / max | 1.3 / 2.5 / 29.8 ms | 1.3 / 2.0 / 5.4 ms | -| Render p50 / p95 / max | 5.9 / 10.0 / 35.4 ms | 5.8 / 9.5 / 10.6 ms | -| Worst timer drift | 14.2 ms | 24.5 ms | -| Worst frame gap | 9.4 ms | 24.9 ms | -| Renderer long tasks over 50 ms | 0 | 0 | -| Missing echoes | 0 / 300 | 0 / 300 | -| Refresh duration p50 / max | — | 103.5 / 204.2 ms | - -There is no treatment-side typing regression in this pass. The current worker is doing useful work and should remain the rollback path during migration. - -### Stress pass - -Configuration: 2 iterations, 300 new sessions per iteration, 512 KiB assistant payload per session, 120 keys per arm at 30 ms cadence, 314.9 MB total seeded data. - -| Metric | Control | Refresh active | -| ------------------------------ | ------------------: | -------------------: | -| Parse p50 / p95 / max | 1.3 / 1.9 / 19.7 ms | 1.4 / 4.7 / 13.8 ms | -| Render p50 / p95 / max | 5.6 / 9.4 / 23.8 ms | 6.0 / 11.2 / 16.3 ms | -| Worst timer drift | 9.3 ms | 43.6 ms | -| Worst frame gap | 9.4 ms | 50.0 ms | -| Renderer long tasks over 50 ms | 0 | 0 | -| Missing echoes | 0 / 240 | 0 / 240 | -| Refresh duration p50 / max | — | 276.3 / 453.5 ms | - -The exaggerated workload causes measurable but bounded whole-system contention. It does not show renderer result application becoming a long task. This is evidence for a lower-priority service process and against a second Chromium renderer. - -### Measurement limits - -- One high-end macOS machine does not establish Windows, Linux, low-core, battery, or memory-pressure behavior. -- The benchmark covers the local service path, not the currently unisolated SSH relay path. -- Raw shell echo is a stable latency probe, not a full-screen TUI workload. -- A child process still competes for machine-wide CPU; the expected benefit comes from priority, heap/failure isolation, and supervision, not from eliminating CPU cost. - -## Post-implementation A/B - -Run the same build with the rollout switch off and on, alternating launch order: - -```bash -ORCA_AI_VAULT_SERVICE_PROCESS=0 pnpm bench:ai-vault-typing -- --label worker-control -ORCA_AI_VAULT_SERVICE_PROCESS=1 pnpm bench:ai-vault-typing -- --label process-treatment -``` - -Run at least five representative and five stress passes on: - -- macOS Apple Silicon. -- Windows x64 with a folder workspace and a WSL-backed source present. -- Ubuntu 20.04-compatible x64 packaging/runtime. -- A 4-core or smaller machine or constrained CI runner. - -Add a Docker SSH variant that runs the same terminal probe while the remote sidecar scans. A fault arm kills the sidecar during typing and verifies the PTY marker stream and reconnect remain healthy. - -## Implemented result - -The implementation now matches the target topology: - -- Desktop and runtime list scans, title resolution, subagent reads, and first-prompt reads use a lazy supervised child process by default outside unit tests. `ORCA_AI_VAULT_SERVICE_PROCESS=0` retains the worker fallback. -- Successful local deletion remains in the trusted main process and acknowledges child parse-cache invalidation. In-flight parses cannot restore an invalidated entry. -- The SSH relay handler no longer imports the remote scanner, transcript title reader, or filesystem provider. It forwards existing public methods to `relay-ai-vault-service.js`; sidecar failure returns a normal host issue. -- All six relay platform bundles include the sidecar, hash its bytes into `.version`, and require it in remote-install completeness probes. -- Renderer results cache immediately, retain only the newest pending publication, wait for 100 ms of input quiet, publish in a React transition, and cannot defer beyond one second. -- Desktop and relay supervisors bound the queue at 16, cap old-space at 384 MiB, use below-normal priority where supported, enforce ready/operation/cancellation/shutdown deadlines, and restart with bounded backoff plus a three-fault circuit breaker. - -Local validation on 2026-08-09: - -- Electron E2E build emitted `out/main/session-scanner-service-entry.js`; every relay target built `relay-ai-vault-service.js` successfully. -- Real built-child smoke checks passed for the desktop service title lane and relay sidecar list lane. -- Vault, runtime, renderer, relay, and remote-install focused tests passed 900/901 in one loaded run. The single unrelated 150 ms map micro-benchmark measured 184 ms during that parallel run and passed at 30 ms alone. -- Electron/CDP feature checks passed for long View Log stability, single-file deletion, and Claude companion-directory deletion with the process backend enabled. - -Representative A/B configuration: 3 iterations, 300 sessions per iteration, 128 KiB payloads, and 100 keys at 30 ms cadence. - -| Metric | Worker control | Worker refresh | Process control | Process refresh | -| --------------------- | -------------: | -------------: | --------------: | --------------: | -| Parse p95 | 2.0 ms | 1.9 ms | 6.2 ms | 6.4 ms | -| Render p95 | 9.6 ms | 9.6 ms | 11.0 ms | 12.3 ms | -| Worst timer drift | 9.4 ms | 17.4 ms | 10.4 ms | 12.0 ms | -| Worst frame gap | 9.4 ms | 9.4 ms | 16.8 ms | 16.2 ms | -| Long tasks over 50 ms | 0 | 0 | 0 | 0 | -| Missing echoes | 0 / 300 | 0 / 300 | 0 / 300 | 0 / 300 | -| Refresh p50 / max | — | 74.9 / 76.9 ms | — | 83.2 / 165.1 ms | - -The process arm stayed well inside every acceptance budget. It did not outperform the already-optimized worker on raw parse latency; its value is the intended heap, crash, priority, relay-loop, and lifecycle isolation while preserving terminal responsiveness. - -Windows/WSL, Ubuntu 20.04, constrained-core, packaged-app launch, and Docker SSH fault measurements remain release/CI matrix work because they cannot be truthfully produced on this macOS host. The implementation includes their path, platform, packaging, Node 18, and mixed-version compatibility requirements; default rollback remains one environment variable. - -## Acceptance criteria - -Correctness: - -- Zero missing terminal echoes. -- The latest seeded Vault session is visibly published in every treatment arm. -- Local, runtime, SSH, WSL, and folder-workspace session fixtures remain equivalent to the current scanner. -- Cancellation, force-preemption, host merging, title resolution, and unlimited history preserve current semantics. - -Performance: - -- Treatment parse p95 is no more than 10 ms above its paired control. -- Treatment render p95 is no more than 15 ms above its paired control. -- Median active-key render latency remains at or below 75 ms; worst remains below 300 ms. -- No renderer task attributable to Vault result publication exceeds 50 ms in the representative run. -- No relay PTY input/output stall exceeds the terminal reliability budget while its sidecar scans or crashes. - -Isolation: - -- Killing or exhausting the desktop service does not crash Electron main, runtime RPC, renderer, or terminal daemon. -- Killing or exhausting the relay sidecar does not interrupt PTYs, relay reconnect, or non-Vault methods. -- The service respects queue, timeout, idle, and heap bounds. -- Packaged app and relay artifacts contain and launch the correct entry for every supported platform. - -## Phased implementation plan - -### Phase 0 — measurement harness (completed in this change) - -- Add `terminal-ai-vault-typing-latency.spec.ts` and its focused corpus/renderer probes. -- Add `run-ai-vault-typing-bench.mjs` and `pnpm bench:ai-vault-typing`. -- Capture representative and stress baselines. - -Exit: reproducible JSON report, visible Vault completion assertion, zero missing echoes. - -### Phase 1 — desktop and runtime service behind a switch - -- Add the private service protocol, entry-path resolver, process entry, client, priority policy, and supervisor. -- Reuse current scanner/cache implementations. -- Route desktop IPC and runtime RPC through the service when `ORCA_AI_VAULT_SERVICE_PROCESS` is enabled. -- Keep the worker path as the disabled-switch fallback. -- Add unit tests for ready timeout, queue priority, coalescing, cancellation, operation timeout, crash, restart backoff, circuit breaker, idle exit, malformed messages, and packaged path resolution. -- Add E2E fault injection that kills the service during active terminal typing. - -Exit: desktop/runtime correctness parity, packaging smoke test, representative/stress A/B within budgets. - -### Phase 2 — make the desktop/runtime process the default - -- Enable the process by default in development, E2E, and canary builds. -- Collect process RSS, restart, timeout, and latency telemetry without content. -- Validate Windows, Linux, WSL, and constrained-runner results. -- Keep `ORCA_AI_VAULT_SERVICE_PROCESS=0` as a release kill switch for one stable cycle. - -Exit: no unexplained crash or timeout increase and all acceptance gates green. - -### Phase 3 — SSH relay sidecar - -- Add the relay sidecar entry and supervisor. -- Remove scanner/parser/cache imports from the relay handler. -- Add the sidecar to every relay bundle, content hash, deploy manifest, and completeness test. -- Add Docker SSH scan, typing, sidecar-crash, reconnect, old-relay fallback, and mixed-version tests. -- Make inline relay scanning impossible; sidecar failure returns a host issue. - -Exit: remote typing and fault gates green; current public relay methods unchanged. - -### Phase 4 — renderer quiet publication - -- Add the shared quiet-publication gate and React transition. -- Retain only the newest pending result per scope/host. -- Add supersession, starvation timeout, unmount, and rapid-host-switch tests. -- Re-run the Electron benchmark with large result counts and active filtering/grouping. - -Exit: no Vault-attributable renderer long task over 50 ms in the representative gate. - -### Phase 5 — hardening and cleanup - -- Remove the worker implementation after one stable release if the kill switch was unused. -- Decide whether public optional paging is necessary from serialized-size and renderer-publication telemetry. -- Promote `bench:ai-vault-typing` from an experimental benchmark to the terminal reliability gate. -- Document operational recovery and service diagnostics. - -Exit: kill switch and dead worker code removed only after evidence supports it. - -## Rollback - -- Desktop/runtime: set `ORCA_AI_VAULT_SERVICE_PROCESS=0` to restore the current worker path without changing public APIs or stored data. -- Renderer: disable quiet publication and apply the validated complete result directly. -- Relay: deploy the prior immutable relay version. A new relay must never fall back to inline scanning when its sidecar fails. -- Parse-cache format remains unchanged during migration, so rollback does not require cache conversion. - -## Confidence and remaining questions - -Confidence in the overall topology is about 90%. Confidence that a separate Vault renderer is unnecessary is higher because both baseline passes recorded zero renderer long tasks. Confidence that the local child process alone will materially improve normal typing latency is lower: the current worker already performs well, and the process migration is primarily buying priority, heap, crash, and lifecycle isolation. - -The remaining implementation questions are deliberately narrow: - -1. Is 384 MiB the right old-space cap on Windows/Linux and for unlimited histories? Resolve with packaged stress runs before default-on. -2. Does the 10-minute idle exit improve retained memory without creating noticeable cold-refresh churn? Resolve with startup/RSS telemetry. -3. Is public paging necessary? Add it only if response-size or renderer-publication evidence crosses the stated budgets. -4. How large is the current SSH relay impact on low-core hosts? Resolve with the Phase 3 Docker and constrained-runner typing gate; it does not change the decision to remove scans from the relay loop. diff --git a/docs/automations-all-hosts-design.md b/docs/automations-all-hosts-design.md deleted file mode 100644 index c186cc67c13..00000000000 --- a/docs/automations-all-hosts-design.md +++ /dev/null @@ -1,530 +0,0 @@ -# All-host Automations - -## Status - -Proposed. The protocol, identity, and fencing decisions are settled here. The product decisions listed under `Deferred / Open Questions` at the end of this document are still open and need an owner before the first release. - -## Summary - -The Automations page is global, but its data is currently loaded from one active authority and several code paths later infer ownership from a bare automation, run, repo, workspace, or host ID. That is unsafe once records from multiple desktop and runtime stores appear together. - -This design introduces an authority-qualified automation catalog. Every row captures the exact desktop/runtime and self/SSH incarnation that produced it. Lists are cached per visible host, mutations and secondary reads are fenced against that captured incarnation, runtime version skew has an explicit fallback, and failures remain isolated to one authority. - -`All hosts` is a renderer aggregation mode, not a backend-wide query. Selecting one host performs no discovery or automation query against unrelated authorities. - -## Goals - -- Show Orca automations stored by the desktop and every saved remote Orca runtime, including each authority's user-visible SSH targets. -- Make host scope visible and persistent without treating an unhydrated catalog as removal. -- Prevent ID collisions, runtime re-pairing, SSH remove/re-add, and late responses from crossing ownership boundaries. -- Keep useful cached results visible when one authority is slow, offline, incompatible, or stale. -- Route list, edit, delete, pause/resume, Run Now, run history, usage, navigation, and workspace actions through the captured owner. -- Avoid startup fanout, remote-manager fanout, unbounded retries, and all-run history downloads. -- Bring every dispatched run to a terminal state without a client attached, including unselected, page-closed, and headless runs. -- Remain safe when desktop clients and runtime servers update independently. - -## Non-goals - -- A global server-side automation index. -- Persisting automation list results across app restarts. -- Moving an automation between desktop and runtime authorities. -- Showing external automation managers owned by remote runtimes in the first release. -- Changing mobile routes, mobile RPCs, pairing, E2EE, or relay framing. -- Changing shell, path, Git, or Git-provider behavior. - -## Required invariants - -1. Storage authority is not inferred from `runContext.hostId`, `schedulerOwner`, the active runtime, or the selected workspace. Those fields may mark a record ambiguous; they may never assign it to a different authority. -2. An ID is unique only inside its authority. Automation, run, repo, workspace, setup, navigation, dialog, availability, and in-flight action keys are authority-qualified. -3. A stable host selection survives label and connection-state changes, but data or actions captured from an old runtime/SSH incarnation do not. -4. Missing catalog data is not evidence of removal. Removal requires a hydrated authoritative catalog or a tombstone. -5. Local selection never contacts a runtime or probes desktop SSH external managers. One runtime selection never contacts another runtime. -6. Target execution health and authority query health are separate. A disconnected SSH target does not prevent its owning, reachable authority from listing or editing stored automations. -7. A parameterless `automation.list` keeps returning the authority's complete list for old clients. - -## Ownership and identity - -Use separate stable and incarnation-bearing forms: - -```ts -type AutomationAuthorityRef = - | { kind: 'desktop' } - | { - kind: 'runtime' - environmentId: string - pairingRevision: number - } - -type AutomationHostSelector = - | { kind: 'self' } - | { - kind: 'ssh' - targetId: string - targetGeneration: number - } - -type AutomationOwnerRef = { - authority: AutomationAuthorityRef - selector: AutomationHostSelector -} - -type StableAutomationHostRef = { - authority: { kind: 'desktop' } | { kind: 'runtime'; environmentId: string } - selector: { kind: 'self' } | { kind: 'ssh'; targetId: string } -} - -type StableAutomationCatalogRef = - | StableAutomationHostRef - | { - authority: { kind: 'desktop' } | { kind: 'runtime'; environmentId: string } - selector: { kind: 'orphan' } - } -``` - -`pairingRevision` is the saved runtime environment's existing pairing revision. `targetGeneration` is a durable SSH registration incarnation, not a connection attempt counter. Add an optional generation to stored SSH targets and `SshTargetSummary`; assign one while loading legacy targets and advance it only when a target is deleted/re-created or explicitly re-adopted. Connection, reconnect, and status transitions do not advance it. - -Allocate generations from a persisted monotonic counter owned by each automation authority. On load, the authority reloads that counter as a high-water mark — `max(persisted counter, highest generation on any stored target or automation) + 1` — so a counter lost to a rollback can never reissue a generation an automation already captured. The migration first scans stored automations: a referenced missing SSH target creates a bounded removal tombstone with its last known label before generations are assigned. That makes legacy ghosts discoverable from the existing mirrored `removedTargetLabels`, including on a newly paired client with no list cache. Runtime-owned migrations run on the runtime authority, not on the desktop copy of its catalog. - -New and migrated automations persist the selected SSH generation as an optional owner field. A legacy automation whose target still exists adopts the target's current generation during migration. A legacy automation whose target is absent becomes an orphan and is not silently assigned to Self or to a later same-ID target. - -### Orphan and ambiguous records - -An orphan has a known storage authority and no executable owner. An ambiguous record is a desktop-stored automation whose `schedulerOwner` or run context points at a runtime. Both are readable, and both follow the same rules: - -- The owning authority refuses to dispatch them. Its scheduler and `automation.runNow` return the typed `target_removed` conflict and record a skipped-run reason. Disabling actions in the client is presentation, not enforcement. -- Migration persists `enabled: false` for every record it classifies as orphaned or ambiguous, so a classified record cannot keep firing on a guessed host before its authority is upgraded. -- Run Now, edit, and workspace navigation are disabled. Pause/resume and Delete stay enabled: both need only the storage authority, which is known. -- Re-adoption is offered only when a compatible target is present under the same authority. When none is, Delete is the only remaining repair. Cross-authority moves stay unsupported. -- Orphan reads are fenced like owned reads. List requests, `automation.show`, and cache entries carry `{ kind: 'orphan' }` as the selector, and commit compares authority, catalog, and request generations only. A record moving into or out of orphan publishes one source and one destination event, or a single unscoped authority event when the old selector cannot be recovered. - -Two keys serve different purposes: - -- `hostStableKey(StableAutomationCatalogRef)` excludes incarnation fields and is used by the persisted filter and display slot. -- `ownerKey(AutomationOwnerRef)` includes `pairingRevision` and `targetGeneration` and is used by fetched rows, mutations, secondary reads, and request commit checks. - -Use one canonical encoder with explicit kind prefixes and encoded components; do not use display labels, bare IDs, or ad hoc `JSON.stringify` order as keys. - -```ts -type AutomationListRow = - | { - kind: 'owned' - key: string // ownerKey + automation.id - owner: AutomationOwnerRef - automation: Automation - usageSummary: AutomationUsageSummary | null - } - | { - kind: 'orphan' - key: string // authority stable key + automation.id - authority: AutomationAuthorityRef - automation: Automation - issue: string - } -``` - -Every owner-qualified navigation record includes the automation ID and, when applicable, the run ID, repo ID, workspace ID, and project-host-setup ID. Existing maps keyed only by repo/workspace ID cannot be used to resolve these rows because cross-host ID collisions are legal. - -Before any mutation or secondary read, resolve the current catalog entry for the stable key and compare its full owner to the captured owner. A mismatch fails closed with `This automation's host changed. Reload it before continuing.` The backend repeats this validation for capable runtimes; client-side checking alone is not sufficient. - -## Automation host catalog - -Create a dedicated automation host catalog. Do not overload the flat `ExecutionHostId`, because Runtime + SSH needs both a parent authority and a nested target. - -```ts -type AutomationHostCatalogEntry = { - stableRef: StableAutomationCatalogRef - owner: AutomationOwnerRef | null - stableKey: string - label: string - authorityLabel: string - kind: 'self' | 'ssh' | 'orphan' - catalogState: 'authoritative' | 'unhydrated' | 'removed' - authorityHealth: AutomationAuthorityHealth - executionHealth: AutomationExecutionHealth - querySupport: 'scoped' | 'legacy-unscoped' | 'incompatible' -} -``` - -Catalog projection rules: - -- Project Desktop + Self and desktop-owned saved SSH targets from the existing execution-host registry. -- Project Runtime + Self from saved runtime environments, even while a runtime is offline. -- Project Runtime + SSH from that environment's `sshStateByEnvironment` bucket. Combine parent runtime health/compatibility with nested SSH state; never copy nested targets into desktop SSH maps. That bucket gains a per-target registration-generation map alongside `targetLabels`, populated from the extended `SshTargetSummary` and cleared with the other bucket fields when the environment's SSH state goes stale. A runtime that does not advertise `automation.list-host-scope.v1` supplies no generations: its entries key on target ID alone and are view-only. -- Preserve labels from saved environments, `targetLabels`, and `removedTargetLabels` during outages. -- Hide runtime-owned ephemeral SSH targets under the existing visibility rule. -- Preserve a ghost/orphan entry when referenced by a stored automation, cached row, persisted filter, or removal/re-adoption tombstone. -- Do not initiate runtime connections, target-list calls, or SSH connections merely to render the picker. Runtime SSH discovery consumes already mirrored state and becomes authoritative only when `targetsHydrated` is true. - -Catalog generation is tracked per authority and advances whenever that authority's authoritative membership or incarnation changes. The commit fence compares only the generation of the authority that owns the entry, so one host's membership change — another runtime's target bucket hydrating, a target added anywhere — cannot discard every other host's in-flight response and exhaust the retry cap. Runtime connection status and SSH connection status update health without changing catalog generation. - -Deterministic order is: All hosts, Desktop + Self, desktop SSH targets by locale-aware label then target ID, the desktop orphan entry, runtime authorities by label then environment ID, and each runtime's SSH targets immediately after its Self entry by label then target ID followed by that authority's orphan entry. Omit empty orphan entries. Construct one collator and precomputed sort fields per catalog rebuild. - -## Persisted filter and hydration - -Persist this optional UI value: - -```ts -type AutomationHostFilter = - | { kind: 'all' } - | { kind: 'host'; host: StableAutomationCatalogRef } -``` - -Only the stable form is persisted. Restore it after the relevant catalogs settle: - -- Desktop + Self is immediately authoritative. -- Desktop SSH absence counts only after `sshTargetsHydrated`. -- Runtime Self absence counts only after the saved runtime catalog settles. -- Runtime SSH absence counts only after that runtime's target bucket hydrates or a removal tombstone provides positive evidence. - -While relevant state is unhydrated, retain the selection and show `Loading host…`; do not fall back or write `All hosts` over the saved value. Once positive removal evidence exists, keep an orphan choice if automations/tombstones still reference it; otherwise switch to All hosts and announce the change. - -A persisted orphan selection settles only after the owning authority has returned an authoritative `orphanCount` or an old-server unscoped list. If the authority is offline, retain the selection as unavailable rather than assuming the orphan was repaired. - -SSH re-adoption migrates automation owner generations, repo/workspace references, removal tombstones, and this persisted filter in one persistence transaction. Same-ID runtime re-pairing retains the display selection, but evicts old authority data and requires a fresh query before rows or actions return. - -## List contract and runtime compatibility - -Add two string capability constants to `src/shared/protocol-version.ts`: - -```ts -export const AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY = - 'automation.list-host-scope.v1' as const - -export const AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY = - 'automation.owner-fencing.v1' as const -``` - -The first capability guarantees scoped list parameters, runtime response validation fields, bounded usage summaries, and expected SSH-generation checking for list reads. The second covers mutation and secondary-read owner preconditions. - -Both constants must also be appended to the exported `RUNTIME_CAPABILITIES` array in the same file. `getStatus()` advertises only entries drawn from that array, so a declared-but-unregistered capability leaves every capable runtime looking legacy and makes the New/New matrix row unreachable. - -The exact `automation.list` request is: - -```ts -type AutomationListParams = - | undefined - | null - | { - selector?: - | { kind: 'self' } - | { kind: 'ssh'; targetId: string; expectedTargetGeneration: number } - | { kind: 'orphan' } - } -``` - -Omitted params, `null`, `{}`, or an omitted `selector` return the authority's complete automation list. This preserves old-client behavior. A supplied selector filters in the authority backend before serialization. Self means records explicitly stored with a self/local execution target; it does not mean “anything not recognized as SSH.” - -The response keeps the existing `automations: Automation[]` field so an old client can consume a new server. A capable server adds projection metadata; for a scoped request, `items` is required and has a validated one-to-one match with `automations`: - -```ts -type AutomationListResult = { - automations: Automation[] - items?: Array<{ - automationId: string - selector: - | { kind: 'self' } - | { kind: 'ssh'; targetId: string; targetGeneration: number } - | { kind: 'orphan'; issue: string } - usageSummary?: AutomationUsageSummary | null - }> - orphanCount?: number -} -``` - -The client runtime-validates rather than casts. It rejects malformed top-level responses, drops and records malformed individual rows, requires exactly one metadata item per scoped automation ID, verifies every returned selector matches the request, and never reclassifies a mismatched row into Self. A malformed or missing metadata item drops its paired automation from the committed rows and increments the invalid-row counter; the one-to-one requirement is evaluated over the surviving pairs, not over the raw `automations` array, so one bad row neither hides a whole host nor produces an unqualified row. Old clients ignore `items` and `orphanCount`; new clients talking to old servers use the legacy partition below. Any scoped response may report `orphanCount`; a positive count adds the authority's orphan catalog entry and queues one orphan-scope request. An orphan row has known storage authority but no executable owner, so it is readable and all actions are disabled. - -Routing is by authority: - -- Desktop uses local IPC with the same selector semantics and owner preconditions. -- A capable runtime gets one scoped request per requested host. -- An older runtime gets at most one parameterless `automation.list` request per authority per refresh cycle. Partition that result into all requested Self/SSH cache entries; never make the same full-list request once per selector. - -Legacy partitioning is explicit: - -- Self requires positive evidence: `executionTargetType` is `local` **and** the owning repo resolves with no connection ID. The desktop store writes the literal `local` whenever that repo lookup misses, and for repos hosted on a runtime, so a bare `local` value is not evidence on its own. -- A valid SSH record with a non-empty target ID goes to that target's entry, creating a ghost entry if necessary. Legacy SSH rows carry no generation: key them by target ID alone and keep them view-only until the authority advertises `automation.owner-fencing.v1`. -- Unknown execution types, missing SSH IDs, contradictory target fields, records whose owning repo no longer resolves, records whose `schedulerOwner` or `runContext.hostId` points at a runtime, and other malformed legacy records go to an `Unassigned legacy automations` orphan entry. -- `runContext.hostId` and `schedulerOwner` mark a record ambiguous. They never override storage ownership, never assign a record to a different authority, and never repair a malformed selector. -- The authority's create and update paths stop re-deriving `local` from a missed repo lookup. They preserve the stored selector and fail closed instead, so a deleted project cannot silently convert an SSH automation into a local one. - -The legacy response has no bounded usage projection. Do not compensate by downloading all run histories. Render neutral `Usage unavailable` copy until the selected automation's lazy history is loaded. - -If a runtime lacks owner fencing, all of its rows stay readable and none are mutable. Runtime + Self and Runtime + SSH are both view-only until the server advertises `automation.owner-fencing.v1`; show an Update server action rather than performing an unfenced Run Now, edit, or delete. Pairing-revision freshness authenticates the connection, not the record's selector, so it is not a substitute — a record whose target changed server-side since the last refresh would still run on a host the user never saw. This mirrors the fail-closed capability assertion the codebase already applies to remote file mutations. - -### Owner-fenced operation contract - -For capable runtimes, add this optional precondition to `automation.show`, `automation.runs`, `automation.update`, `automation.delete`, and `automation.runNow`; add `destination` to create and to updates that may move selectors: - -```ts -type AutomationOwnerPrecondition = { - selector: - | { kind: 'self' } - | { kind: 'ssh'; targetId: string; targetGeneration: number } -} - -type AutomationOwnedIdParams = { - id: string - expectedOwner?: AutomationOwnerPrecondition -} - -type AutomationCreateDestination = { - destination?: AutomationOwnerPrecondition -} -``` - -Concretely, show/delete/runNow use `{ id, expectedOwner? }`; runs uses `{ automationId?, expectedOwner? }`; update uses `{ id, updates, expectedOwner?, destination? }`; and create appends `destination?` to its existing fields. Pause and resume are `automation.update` payloads and carry the same `expectedOwner` precondition; if they ever land as dedicated RPCs they take it too, so scheduler state cannot be changed for the wrong incarnation while every neighbouring action is fenced. A capable new client always supplies the applicable fields. Parameterless old-client shapes remain valid. - -The authority is implicit in the local IPC endpoint or runtime RPC connection; it is never supplied as a caller-chosen environment ID inside the payload. Runtime calls also pass the captured `pairingRevision` to the existing renderer/main runtime-environment revision guard before transport dispatch and again before accepting the response. The runtime backend compares the expected selector with the stored automation's selector and current SSH registration generation in the same synchronous persistence operation that reads or mutates the record. Create validates the destination immediately before insert. Update validates both the stored source and requested destination before replacement. Validation resolves `destination.selector` against the authority's current saved target registry inside that same operation: Self is allowed, SSH requires a present target ID whose current registration generation matches, and orphan or unknown selectors are rejected with a structured error and no write. A destination is never accepted as a free-form value, so an automation cannot be attached to a ghost or future same-ID target and become runnable on the wrong host once a later registration satisfies the fence. - -The preconditions are optional on the wire so old clients can call new servers, but optional is not unenforced. A capable server rejects any mutation or execution request that omits `expectedOwner` when the stored automation carries an SSH selector with a generation, returning a typed upgrade-required conflict with no side effects. The precondition stays genuinely optional only for Self records and for legacy SSH rows that still have no generation, so unfenced mutation never becomes permanent server behavior for a caller that skips the field. A new client sends the fields whenever `automation.owner-fencing.v1` is advertised. A mismatch returns a typed conflict and performs no mutation or execution. Validation, not Zod stripping of unknown fields, defines this behavior. - -## Cache and request lifecycle - -Keep an in-memory entry per catalog stable key: - -```ts -type CacheEntry = { - data: AutomationListRow[] - fetchedAt: number | null - attempt: number - requestGeneration: number - catalogGeneration: number - request: Promise | null - error: AutomationHostQueryError | null -} -``` - -Errors use a typed code rather than copy matching: - -```ts -type AutomationHostQueryError = { - code: - | 'authority_unavailable' - | 'timeout' - | 'permission_denied' - | 'incompatible' - | 'invalid_response' - | 'owner_changed' - | 'target_removed' - | 'unknown' - message: string - retryable: boolean - retryAt: number | null -} -``` - -Policy: - -- TTL is 30 seconds. Fresh data is returned without a request; stale data remains visible during revalidation. -- A request captures stable key, full owner, request generation, the owning authority's catalog generation, and the authority connection generation. The authority connection generation is the saved runtime environment's current `pairingRevision` — the same value the existing renderer/main revision guard compares — and a fixed constant for the desktop authority. It is not a third counter. -- Refresh, mutation invalidation, authority re-pair, SSH re-adoption/removal, and entry eviction advance `requestGeneration` before starting replacement work. -- A response commits only when every captured generation still matches and no removal tombstone supersedes it. Otherwise it is discarded. -- Concurrent callers for the same owner share `request`. One authority-level legacy fallback request may fulfill several entries. -- A failed refresh retains successful data and records the error separately. Success clears `attempt` and `error`. -- Automatic transient retry uses full jitter with a 1-second base and 30-second cap, at most three attempts while the page remains visible. Permanent validation, permission, and incompatibility failures do not retry. Manual Retry bypasses cooldown and starts one new attempt. -- Authority calls use a four-request global pool. The selected host and Desktop + Self have priority. Obsolete queued work is cancelled when the filter/catalog changes; already-sent transport work may finish but must pass the commit fence. -- An All-hosts refresh never initiates an authority connection. It queries only authorities with an established connection; a saved-but-disconnected authority renders a compact status row with a Reconnect action and is fetched only after the user reconnects or selects that host directly. Reconnect triggers one prioritized refresh for stale entries owned by that authority. Nested SSH disconnection does not block a list query when its authority is reachable. -- Manual All-host refresh bypasses TTL for reachable authorities only. Unreachable authorities retain stale data and expose Reconnect; the refresh does not enqueue requests already known to fail. -- Focus/visibility refreshes only stale entries and is coalesced. No fixed polling interval is added. -- Removed non-visible entries are evicted after request invalidation. Retired cache entries are LRU-capped at 256; visible catalog entries and active requests are not retained in that retired pool. - -Mutation invalidation happens before the request is sent, so an older in-flight list cannot overwrite the mutation result. Create/update/delete/pause/resume/Run Now invalidate only affected entries. An update may move an automation between Self and SSH selectors inside one authority: remove it optimistically from the source, invalidate both source and destination, and reconcile both responses. Cross-authority movement is rejected. - -## Editing, creation, and actions - -Edit hydration, conflict checks, save, delete, pause/resume, Run Now, and lazy history all receive the selected row's `AutomationOwnerRef`. They must not call the desktop store or active runtime as a fallback. - -The editor resolves project, repo, workspace, folder-workspace, and project-host-setup options within the captured authority. Those option identities are authority-qualified before entering maps. Selecting another project may move between Self and SSH selectors owned by the same authority; the save request includes both expected source owner and destination owner. It cannot move between desktop and runtime authorities. - -Creation rules: - -- A single concrete host filter preselects and constrains the destination. Orphan entries cannot create. -- Under All hosts, default from the active workspace's qualified owner only when it resolves to a catalog entry with a non-null executable owner, and show that owner explicitly before submit. When that owner is missing, unhydrated, orphaned, or not uniquely resolvable, require an explicit host choice before submit; never fall back to the active runtime or to a bare workspace or repo ID. -- The create request captures and validates the destination incarnation immediately before submit. -- If a concurrent catalog change makes the destination stale, fail closed and preserve the form. -- After success, select the created row. If it is unexpectedly outside the current filter, intentionally switch the filter to its destination and announce that change; never let a successful creation silently disappear. - -Dialogs keep their captured owner for their entire lifetime. An authority/target incarnation change disables submit and presents Reload; it never silently retargets an open form. - -## Run history, usage, and completion - -Run history stays lazy and authority-qualified: fetch only the selected automation's runs, page/cap them under the existing retention rules, and key selection/navigation by owner + automation ID + run ID. - -The list must not fetch all runs to build usage. Capable list projections include the existing aggregate fields (`knownRuns`, `unavailableRuns`, token totals, and estimated cost) computed by the authority while it already owns bounded retained runs. Legacy rows show neutral unavailable usage until their selected history is present. - -Move dispatched-run completion and usage reconciliation out of `AutomationsPage` rendering into the authority-owned automation service/dispatcher. The desktop service and each runtime service observe their own dispatched sessions, persist terminal status exactly once, and publish change events whether or not any client has the Automations page open. On authority startup, reconcile retained non-terminal runs against owned execution/session state. This prevents unselected or headless runs remaining stuck in `dispatched` and avoids client fanout. - -## External automation managers - -The first release includes external managers only for Desktop + Self and desktop-owned SSH hosts. Runtime + Self and Runtime + SSH show Orca automations only. This is an explicit scope boundary, not an unknown-source fallback. On a runtime-owned host, both the empty and the populated state say so: the absence of manager rows there is a stated scope boundary and is never presented as "none configured". - -Replace the broad desktop `listExternalAutomationManagers()` page call with scoped IPC operations that accept the captured desktop `AutomationOwnerRef` and provider. Every scoped call — list, runs, create, update, and action — re-applies the two checks the broad call enforced, before contacting a relay or launching a provider command: the runtime-owned-target exclusion that keeps hidden targets out of user-facing surfaces, and the same saved-owner comparison used for Orca mutations, including `targetGeneration` against the current SSH registration. A mismatch fails closed with the host-changed conflict and performs no probe. Selecting Local probes only local managers. Selecting one desktop SSH host probes only that target. All hosts schedules `{host, provider}` calls through the same bounded pool at lower priority than Orca automation list and mutation traffic for the selected host and Desktop + Self, cancels them when the filter leaves desktop scope, and does not eagerly probe every SSH target from a Local view. Manager work counts toward the release profiling in-flight assertion. - -Manager, job, run, dialog, and action keys include owner + provider + provider ID. Cache and error state are per `{owner, provider}`, separate from Orca automation storage health. An external-manager failure cannot mark the host's Orca store unavailable. - -Adding runtime-owned external managers later requires dedicated runtime list/runs/create/update/action RPCs, authority-qualified target types, capabilities, validation, and old-server UX. It must not be implemented by tunneling a desktop-only target or treating unknown authority as Local. - -## Security and trust boundaries - -Runtime authentication and authorization remain those of the existing paired RPC connection. The server derives authority from that connection, validates selectors against its saved target registry, and treats target IDs only as identifiers; no selector is interpolated into a path or shell command. Owner conflicts, permission errors, and invalid schemas return structured errors without record or prompt contents. Response validation and telemetry never log prompts, precheck commands, run output, credentials, or external-manager payloads. - -The renderer cannot bypass fencing by supplying a different authority or generation. Local IPC performs the same saved-owner comparison, and runtime RPC handlers compare against server-owned state. External-manager scoped IPC verifies the requested desktop target and provider allowlist before contacting a relay or launching a provider command. - -## Change events and invalidation - -Add an `automationsChanged` event to the existing local event channel and runtime client event stream: - -```ts -type AutomationsChangedEvent = { - type: 'automationsChanged' - selector?: { kind: 'self' } | { kind: 'ssh'; targetId: string } | { kind: 'orphan' } - reason?: 'definition' | 'run' | 'usage' -} -``` - -CRUD, scheduler transitions, run creation/status changes, and usage updates publish after persistence succeeds. Runtime authority is derived from the subscription environment and its current pairing revision; it is never accepted from the event body. - -A scoped event invalidates its one stable entry. An older or unscoped event invalidates all entries for that one authority only. Coalesce event bursts in one microtask and share any legacy authority request. Reconnect, focus, and TTL revalidation remain fallback paths for older servers that publish no event. - -An update that moves selectors publishes one event for the source and one for the destination; if the old selector cannot be recovered, publish one unscoped authority event. Subscribers treat duplicate events as harmless invalidations. - -The event is additive. Old clients must continue to ignore the unknown event type. Do not introduce a new stream opcode; if transport framing ever requires one, capability-negotiate it because old decoders may silently drop unknown opcodes. - -## User experience - -The host picker and search field remain visible in loading, empty, no-match, and failure states. Use the existing Select primitive for eight or fewer entries and the existing searchable Command/Popover for nine or more. Both expose the label `Filter by host`; the searchable variant focuses search on open, Enter selects, and Esc closes without changing selection. - -Every row shows a host badge rendered with the existing `RepoBadgeLabel` component already used for host and repo labels elsewhere in the automations UI. The pill `Badge` component has no `muted` variant, and this design does not add one. Badge truncation retains the full accessible name and tooltip. Search runs after host selection and matches name, project, workspace, agent, host label, and at most the first 2,048 prompt characters. Build the normalized search index once per changed row set, not during each render or comparator call. - -Keep these states distinct: - -- Authority query: loading, fresh, refreshing, stale-error, unavailable, incompatible. -- Execution target: connected, connecting, disconnected, unavailable, unknown. - -Under All hosts, rows are grouped by host in the same deterministic catalog order the picker uses, with each host's compact host-level status row anchored at the top of its group, so incomplete authorities do not reflow the list as responses arrive. Healthy and stale rows render inside their group as they arrive. A stale row remains readable. Each persistent failure supplies the relevant Retry, Reconnect, or Update server action; do not rely on a toast for recoverable errors. Run Now is disabled when execution health is insufficient, with a concrete reason, while storage-only actions follow authority and fencing availability. - -Use a polite, deduplicated `aria-live` summary for partial failures, worded ` of hosts could not be loaded`. Re-announce only when the failed-host count changes, not on every retry attempt. Do not move focus when status rows appear. If filtering or refresh removes the focused row, move focus to the next row, then previous row, then the picker; changing selection must not open a detail or fetch history until the replacement row is rendered. - -Empty copy distinguishes `No automations on `, `No automations across loaded hosts`, `No automations match your search`, and `Automations could not be loaded from `. Copy never claims a disconnected or unhydrated host is empty. On a runtime-owned host it also states that external automation managers are not listed for that host in this release, so a scope-limited host is never presented as clean. - -## Performance and resource budgets - -- No automation or external-manager network work is added to app startup or picker render. -- Selected-host first usable rows require at most one authority request; old-server All hosts requires at most one unscoped list request per authority. -- Concurrency is four remote requests, retries are capped, event bursts are coalesced, and timers/listeners are disposed when the page/controller closes. -- Prompt indexing is bounded per row. Retired cache history is capped. Tombstone history is capped per authority, but a tombstone still referenced by a stored automation, cached row, or persisted filter is retained past the cap: a tombstone becomes evictable only after its owning authority has returned an authoritative catalog with no remaining reference to that target. Positive removal evidence is never discarded while something still depends on it. Run lists continue to use existing retention/pagination caps. -- Cache instrumentation records request counts by authority/stable key, in-flight dedupe hits, discarded stale responses, result row counts/bytes, and refresh duration. It does not log prompts or run output. -- Release profiling covers 1,000 automations across 50 hosts, one offline authority, one old runtime, rapid filter changes, and an event burst. Acceptance: no more than four remote calls in flight, one legacy call per authority per cycle, no stale commit, and no synchronous long task over 50 ms attributable to filter/search on release hardware. - -## Migration and mixed-version behavior - -Persisted additions are optional so rollback builds ignore them: - -- SSH target registration generation. -- Automation SSH owner generation. -- Persisted stable automation host filter. - -Migration is idempotent and preserves unknown fields. It assigns generations only with positive current-target evidence, preserves missing references as ghosts, and never rewrites an orphan to Self. - -Supported rollback fixtures must prove the previous desktop/runtime builds preserve the optional generation fields on read/write. If a rollback-era state has lost those fields and a same-ID SSH replacement cannot be distinguished from the prior target, re-upgrade classifies the automation as ambiguous and requires explicit re-adoption; it does not guess and run on the replacement host. - -Existing desktop-stored records whose `schedulerOwner` or run context points at a runtime need explicit classification. Physical storage remains the authority unless a separate verified migration proves the runtime holds the canonical record. The first release does not delete or automatically transfer such records. Ambiguous records appear in the legacy/orphan entry with actions disabled and recovery guidance, preventing duplicate scheduling or destructive guessing. - -Desktop/runtime version matrix: - - -| Desktop | Runtime | Behavior | -| -------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Old | New | Parameterless list still returns the complete authority list; new optional fields/events are ignored. | -| New | Old | One unscoped list per authority, defensive partition, neutral usage, all runtime rows view-only without owner fencing. Runs dispatched by that server are reconciled only by it, so they stay `dispatched` until it updates; the row surfaces Update server rather than hanging with no explanation. | -| New | New | Scoped lists, bounded usage summaries, owner-fenced actions, scoped invalidation. | -| New before/after same-ID re-pair | Any | Old cache/actions are evicted by pairing revision; stable display selection may remain. | - - -No mobile-facing route, schema, RPC, handshake, protocol minimum, recommended app version, pairing file, E2EE, or relay surface changes. Automation RPC/event additions remain runtime-scoped, additive, and optional. No platform-specific path, shell, keyboard, native-module, filesystem, or Git behavior is introduced; the design applies equally on macOS, Windows, Linux, WSL, SSH, relay, git worktrees, and folder workspaces. - -## Implementation sequence - -1. Add canonical stable/owner refs, SSH registration generation persistence/migration, qualified row/run/navigation keys, and owner comparison helpers. -2. Build the dedicated catalog and persisted-filter hydration/re-adoption behavior. -3. Add local scoped IPC, runtime list capability/schema/validation, legacy authority partitioning, and owner-fenced mutation/secondary-read contracts. -4. Add the generation-fenced cache, bounded scheduler, invalidation events, retry policy, and instrumentation. -5. Move run completion/usage reconciliation into authority-owned services and add list usage projections. This step fixes a defect every user hits today — runs stuck in `dispatched` whenever the page is closed — and depends on nothing in steps 1-4, so it can land on its own schedule rather than waiting on the multi-host work. -6. Convert edit/create/actions/dialogs/project/workspace resolution to captured owners. -7. Add picker, badges, search index, partial states, recovery actions, focus, and announcements using the style guide and existing primitives/tokens. -8. Replace broad external-manager discovery with desktop-only scoped provider calls. -9. Remove the active-runtime inference and broad all-run fetch paths only after the new tests pass. - -Each step lands with its compatibility tests; do not ship a UI that merges authorities before mutation and navigation identity are converted. - -## Test plan - -### Identity, catalog, and persistence - -- Equal automation/run/repo/workspace IDs under two authorities remain distinct through selection, edit, navigation, and React keys. -- Equal SSH target IDs under Desktop and a runtime do not collide. -- Same-ID runtime re-pair and SSH remove/re-add discard stale rows, requests, dialogs, and actions. -- Unhydrated absence retains the persisted selection; authoritative removal falls back or preserves a ghost as specified. -- Rename preserves stable selection and cache slot; re-adoption migrates automations and filter atomically. -- Ephemeral runtime SSH targets stay hidden; parent outage preserves nested labels without claiming removal. - -### Query, cache, and performance - -- Local selection makes zero runtime and SSH-manager calls. -- Runtime selection contacts only that runtime; nested SSH disconnection does not block authority listing. -- Capable All hosts makes scoped calls with at most four remote requests in flight. -- Old-server All hosts makes exactly one unscoped request per authority and partitions Self, SSH, and orphan records correctly. -- Fresh/stale/error behavior, in-flight dedupe, capped retry, reconnect refresh, queue cancellation, and LRU eviction are deterministic with fake time. -- Late responses after refresh, mutation, removal, re-adoption, re-pair, and catalog replacement cannot commit. -- Search indexing is bounded and the 1,000-row/50-host profiling fixture meets the stated budgets. - -### Actions, create, and edit - -- Show, edit hydration/conflict/save, delete, pause/resume, Run Now, runs, usage, and open-workspace route through captured owners. -- Incarnation changes while a dialog is open fail closed and preserve input. -- In-authority selector movement invalidates/removes source and destination correctly; cross-authority movement is rejected. -- Single-host create is constrained; All hosts defaults visibly from the active qualified workspace; post-create selection never disappears silently. -- Folder workspaces and git worktrees both resolve inside the captured authority. -- An enabled automation whose SSH target was deleted produces no run on any host: its authority refuses the dispatch, records a skipped-run reason, and the migration left the record disabled. -- An automation whose owning repo was deleted, and a desktop-stored record scheduled against a runtime, both land in the orphan entry rather than under Desktop + Self. -- Delete and pause remain available on orphan and ambiguous rows; Run Now, edit, and workspace navigation do not. - -### Runs and events - -- List rendering fetches no broad run history. -- Selected history is lazy and authority-qualified; legacy usage copy is neutral. -- Unselected, page-closed, remote, restarted, and headless dispatched runs reach a terminal state through authority-owned reconciliation on capable authorities. Against an older runtime the run stays `dispatched` and surfaces Update server instead of hanging with no explanation. -- CRUD/scheduler/run/usage events invalidate one selector; old unscoped events invalidate one authority; bursts coalesce. -- Old clients ignore the new event and still use parameterless list behavior. - -### Failures, accessibility, and compatibility - -- One timeout, malformed response, permission error, offline authority, or incompatible runtime does not hide other hosts. -- Stale data remains readable and persistent errors expose working Retry/Reconnect/Update actions. -- Picker/search remain present in every empty/error state; full badge labels, keyboard behavior, focus recovery, and deduplicated `aria-live` output pass accessibility tests. -- New/old desktop-runtime matrix tests cover omitted/null/empty/scoped list params and unknown optional fields. -- Persisted migration/rollback fixtures cover missing generations, orphan targets, same-ID replacement, and ambiguous desktop-stored remote-scheduled records. -- No mobile legacy fixture, protocol minimum, or mobile package changes; Windows/Linux/macOS static review finds no new platform-specific behavior. - -## Release gates - -- All unit, integration, and RPC compatibility tests above pass. -- Real desktop + current runtime, desktop + previous runtime, SSH, runtime-owned SSH, runtime re-pair, SSH re-adoption, folder workspace, and offline/reconnect smoke tests pass. -- The request-count and stale-response assertions pass under rapid interaction and network failure. -- There is no unresolved path that infers authority from a bare ID, active runtime, `runContext.hostId`, or `schedulerOwner`. -- External managers remain desktop-scoped unless their full runtime RPC design lands separately. - -## Existing code that must change - -- `src/main/runtime/rpc/methods/automations.ts`: `automation.list` is currently parameterless. -- `src/renderer/src/components/automations/automation-host-client.ts`: ownership currently collapses to local vs environment and can fall back from `runContext.hostId`. -- `src/renderer/src/components/automations/AutomationsPage.tsx`: refresh currently fetches broad runs, completion reconciliation is page-owned, and edit/refresh paths use the wrong ambient target. -- `src/main/persistence.ts`: update can change execution target and SSH re-adoption does not migrate automations/filter state. Create and update also derive `executionTargetType` from the owning repo, writing the literal `local` whenever that lookup misses. -- `src/main/ipc/automations.ts`: `automations:list` takes no selector, and `automations:markDispatchResult` is driven by the renderer. -- `src/main/automations/service.ts`: only the headless dispatch path reconciles completion; renderer-dispatched runs depend on the page being open. -- `src/renderer/src/store/worktree-repo-index.ts`: bare-ID maps cannot resolve cross-authority collisions. -- `src/renderer/src/store/slices/runtime-environment-ssh.ts`: runtime SSH state is correctly separate and supplies the basis for nested catalog projection, but connection generation is not the durable target registration generation required here. -- `src/main/automations/external-manager.ts`: manager listing currently probes all desktop SSH targets. -- `src/shared/automations-types.ts`: external targets and existing automation owner data lack authority/incarnation qualification. -- `src/shared/runtime-client-events.ts`: no automation invalidation event exists. -- `src/shared/protocol-version.ts`: runtime capabilities are string constants and need the new additive entries. diff --git a/docs/mobile-relay-ux-findings.md b/docs/mobile-relay-ux-findings.md deleted file mode 100644 index 3fbbd1419be..00000000000 --- a/docs/mobile-relay-ux-findings.md +++ /dev/null @@ -1,442 +0,0 @@ -# Mobile Relay UX — Investigation Findings & Fix Plan - -Scope: phone-side presentation/state-machine issues behind three reported symptoms on Android over -the cloud relay. The relay protocol and server-side assignment are healthy; nothing here changes -desktop or relay-server code. All file references are in `mobile/` of this worktree. - -## 1. Symptom → root-cause summary - -| # | Symptom | Root cause (verified) | -|---|---------|----------------------| -| S1 | Resume lands on an empty "Host" page, grey dot | Bare cross-stack `router.push` into a cold nested host navigator resolves to the host index route **without the `hostId` param**; every screen below then runs with `hostId: undefined` | -| S2 | Tapping a healthy relay host shows grey 1–2s before green | Every screen focus funnels into the network-handoff recovery path, which **suspends the healthy relay session** (publishes `disconnected`) and re-dials; the re-dial is invisible because `migrateTo` binds new-session state only after authentication | -| S3 | Relay-forced pairing looks dead ~5–10s | The pairing relay path has **no log sink** (only direct-path entries reach the "Pairing log"), and post-pairing the app dials the unreachable LAN endpoint for up to 12s before relay recovery is even eligible | - -## 2. Verified end-to-end causal chains - -### S1 — Resume dead-ends on the host index page - -1. Home renders the Resume card only once `hostStates[lastVisited.hostId] === 'connected'` - (`app/index.tsx:488`); over relay that is seconds after the host list paints, and the card - inserts **above** the Tasks card in the same footer (`app/index.tsx:733-780`) — a layout shift - under the thumb. -2. Tap → bare `router.push(createMobileSessionHref(...))` (`app/index.tsx:740-746`) targeting - `/h/[hostId]/session/[worktreeId]`. -3. With the `h` group cold (cold start, or host never visited this session), Expo Router resolves - the push to the host stack's **index route with no `hostId` param**. This exact failure mode is - documented twice in-repo ("cold Expo deep links resolve to index" — - `src/transport/host-edit-navigation.ts:52`, `src/tasks/mobile-task-navigation.ts:90`) and is the - root cause named by PR #12001. -4. `app/h/_layout.tsx:64` reads `hostId` via `useGlobalSearchParams` → `undefined`. - `HostProtocolGate` gets `hostId: undefined`; `useHostClient(undefined)` returns - `state: 'disconnected'` (`src/transport/client-context.tsx:344`) → **grey dot**. -5. The host index screen renders the fallback title `'Host'` (`app/h/[hostId]/index.tsx:821`), and - every fetch no-ops on `!client || connState !== 'connected'` - (`app/h/[hostId]/index.tsx:298,364,418,520`) → **empty list**. The Filter/Recent/Repo chips are - static toolbar UI, so the page looks "real" but dead. -6. "Sometimes": a warm host stack resolves the same push correctly, so the bug is intermittent by - navigation history. - -Corrections to the preliminary sweep: the empty page is primarily the missing `hostId` param, not -the connection-gated fetches or the cold 30s worktree cache (those matter only when landing *with* -a valid `hostId`, e.g. the mistap-strand case). Also, the Resume target "validation" is weaker than -it looks: `getCachedWorktrees` is seeded from the persisted home snapshot at hydration -(`app/index.tsx:264-277`) and the 30s TTL is stamped at seed time (`src/cache/worktree-cache.ts:20`), -so a worktree deleted while the phone was off still passes until a live `worktree.ps` overwrites it. - -**Fix**: PR #12001 ("open the Resume workspace through a mounted host stack") routes Resume through -the same mount-then-replace mechanism Tasks uses, extracted to `src/navigation/host-stack-navigation.ts`. -Reviewed and validated per Jinwoo; **merged to main as `7948e46db855`** after final validation -(see §4, F0). Residual S1 items it does not cover: bare notification/accounts/deep-link pushes (F4), -catalog validation + not-found bounce (F7), Resume-card layout shift (F8), gate unmount hazard (F9). - -### S2 — grey blink when focusing a healthy relay host - -1. Every focus of the host screen fires `notifyForeground()` - (`app/h/[hostId]/index.tsx:512-517`, deliberately empty deps). -2. `openHostLogicalClient` wraps that into `endpointLifecycle.setForeground(true)` - (`src/transport/host-logical-client.ts:31-33`); the lifecycle forwards without dedupe - (`src/transport/mobile-endpoint-lifecycle.ts:62-64`). -3. `MobileEndpointSupervisor.setForeground(true)` computes `wasForeground = true` and calls - `RelayReconnectController.handleForeground` (`src/transport/mobile-endpoint-supervisor.ts:115-119`). -4. `handleForeground` with `wasForeground && state === 'connected'` **suspends the healthy session** - (`src/transport/mobile-relay-reconnect-controller.ts:53-60`). `suspendActiveRelay` early-returns - unless the active path is `'relay'` (`:77-84`) — which is why LAN hosts never blink. -5. `suspendActiveSession` closes the physical session, disposes all subscriptions, and publishes - `'disconnected'` (`src/transport/stable-logical-rpc-client.ts:164-180`) → grey dot - (`src/components/StatusDot.tsx:11`), worktree queries blocked. -6. `onRetry()` → `recoverRelay()` → `openRelay` + `migrateTo`. During the dial the logical state - **stays 'disconnected'**: `migrateTo` only binds the new session's state after - `waitForAuthenticated` resolves (`src/transport/stable-logical-rpc-client.ts:188,213`), and the - dialing session's own `connecting`/`handshaking` publishes (`src/transport/mobile-relay-rpc-session.ts:43,74`) - fire with no listeners attached. Grey persists the full 1–2s (happy path has no artificial - delays; any failure adds ≥250ms full-jitter backoff, `src/transport/mobile-relay-retry-delays.ts:3-5`). -7. `migrateTo` completes → `'connected'` → green; subscriptions replay; gated fetches rerun. - -Second trigger for the same path: OS network-revival nudges call `notifyForeground()` on every live -client (`src/transport/client-context.tsx:286-292`, `src/transport/connection-revival-triggers.ts`) -— any Wi-Fi↔cellular transition or came-online event grey-blinks every connected relay host. - -Design context: the suspend-on-repeat-foreground is pinned by the supervisor test as the -network-handoff half-open case (`src/transport/mobile-endpoint-supervisor.test.ts:~160-197`). The -asymmetry is that **direct sockets probe instead of tearing down** — `notifyForeground` on a -connected direct client runs an activity probe that detects a half-open socket in ≤8s -(`src/transport/rpc-client.ts:1119-1124`) — while relay sessions have a no-op `notifyForeground` -(`src/transport/mobile-relay-rpc-session.ts:106`) and the supervisor's only tool is -suspend-then-redial. There is also an in-repo make-before-break precedent: lease rotation calls -`recoverRelay(forceReplacement = true)` and migrates a **live** session with zero visible blink -(`src/transport/mobile-endpoint-supervisor.ts:56-59,146,249`). - -Divergent mount defaults (secondary): home renders `hostStates[id] ?? 'connecting'` (amber, -`app/index.tsx:707`) while `getState()`/`useHostClient` return `'disconnected'` (grey) for a -missing store entry (`src/transport/client-context.tsx:221,344`) — so host screens flash grey -during the async client acquire (Keychain read) that home never shows. - -### S3 — silent 5–10s relay-forced pairing - -Pairing phase: - -1. `pair-confirm.tsx` / `pair-scan.tsx` pass `connectOptions.onLog` into `startPreProfilePairing` - (`app/pair-confirm.tsx:91-99`, `app/pair-scan.tsx:135-143`). -2. The coordinator threads it **only to the direct candidate** - (`src/transport/pre-profile-pairing-coordinator.ts:152-157`). The relay candidate - (`:161-187`) gets nothing: `connectMobileRelayForPairing` has no log parameter at all - (`src/transport/mobile-relay-physical-client.ts:22-30`), nor do the director resolution, - journal writes, or the recovery loop in `src/transport/pairing-relay-candidate.ts`. -3. With LAN unreachable, the visible "Pairing log" shows only the direct dial stalling toward its - 12s connect timeout while the relay path does the real work silently: cell WebSocket + E2EE - handshake + `pairing.provisionRelay` + `pairing.getEndpoints` + credential-bundle write - (`pre-profile-pairing-coordinator.ts:206-233`). The error copy even says "see log below for - where it stalled" (`app/pair-confirm.tsx:138`) — the log cannot show it. -4. Un-logged waits in the relay recovery loop: each of up to 3 attempts wraps a 5s director - resolution (`src/transport/mobile-relay-invite-director.ts:16`) plus full-jitter sleeps capped - at 100/200/400ms (`src/transport/pairing-relay-candidate.ts:58-59,70-71`) — worst case ~15s of - silence. (Correction: the preliminary "~3×2s of backoff" was wrong; the sleeps are small, the - director resolves dominate.) The relay E2EE layer itself has **no timers**: a pairing relay - request is unbounded except the screen's 25s cap (`app/pair-confirm.tsx:27`). -5. Pairing logs also never reach `connectionLogStore` (single producer: - `src/transport/client-context.tsx:132`), so the Connection Log screen shows nothing about a - pairing that just failed. - -Post-pairing phase: - -6. `pair-confirm` calls `closeHost(hostId)` then replaces to `/h/` - (`app/pair-confirm.tsx:118-123`). -7. The destination re-acquires a client asynchronously (grey `'disconnected'` default during the - Keychain read, `src/transport/client-context.tsx:221`), then dials the **LAN endpoint first** - (`src/transport/host-logical-client.ts:12`) — amber for up to `CONNECT_TIMEOUT_MS = 12s` - (`src/transport/rpc-client.ts:126`) on a black-holed LAN. -8. Relay recovery cannot start earlier: `needsRecovery` treats `connecting`/`handshaking` as live - progress (`src/transport/mobile-relay-reconnect-controller.ts:73-75`), checked at supervisor - start and on every retry (`src/transport/mobile-endpoint-supervisor.ts:106,146`). -9. When the direct dial finally fails, the relay dial runs invisibly (same `migrateTo` mechanism - as S2) → green. Worst case with a director resolution failure and grace-credential retry: - ~29–58s under the old session's labels. -10. The "Orca Relay" path label only renders once `state === 'connected'` - (`src/components/MobileHostCard.tsx:23,47`) — the user learns the phone is using relay only - after the wait ends, and `classifyConnection` has no relay-aware branch - (`src/transport/connection-health.ts:45-100`). - -## 3. Anti-pattern sweep - -### (a) Uncoordinated deep pushes into `/h` from outside the host stack - -Coordinated today (mount-then-replace): host edit (`src/transport/host-edit-navigation.ts`) and -Tasks (`src/tasks/mobile-task-navigation.ts`). Note host-edit's predicate is weaker — it checks the -root route only and can fire its `replace` while the nested stack is still gated/unmounted; Tasks -proves the nested stack exists (`mountedHostStack`, `mobile-task-navigation.ts:53-70`). - -Bare pushes remaining (host stack plausibly cold at each): - -| Call site | Target | Cold scenario | -|---|---|---| -| `app/_layout.tsx:127` via `src/notifications/notification-routing.ts:58,64` | `/h//session/` or `/h/` | **Coldest path** — `getLastNotificationResponse()` after launch from a killed app, plus the warm listener | -| `app/index.tsx:740-746` (Resume) | `/h/[hostId]/session/[worktreeId]` | Fixed by PR #12001 | -| `app/index.tsx:835` (Account-usage card) | `/h//accounts` | Home is the root route | -| `orca://` deep links (scheme in `app.json:9`, no linking config) | any `/h/...` | Default filesystem linking, zero coordination; `app/_layout.tsx:52-58` only intercepts pairing codes | -| `app/h/[hostId]/history/[worktreeId].tsx:15`, `pr/[worktreeId].tsx:16` | redirect to source-control | A cold deep link to these hits the same cold-navigator resolution first | - -Shallow index-only pushes (`app/index.tsx:723,803`, onboarding/pair flows) don't need coordination. -No ``, `navigationRef`, or `router.navigate` anywhere in `mobile/`. - -Related hazard: `HostProtocolGate` **unmounts the mounted HostStack mid-connect** for a first-visit -host — stack mounts while `connecting`, is replaced by a spinner when `status.get` goes in flight -(`statusPending` true only when connected: `src/transport/host-status-gates.ts:111`), then remounts -(`src/components/HostProtocolGate.tsx:34-44`). A deep navigation that resolved into the first mount -can be destroyed by the gate cycle. - -### (b) Surfaces that render grey 'disconnected' during expected transients - -Store defaults: every read API on the canonical store defaults to `'disconnected'` for a missing -entry — `getState` (`src/transport/client-context.tsx:221`), `useHostClient` seed/re-seed/unbound -fallback (`:343-345,377,393`). Exactly one call site defaults to amber instead: the home screen's -`hostStates[id] ?? 'connecting'` (`app/index.tsx:707,903`), whose reconciliation effect also -refuses to write `'disconnected'` for a never-tracked host (`:378-394`) — home already solved -locally what every other surface gets wrong. - -The grey window is not one frame: `openEntry` awaits `loadHosts()` (a Keychain/SecureStore pass) -**before** inserting the store entry (`client-context.tsx:87-161`, insert at `:153`), so a cold -start or deep link into `/h/[hostId]` shows grey for the whole Keychain latency. The physical -client is not the cause — it already reports `'connecting'` synchronously by the time `connect()` -returns (`rpc-client.ts:302,967`). Additionally, `forceReconnect` deletes the entry then awaits the -async reopen (`client-context.tsx:201-218`), so **every Retry button drives the UI grey before -amber**. - -Surfaces that show grey / "disconnected" copy for a healthy host during these transients (all via -`useHostClient`): host header dot (`app/h/[hostId]/index.tsx:819`); host toolbar + FAB disabled -(`:850-860,941-1000,1063-1078,1209`); the workspace list body renders **nothing at all** for -`disconnected` — `selectHostWorkspaceListState` falls through to `null`, not even a spinner -(`src/worktree/host-workspace-list-state.ts:17-24`); tasks header dot + "Connect to a host" empty -state (`app/h/[hostId]/tasks.tsx:8681,8661-8663`); session dot (no `verdict` prop at all, -`session/[worktreeId].tsx:4434`) and the literal "Disconnected" chip (`:4246-4255`); native-chat -composer lock (`src/session/MobileNativeChatView.tsx:427-432`); source-control / git-history / -diff-review / file-explorer / agent-history "Waiting for desktop…" states; the connection-log -screen prints the raw enum (`app/connection-log.tsx:113-117`); home's Resume/Accounts/Tasks/Quick -Action gates all read `=== 'connected'`; voice settings goes fully inert -(`app/voice-settings.tsx:50-55`). Counter-examples that behave well: home host card, the accounts -screen ("Connecting to {host}…" + cached snapshot, `app/h/[hostId]/accounts.tsx:366-370`). - -Deliberate transients that publish `'disconnected'` while healthy work proceeds: relay suspend on -focus/network nudges (S2); background suspend (`src/transport/mobile-endpoint-supervisor.ts:123` — -correct per billing, but state stays grey through the entire foreground re-dial rather than -flipping to `'connecting'`); post-migration cleanup (`:251-253`); `closeHost` during the -pair-confirm handoff (`src/transport/client-context.tsx:83`); three open-failure paths -(`:107,114,135`). - -Destructive companion pattern — state flips don't just recolor, they **wipe loaded data**: -`host-status-gates.ts:32,100-112` wipes cached host capabilities on every disconnect; -`tasks.tsx:2774-2800` resets the whole screen's hydration and force-closes ~15 sheets; -`session/[worktreeId].tsx:2043,2432-2439,3713-3716` clears diff comments/capability flags/agent -lists; the PR sidebar hides entirely (`src/session/use-mobile-pr-branch-context.ts:59-66` → -`use-mobile-pr-sidebar-controller.ts:113-118`); git history blanks rows on the **reconnect** branch -(`src/source-control/MobileGitHistoryList.tsx:63-68`); the repo cache survives disconnect but is -wiped by the rejected in-flight call (`NewWorktreeModal.tsx:330-333`); the worktree cache is read -only at mount/hostId change (`app/h/[hostId]/index.tsx:129,330`), never on reconnect, so a >30s -entry means an empty remount. - -Same bug class in a second enum: `workspaceSshStatusLabel` defaults a `null` SSH status to -"Disconnected" (`src/tasks/workspace-ssh-gate.ts:14-37`, rendered in `NewWorktreeModal.tsx:863` -and `tasks.tsx:10970`). - -### (c) Invisible relay establishment phases - -- `connectMobileRelayRpcSession` (normal relay connects) has **no onLog** — the entire relay - session lifecycle emits nothing (`src/transport/mobile-relay-rpc-session.ts:30-39`); the - supervisor logs only coarse post-hoc lines (`mobile-endpoint-supervisor.ts:185-188,261`). -- `migrateTo` structurally discards the dialing session's `connecting`/`handshaking` states - (`src/transport/stable-logical-rpc-client.ts:182-223,267-299`). -- Direct→relay upgrade path has no sink at all (`src/transport/mobile-endpoint-lifecycle.ts:49-58`, - `mobile-relay-direct-upgrade-controller.ts:19`). -- Pairing relay path fully silent (S3 above); pairing logs never reach `connectionLogStore`. -- Path label ("Orca Relay") gated on `connected` (`src/components/MobileHostCard.tsx:47`); - `classifyConnection` collapses `connecting`/`handshaking`/`reconnecting` and has no relay branch. -- Regression suite for connect-label stalls exists for the direct path only - (`src/transport/cellular-connecting-label-stall.test.ts`); no relay equivalent. - -## 4. Fix plan - -Ordered by felt-flakiness-removed per unit risk. All fixes are phone-local; none change the wire -protocol, so every old/new phone × old/new desktop pairing keeps working unless noted. - -### F0 (S1, quick win) — land PR #12001 ✅ MERGED - -Squash-merged to main as `7948e46db855` (2026-08-04) after validation: CI fully green; drift check -against current main clean (only overlap, #12575, touches different regions of `app/index.tsx` and -auto-merges); full mobile suite (411 files, 3110 tests) passed on a local merge of main into the PR -branch. The PR routes Resume through the shared mount-then-replace mechanism -(`src/navigation/host-stack-navigation.ts`) and adds a source-guard test against reintroducing the -bare push. This branch has since been fast-forwarded onto that merge, and F4 builds on the -extracted module. -Backward compat: navigation-only, none. -Residuals tracked as F4/F7/F8/F9. - -### F1 (S2, quick win) — stop suspending a healthy relay on focus ✅ IMPLEMENTED (this branch) - -Approach: split the nudge reasons that today all funnel into `setForeground(true)`: - -- Screen-focus nudge (`app/h/[hostId]/index.tsx:515`): must not suspend. For the relay path, either - no-op (state changes already drive the UI) or run a cheap liveness probe (an RPC with a short - budget) and only enter recovery on failure — mirroring the direct path's activity probe. -- Network-change / app-resume nudges: keep half-open protection, but **verify by replacement** - instead of break-before-make: call the existing `recoverRelay(forceReplacement = true)` path - (proven by lease rotation) so `migrateTo` swaps sessions with the dot staying green; only if the - replacement dial fails, fall back to `suspendActiveRelay` so a genuinely dead link stops lying - green and the retry loop re-arms (plain `recoverRelay` early-returns while the stale state is - still `'connected'`, so the fallback suspend is required for convergence). - -Files: `src/transport/mobile-relay-reconnect-controller.ts` (`handleForeground`), -`src/transport/mobile-endpoint-supervisor.ts` (thread a nudge reason; failure-path suspend), -`src/transport/mobile-endpoint-lifecycle.ts`, `src/transport/host-logical-client.ts` (reason-tagged -`notifyForeground`), optionally `src/transport/rpc-client.ts` type for the reason parameter. -Risk: PEER_DROPPED/LIMIT_EXCEEDED churn if replacement dials overlap — reuse the existing -`shouldDefer` cooldown; billed duplicate socket for the overlap window (lease rotation already -accepts this). Half-open regression risk is covered by the fallback suspend. -Tests: split `mobile-endpoint-supervisor.test.ts:~160-197` into (focus nudge → no suspend, dot -stays green) and (network handoff → replacement dial; failure → suspend + cooldown). Keep the -background-suspend test unchanged. - -### F2 (S2/S3, quick win) — unify mount defaults to 'connecting' ✅ IMPLEMENTED (this branch) - -Approach: `getState(hostId)` returns `'connecting'` when the host is known (primed profile or -pending open) and no entry exists yet; `'disconnected'` only for unknown/closed hosts. Aligns every -host screen with home's `?? 'connecting'`. Two companion changes in the same class: -- `forceReconnect` should notify `'connecting'` (or insert a placeholder entry) instead of leaving - the deleted-entry window grey (`src/transport/client-context.tsx:201-218`) — every Retry button - currently drives the UI grey before amber. -- Optionally have `openEntry` insert a `'connecting'` placeholder before the Keychain read so the - cold-start gap (`client-context.tsx:87-153`) is amber too. - -**Required interaction fix**: `app/h/[hostId]/index.tsx:738-741` falls back to -`lastKnownWorktrees` only for `disconnected | reconnecting | auth-failed`; `connecting`/ -`handshaking` fall through to the live (empty on fresh mount) array. Flipping the default without -extending that predicate would silently disable the stale-list fallback and blank the list — -extend it to every not-connected state (or key it on "no live fetch has succeeded this mount"). -Files: `src/transport/client-context.tsx` (`getState`, `useHostClient`, `forceReconnect`, -`openEntry`), `app/h/[hostId]/index.tsx` (fallback predicate), -`src/worktree/host-workspace-list-state.ts` (render a spinner for the not-connected states instead -of `null`). -Risk: a permanently unreachable host now shows amber briefly before the verdict system escalates — -acceptable; `classifyConnection` already owns escalation. Audit the §3(b) "wipe" sites for any that -key on `'disconnected'` specifically. -Tests: `client-context.test.ts` known-vs-unknown host defaults + forceReconnect state sequence; -host screen test for the stale-list fallback under `'connecting'`. - -### F3 (S3, quick win) — give the pairing relay path a log sink ✅ IMPLEMENTED (this branch) - -Approach: add an optional `onLog` to `connectMobileRelayForPairing`, -`createRecoveringPairingRelayCandidate`, and `resolvePairingInviteThroughDirector`; thread -`connectOptions.onLog` from the coordinator to the relay candidate; emit phase lines ("relay: -resolving director…", "relay: cell connected", "relay: E2EE handshake…", "relay: authenticated", -"relay: installing credential…"). Optionally also append pairing logs into `connectionLogStore` -under the resolved host id so the Connection Log screen has a record post-pairing. -Files: `src/transport/mobile-relay-physical-client.ts`, `pairing-relay-candidate.ts`, -`mobile-relay-invite-director.ts`, `pre-profile-pairing-coordinator.ts`. -Risk: none (additive, phone-local). Old desktops: unaffected — logging only. -Tests: coordinator test asserting relay-path log entries arrive through `connectOptions.onLog`; -extend `pairing-relay-candidate.test.ts` for per-attempt lines. - -### F4 (S1 class, quick win after F0) — coordinate the remaining bare deep pushes - -Approach: route notification taps (`app/_layout.tsx:127` + `src/notifications/notification-routing.ts`) -and the Account-usage card (`app/index.tsx:835`) through `src/navigation/host-stack-navigation.ts` -once #12001 lands; migrate host-edit onto the same stricter mechanism (#12001's own noted -follow-up). `orca://` deep links can follow later via a route-level guard. -Risk: notification cold-start ordering (push before root nav ready) — the mechanism already -tolerates that by waiting for state commits. -Tests: reuse the `host-stack-navigation.test.ts` harness for a notification-shaped target. - -### F5 (S2/S3, deeper) — make relay dials visible through `migrateTo` - -Approach: while the logical client is `suspended`/`'disconnected'`, have `migrateTo` forward the -dialing session's state publishes (`connecting`/`handshaking`) to `publishState`, unbinding on -success (normal bind takes over) or failure (restore `'disconnected'`). Guard: never downgrade a -still-`'connected'` previous session (make-before-break migrations must stay green). Follow-on UI: -show the path being dialed ("Connecting via Orca Relay…") by exposing the pending path, and let -`MobileHostCard`/`classifyConnection` render it while not yet connected. -Files: `src/transport/stable-logical-rpc-client.ts` (+ its test), `src/transport/connection-health.ts`, -`src/components/MobileHostCard.tsx`, `src/transport/mobile-connection-path-label.ts`. -Risk: state-ordering regressions in the pinned stable-client and connecting-label suites; keep the -forwarding strictly gated on suspended/disconnected. -Tests: add a relay-path analog of `cellular-connecting-label-stall.test.ts`; stable-client cases: -forwarded states during suspended dial, no forwarding during live-session replacement, failure -restores `'disconnected'`. - -### F6 (S3, deeper) — happy-eyeballs relay start post-pairing - -Approach: when a relay credential bundle exists and the direct dial has not authenticated within a -short grace (2–3s), start the relay dial in parallel instead of waiting for the 12s direct failure; -first authenticated path wins via the existing `migrateTo`/hysteresis machinery. Scope initially to -the first connect after pairing (or hosts whose last success was relay) to avoid pointless relay -sockets on healthy LANs. -Files: `src/transport/mobile-endpoint-supervisor.ts` (start/needsRecovery gating), possibly a -phone-local `HostProfile` hint field (no protocol impact; old desktops never present `relay`, so -the path is naturally guarded). -Risk: racing direct is exactly what `needsRecovery`'s design avoids — needs the mutex -(`operationInFlight`) audit and dwell/hysteresis respect; billed relay data on LANs if scoped too -broadly. -Tests: supervisor fake-timer cases: black-holed LAN converges in ~3-5s; healthy LAN never opens a -relay socket; relay loser closed after direct wins. - -### F7 (S1, deeper) — catalog-validate resume targets + not-found bounce - -Approach: (1) on Resume tap with the host connected, validate the target against the freshest -`worktree.ps` result (not the snapshot-seeded cache); if absent, open the host index instead. -(2) In the session screen, once connected and the catalog is known, bounce unknown `worktreeId`s -(exempting `folder:` and floating-workspace sentinels, `app/h/[hostId]/session/[worktreeId].tsx:852-854`) -to the host index with a notice. (3) Use the validating reader in -`src/worktree/last-visited-worktree-repo.ts` on home instead of the raw `JSON.parse` -(`app/index.tsx:315-322`), and import the storage-key constant at both literal call sites. -Risk: false bounces during slow catalog loads — only bounce on a *confirmed* fresh catalog miss. -Tests: repo tests for the validating reader on home; session-screen bounce cases incl. sentinel -exemptions. - -### F8 (S1 aggravator, cheap) — stop the Resume/Tasks layout shift - -Approach: reserve the Resume card's slot (fixed-height placeholder or render-below-Tasks) so its -late arrival cannot move the Tasks card under the thumb; alternatively render the card immediately -from the snapshot in a disabled state until the host connects. -Files: `app/index.tsx` footer. -Risk: none. -Tests: render test asserting footer order/height stability across `resumeWorktree` arrival. - -### F9 (S1 class, deeper) — HostProtocolGate should not unmount a mounted stack - -Approach: once the HostStack has mounted for a host, keep it mounted and overlay the pending -spinner instead of replacing children, preserving in-flight nested navigation; keep the hard -replace only for the `blocked` verdict. -Files: `src/components/HostProtocolGate.tsx`. -Risk: the gate exists so child routes don't call too-new RPCs while compatibility is unknown — an -overlay must still block interaction until resolved; verify child mount effects don't fire gated -RPCs pre-verdict before choosing overlay vs. current behavior. -Tests: gate test asserting no unmount across `statusPending` for an already-mounted host. - -### F10 (S2 class, deeper) — stop wiping loaded data on transient state flips - -Approach: audit the §3(b) destructive-clear sites and make each preserve data across a -not-`'connected'` blip, clearing only on host change or explicit sign-out. Top offenders by felt -impact: git history blanking rows on the reconnect branch -(`src/source-control/MobileGitHistoryList.tsx:63-68` — refetch without `setRows(null)`); the repo -cache wiped by the rejected in-flight call (`NewWorktreeModal.tsx:330-333` — keep last-good on -error); the diff-review "ready-state preserved" branch that is dead code because it sits after the -early return (`src/session/use-mobile-diff-review-controller.ts:85-89`); host capability wipe -(`src/transport/host-status-gates.ts:32`); the tasks-screen full re-hydration -(`app/h/[hostId]/tasks.tsx:2774-2800`); worktree-cache re-read on reconnect, not only at mount -(`app/h/[hostId]/index.tsx:129,330`). -Risk: showing stale data as if live — pair each preservation with the existing staleness verdicts -rather than inventing new indicators. The in-repo reference pattern is -`src/worktree/home-worktree-info.ts:27-47`: counts older than a 10min TTL render as -"Last known: N worktrees" instead of being dropped, and `markHomeWorktreeCatalogUnavailable` -preserves proven counts across a failed refresh, flagging only `staleCounts`. -Tests: per-surface "data survives disconnect→reconnect" cases; F1 largely removes the *trigger* -(suspend blips), so this is hardening, not the primary fix. - -## 5. Backward compatibility (old/new phone × old/new desktop) - -- Every fix above is phone-app-local; no RPC methods, close codes, credential formats, or pairing - steps change. Old phones against any desktop are untouched (they don't have the code). -- New phone + old desktop without relay support: `host.relay` is absent → F1/F5/F6 relay paths - never activate; pairing keeps the existing `method_not_found` downgrade - (`src/transport/pre-profile-pairing-coordinator.ts:210-217`); F3 logging is inert (no relay - candidate is created). -- New phone + old desktop with relay: all paths use existing RPCs (`status.get`, - `pairing.provisionRelay`, resume confirm) — no new calls introduced. F1's replacement dial reuses - the same resume-credential flow lease rotation already exercises against production desktops. -- F6's profile hint (if added) is a phone-local persisted field; absent values behave as today. - -## 6. Constants appendix (verified) - -| Constant | Value | Where | -|---|---|---| -| Direct connect timeout | 12s | `src/transport/rpc-client.ts:126` | -| Direct handshake timeout | 5s | `rpc-client.ts:127` | -| Direct reconnect ladder | 0.5→60s, give up 12, trickle 90s | `rpc-client.ts:113-117` | -| `migrateTo` auth timeout | 12s | `src/transport/stable-logical-rpc-client.ts:182` | -| Relay backoff | 250ms floor, 500ms base, 30s ceiling, full jitter | `src/transport/mobile-relay-retry-delays.ts:3-5` | -| Host-offline relay retry | 5–15s | `mobile-relay-retry-delays.ts:7-8` | -| Gate reprobe cadence | 60s→15min | `mobile-relay-retry-delays.ts:13-14` | -| Director resolve timeout | 5s (invite & resume) | `mobile-relay-invite-director.ts:16`, `mobile-relay-resume-director.ts:21` | -| Pairing relay recovery | ≤3 attempts × (5s director + ≤100/200/400ms jitter) | `src/transport/pairing-relay-candidate.ts:42,58-71` | -| Pairing overall cap | 25s | `app/pair-confirm.tsx:27` | -| Relay E2EE layer timers | none | `mobile-relay-e2ee-link.ts`, `mobile-e2ee-v2-*.ts` | -| Worktree cache TTL | 30s from write/seed | `src/cache/worktree-cache.ts:12` | -| Direct activity probe (foreground) | detects half-open ≤8s | `rpc-client.ts:1119-1124` | From b755629f3776150a50d9d9ecceeefae0bd51f252 Mon Sep 17 00:00:00 2001 From: hwantage <82494320+hwantage@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:47:06 +0900 Subject: [PATCH 003/152] feat(i18n): add Korean translations for CLI-created workspace labels (#16212) - Localize filter toggle labels in SidebarFilter and SidebarWorkspaceFilterSection. - Localize card detail descriptions in WorktreeCardCliDetailSection. - Localize meta badge accessibility label in WorktreeCardMetaBadges. - Resolves English fallback for CLI-created workspace UI under Korean locale. --- src/renderer/src/i18n/locales/ko.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index f34e3b30662..bf5238cbc34 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4288,7 +4288,7 @@ "489d1c8c9f": "프로젝트 검색...", "ee240a39eb": "필터 편집", "automationCreated": "자동화로 생성된 워크스페이스 숨기기", - "cliCreated": "Hide CLI-created", + "cliCreated": "CLI로 생성된 워크스페이스 숨기기", "detachedHead": "분리된 HEAD 숨기기", "keepDefaultBranch": "기본 브랜치는 제외", "keepDefaultBranchAria": "슬립 중인 워크스페이스를 숨겨도 기본 브랜치는 계속 표시" @@ -4357,7 +4357,7 @@ "ed1611b65b": "슬립 중인 항목 숨기기", "82594419ba": "필터", "automationCreated": "자동화로 생성된 워크스페이스 숨기기", - "cliCreated": "Hide CLI-created", + "cliCreated": "CLI로 생성된 워크스페이스 숨기기", "detachedHead": "분리된 HEAD 숨기기", "keepDefaultBranch": "기본 브랜치는 제외", "keepDefaultBranchAria": "슬립 중인 워크스페이스를 숨겨도 기본 브랜치는 계속 표시" @@ -4530,10 +4530,10 @@ "issueLinkLabel": "이슈 링크", "reviewLinkLabel": "{{value0}} 링크", "cliHeader": "Orca CLI", - "cliCreatedFromAgent": "Created by an agent via `orca worktree create`", - "cliCreatedFromShell": "Created via `orca worktree create`", - "cliStartupAgent": "Started with {{value0}}", - "cliCreated": "Created by Orca CLI", + "cliCreatedFromAgent": "Agent가 `orca worktree create`로 생성", + "cliCreatedFromShell": "`orca worktree create`로 생성", + "cliStartupAgent": "{{value0}}(으)로 시작됨", + "cliCreated": "Orca CLI로 생성됨", "jiraIssue": "Jira {{value0}}", "viewOnJira": "Jira에서 보기", "linkedJira": "연결된 Jira {{value0}}" From d9c77c5830eee8955110c178eff3eed1de1fe134 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:21:48 -0700 Subject: [PATCH 004/152] fix(automations): restore main checks --- .../automations/AutomationsListPanel.test.tsx | 1 + .../automations/AutomationsPage.tsx | 28 ++----------------- .../automations/automation-host-client.ts | 3 -- 3 files changed, 4 insertions(+), 28 deletions(-) diff --git a/src/renderer/src/components/automations/AutomationsListPanel.test.tsx b/src/renderer/src/components/automations/AutomationsListPanel.test.tsx index ab0be05df3b..3ccaefabfab 100644 --- a/src/renderer/src/components/automations/AutomationsListPanel.test.tsx +++ b/src/renderer/src/components/automations/AutomationsListPanel.test.tsx @@ -98,6 +98,7 @@ function renderPanel( requestExternalAction={() => undefined} openEditExternalDialog={() => undefined} openCreateDialog={() => undefined} + canCreateAutomation onOpenDetail={() => undefined} onRefresh={() => undefined} isRefreshing={false} diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 7e3beff585a..85b4ddf5929 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -63,10 +63,7 @@ import type { AutomationTemplate } from './automation-templates' import { getAutomationTargetAvailability } from './automation-target-availability' import { getAutomationCreateAvailability } from './automation-create-admission' import { buildAutomationRunContextForRepo } from './automation-run-context' -import { - repoMatchesExternalAutomationTarget, - getExternalAutomationTargetForRepo -} from './automation-external-target-match' +import { repoMatchesExternalAutomationTarget } from './automation-external-target-match' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' @@ -82,6 +79,7 @@ import { getAutomationHostTargetFromKey, getAutomationHostTargetKey, getAutomationListTarget, + getAutomationOwnerTarget, getAutomationTargetFromHostId, listAutomationsForTarget, runAutomationNowForTarget, @@ -1349,27 +1347,7 @@ export default function AutomationsPage(): React.JSX.Element { : null const projectId = targetWorktree?.repoId ?? fallbackRepo?.id ?? '' const workspaceId = targetWorktree?.id ?? fallbackWorktree?.id ?? '' - const nextDraft: AutomationDraft = { - name: job.name, - prompt: job.prompt ?? job.promptPreview, - agentId: 'hermes', - projectId, - workspaceMode: 'existing', - workspaceId, - baseBranch: '', - setupDecision: undefined, - reuseSession: false, - precheckCommand: '', - precheckTimeoutSeconds: '60', - preset: hasCustomSchedule ? 'custom' : 'weekdays', - time: AUTOMATION_DEFAULT_TIME, - dayOfWeek: '1', - customSchedule: hasCustomSchedule ? rawSchedule : '', - missedRunGraceMinutes: '720', - scheduleWarning: hasCustomSchedule - ? null - : 'This Hermes automation has an unsupported saved schedule. Pick a supported schedule before saving changes.' - } + const nextDraft = buildExternalAutomationEditDraft(job, { projectId, workspaceId }) setEditingAutomationId(null) setEditingRowKey(null) setEditingDestination(null) diff --git a/src/renderer/src/components/automations/automation-host-client.ts b/src/renderer/src/components/automations/automation-host-client.ts index 79ef3c65436..52c2d85e341 100644 --- a/src/renderer/src/components/automations/automation-host-client.ts +++ b/src/renderer/src/components/automations/automation-host-client.ts @@ -127,9 +127,6 @@ export async function listAutomationRunsForTarget( export async function createAutomationForTarget(input: AutomationCreateInput): Promise { const target = getAutomationCreateTarget(input) - if (target.kind === 'local') { - return await window.api.automations.create(input) - } const result = await callRuntimeRpc<{ automation: Automation }>( target, 'automation.create', From 588eec68b4cf681a4382051ed385591e0550fc6d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:38:59 -0700 Subject: [PATCH 005/152] fix(native-chat): stop rendering a tool result whose call is outside the window (#15653) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(native-chat): stop rendering a tool result whose call is outside the window A tool result carries no call id, so it can only be attributed to a tool call loaded alongside it. Both chat views read a windowed transcript tail (mobile 40 messages, desktop 300), and the window regularly opens between an assistant's `tool_use` record and the user-role record that answers it. Claude also re-emits already-answered `tool_result` records at a `/compact` boundary, long after their call scrolled out of the window. `foldToolMessages` had no rule for those: with no assistant predecessor in the output they were pushed through as standalone messages and rendered as a bare, unowned block of raw tool output with no tool name — reading as a message from nowhere mid-conversation. Sampling real Claude transcripts, 176 of 400 sessions (44%) produced one in a mobile-sized first page. Drop a result no loaded call can own, before folding. It is not lost: it comes back attached to its call as soon as the owning turn pages in. * fix(native-chat): scope tool result attribution to folded turns * fix(native-chat): preserve harness-attributed tool results * fix(native-chat): keep interruption boundaries --- .../session/mobile-native-chat-blocks.test.ts | 14 +- .../mobile-native-chat-render-data.test.ts | 89 ++++++++++ .../session/mobile-native-chat-render-data.ts | 2 +- ...transcript-window-tool-attribution.test.ts | 156 ++++++++++++++++++ .../native-chat/NativeChatMessageList.tsx | 8 +- .../native-chat/native-chat-tool-fold.test.ts | 135 ++++++++++++++- src/shared/native-chat-tool-fold.ts | 102 +++++++++++- 7 files changed, 480 insertions(+), 26 deletions(-) create mode 100644 src/main/native-chat/transcript-window-tool-attribution.test.ts diff --git a/mobile/src/session/mobile-native-chat-blocks.test.ts b/mobile/src/session/mobile-native-chat-blocks.test.ts index 27b47576bfc..09dfcea8a0e 100644 --- a/mobile/src/session/mobile-native-chat-blocks.test.ts +++ b/mobile/src/session/mobile-native-chat-blocks.test.ts @@ -125,10 +125,9 @@ describe('foldToolMessages', () => { expect(folded.map((m) => m.id)).toEqual(['a0', 'a2']) }) - it('keeps a tool message standalone when no assistant precedes it', () => { + it('drops a tool message whose call is outside the loaded window', () => { const folded = foldToolMessages([msg('tool', [{ type: 'tool-result', output: 'x' }])]) - expect(folded).toHaveLength(1) - expect(folded[0]!.role).toBe('tool') + expect(folded).toHaveLength(0) }) it('does not merge across a user message', () => { @@ -137,17 +136,18 @@ describe('foldToolMessages', () => { msg('user', [{ type: 'text', text: 'q' }], 'u'), msg('tool', [{ type: 'tool-result', output: 'r' }], 't') ]) - expect(folded.map((m) => m.role)).toEqual(['assistant', 'user', 'tool']) + expect(folded.map((m) => m.role)).toEqual(['assistant', 'user']) }) it('folds a long tool run without mutating the source assistant', () => { const assistant = msg('assistant', [{ type: 'text', text: 'working' }], 'a') - const tools = Array.from({ length: 1_000 }, (_unused, index) => + const tools = Array.from({ length: 1_000 }, (_unused, index) => [ + msg('assistant', [{ type: 'tool-call', name: 'Bash', input: {} }], `c-${index}`), msg('tool', [{ type: 'tool-result', output: String(index) }], `t-${index}`) - ) + ]).flat() const folded = foldToolMessages([assistant, ...tools]) - expect(folded[0].blocks).toHaveLength(1_001) + expect(folded[0].blocks).toHaveLength(2_001) expect(assistant.blocks).toHaveLength(1) }) }) diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts index 13fbddf8e69..60db9e818d5 100644 --- a/mobile/src/session/mobile-native-chat-render-data.test.ts +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -180,3 +180,92 @@ describe('buildMobileNativeChatTransientData', () => { expect(data.some((message) => message.id === 'streaming')).toBe(false) }) }) + +describe('foldMobileNativeChatMessages', () => { + function toolCall(id: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'Bash', input: { command: 'command -v orca-ide' } }], + timestamp: 0, + source: 'transcript' + } + } + + function toolResult(id: string, output: string): NativeChatMessage { + return { + id, + role: 'tool', + blocks: [{ type: 'tool-result', output }], + timestamp: 0, + source: 'transcript' + } + } + + // The chat reads a 40-message tail, so the window regularly opens on a result + // whose `tool_use` is older than the window. It used to render as its own + // bubble of raw shell output with no owning call. + it('drops a leading tool result whose call is older than the read window', () => { + const folded = foldMobileNativeChatMessages([ + toolResult('orphan', 'Exit code 1\norca-ide not found'), + assistant('a1', 'Falling back to the installed binary.') + ]) + + expect(folded.map((message) => message.id)).toEqual(['a1']) + }) + + it('still folds a result whose call is inside the window', () => { + const folded = foldMobileNativeChatMessages([ + assistant('a1', 'Checking which binary is on PATH.'), + toolCall('c1'), + toolResult('r1', 'orca-ide not found') + ]) + + expect(folded).toHaveLength(1) + expect(folded[0]?.blocks.map((block) => block.type)).toEqual([ + 'text', + 'tool-call', + 'tool-result' + ]) + }) + + it('keeps a mixed Claude tool result with a harness sidecar paired', () => { + const folded = foldMobileNativeChatMessages([ + toolCall('c1'), + { + id: 'mixed', + role: 'user', + blocks: [ + { type: 'tool-result', output: 'important output' }, + { type: 'text', text: 'continue' } + ], + timestamp: 0, + source: 'transcript' + } + ]) + + expect(folded[0]?.blocks).toEqual([ + { type: 'tool-call', name: 'Bash', input: { command: 'command -v orca-ide' } }, + { type: 'tool-result', output: 'important output' } + ]) + }) + + it('keeps a hidden interruption from authorizing a later result', () => { + const folded = foldMobileNativeChatMessages([ + toolCall('c1'), + { + id: 'interrupt', + role: 'user', + blocks: [{ type: 'text', text: '[Request interrupted by user]' }], + timestamp: 1, + source: 'transcript' + }, + toolResult('orphan', 'stale output') + ]) + + expect(folded.map((message) => message.id)).toEqual(['c1']) + expect(folded[0]?.blocks).toEqual([ + { type: 'tool-call', name: 'Bash', input: { command: 'command -v orca-ide' } } + ]) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index ce24a1fa507..92b7e6b8338 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -47,7 +47,7 @@ export type MobileNativeChatPendingItem = { export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] { // Normalize first (desktop assembler parity): image marker turns fold into // image-ref blocks instead of rendering as raw `[Image: …]` text. - return foldToolMessages(stripNoiseMessages(normalizeImageTranscriptMessages(messages))) + return stripNoiseMessages(foldToolMessages(normalizeImageTranscriptMessages(messages))) } /** Assemble the list data the chat renders: the folded transcript, then a diff --git a/src/main/native-chat/transcript-window-tool-attribution.test.ts b/src/main/native-chat/transcript-window-tool-attribution.test.ts new file mode 100644 index 00000000000..a8428a165d0 --- /dev/null +++ b/src/main/native-chat/transcript-window-tool-attribution.test.ts @@ -0,0 +1,156 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { stripNoiseMessages } from '../../shared/native-chat-noise' +import { foldToolMessages, pairToolBlocks } from '../../shared/native-chat-tool-fold' +import { isToolCallBlock, isToolResultBlock } from '../../shared/native-chat-types' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { decodeClaudeTranscriptLine } from './transcript-line-decoders-claude' +import { readNativeChatTranscriptTailFile } from './transcript-tail-reader' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +async function writeTranscript(records: unknown[]): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-attribution-')) + tempRoots.push(root) + const filePath = join(root, 'transcript.jsonl') + await writeFile(filePath, records.map((record) => JSON.stringify(record)).join('\n')) + return filePath +} + +/** One Claude turn: the assistant's `tool_use` record, then the user-role record + * carrying its `tool_result` — the shape every Claude transcript writes. */ +function toolTurn(n: number): unknown[] { + return [ + { + type: 'assistant', + uuid: `call-${n}`, + timestamp: `2026-08-20T10:00:${String(n).padStart(2, '0')}.000Z`, + message: { + role: 'assistant', + content: [{ type: 'tool_use', name: 'Bash', input: { command: `echo ${n}` } }] + } + }, + { + type: 'user', + uuid: `result-${n}`, + timestamp: `2026-08-20T10:00:${String(n).padStart(2, '0')}.500Z`, + message: { + role: 'user', + content: [{ type: 'tool_result', content: `output ${n}`, tool_use_id: `toolu_${n}` }] + } + } + ] +} + +/** The rendered defect: a tool row with output but no call to attribute it to. + * Mirrors what both chat views build from a folded message's tool blocks. */ +function unattributedResults(messages: readonly NativeChatMessage[]): string[] { + const stray: string[] = [] + for (const message of messages) { + const tools = message.blocks.filter( + (block) => isToolCallBlock(block) || isToolResultBlock(block) + ) + if (tools.length === 0) { + continue + } + for (const pair of pairToolBlocks(tools)) { + if (pair.call === undefined && pair.result) { + stray.push(pair.result.output) + } + } + } + return stray +} + +async function renderedMessages(filePath: string, limit: number): Promise { + const page = await readNativeChatTranscriptTailFile( + filePath, + limit, + decodeClaudeTranscriptLine, + true + ) + return stripNoiseMessages(foldToolMessages(page.messages)) +} + +describe('windowed transcript tool attribution', () => { + it('drops a tool result whose call falls outside the read window', async () => { + const filePath = await writeTranscript([...toolTurn(1), ...toolTurn(2), ...toolTurn(3)]) + + // A 3-message window cuts turn 2 in half: its result is the oldest record in + // the window, its `tool_use` is not. + const windowed = await renderedMessages(filePath, 3) + + expect(unattributedResults(windowed)).toEqual([]) + // The turn that survives whole still renders its output. + expect( + windowed.flatMap((message) => + message.blocks.filter(isToolResultBlock).map((block) => block.output) + ) + ).toEqual(['output 3']) + }) + + it('keeps every result when the window contains its call', async () => { + const filePath = await writeTranscript([...toolTurn(1), ...toolTurn(2)]) + + const whole = await renderedMessages(filePath, 100) + + expect(unattributedResults(whole)).toEqual([]) + expect( + whole.flatMap((message) => + message.blocks.filter(isToolResultBlock).map((block) => block.output) + ) + ).toEqual(['output 1', 'output 2']) + }) + + it('drops the answered result records Claude re-emits at a /compact boundary', async () => { + // Claude appends byte-identical copies of already-answered tool_result + // records after a compaction, with no `tool_use` in between. + const [, answeredResult] = toolTurn(1) + const filePath = await writeTranscript([ + ...toolTurn(1), + { + type: 'user', + uuid: 'compact-prompt', + timestamp: '2026-08-20T10:05:00.000Z', + message: { role: 'user', content: '/compact' } + }, + answeredResult + ]) + + const whole = await renderedMessages(filePath, 100) + + expect(unattributedResults(whole)).toEqual([]) + expect( + whole.flatMap((message) => + message.blocks.filter(isToolResultBlock).map((block) => block.output) + ) + ).toEqual(['output 1']) + }) + + it('keeps a hidden interruption from authorizing a later result', async () => { + const [abandonedCall] = toolTurn(1) + const [, laterResult] = toolTurn(2) + const filePath = await writeTranscript([ + abandonedCall, + { + type: 'user', + uuid: 'interrupt', + timestamp: '2026-08-20T10:01:00.000Z', + message: { role: 'user', content: '[Request interrupted by user]' } + }, + laterResult + ]) + + const messages = await renderedMessages(filePath, 100) + + expect(unattributedResults(messages)).toEqual([]) + expect(messages.map((message) => message.id)).toEqual(['call-1']) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 354732ba0d8..f3393a188b1 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -265,13 +265,9 @@ export function NativeChatMessageList({ const { hasMore, loadingEarlier, loadEarlier } = session - // Strip harness noise (task-notifications, system reminders, slash-command - // envelopes) before folding so they don't render as the user's own bubbles — - // matching the mobile chat. Then fold each turn's tool activity into the - // assistant message it belongs to, ordered stably, so a turn's tools collapse - // under one run. + // Keep hidden harness turns as fold boundaries, then strip them before render. const messages = useMemo( - () => foldToolMessages(orderNativeChatMessages(stripNoiseMessages(session.messages))), + () => stripNoiseMessages(foldToolMessages(orderNativeChatMessages(session.messages))), [session.messages] ) const showTypingIndicator = diff --git a/src/renderer/src/components/native-chat/native-chat-tool-fold.test.ts b/src/renderer/src/components/native-chat/native-chat-tool-fold.test.ts index c7b2caba305..fd4976c6d01 100644 --- a/src/renderer/src/components/native-chat/native-chat-tool-fold.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-tool-fold.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest' import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { stripNoiseMessages } from './native-chat-noise' import { foldToolMessages, splitNativeChatBlocks } from './native-chat-tool-fold' function msg( @@ -17,13 +18,21 @@ function msg( describe('foldToolMessages', () => { it('merges a tool-only message into the preceding assistant turn', () => { const folded = foldToolMessages([ - msg({ id: 'a', role: 'assistant', blocks: [{ type: 'text', text: 'running it' }] }), + msg({ + id: 'a', + role: 'assistant', + blocks: [ + { type: 'text', text: 'running it' }, + { type: 'tool-call', name: 'Bash', input: {} } + ] + }), msg({ id: 't', role: 'tool', blocks: [{ type: 'tool-result', output: 'done' }] }) ]) expect(folded).toHaveLength(1) expect(folded[0]?.id).toBe('a') expect(folded[0]?.blocks).toEqual([ { type: 'text', text: 'running it' }, + { type: 'tool-call', name: 'Bash', input: {} }, { type: 'tool-result', output: 'done' } ]) }) @@ -38,12 +47,58 @@ describe('foldToolMessages', () => { expect(folded[0]?.blocks).toHaveLength(3) }) - it('leaves an orphan tool message standalone when no assistant precedes it', () => { + it('drops a tool result no loaded call can own instead of leaving it standalone', () => { const folded = foldToolMessages([ msg({ id: 'u', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }), msg({ id: 't', role: 'tool', blocks: [{ type: 'tool-result', output: 'x' }] }) ]) - expect(folded.map((m) => m.id)).toEqual(['u', 't']) + expect(folded.map((m) => m.id)).toEqual(['u']) + }) + + it('drops a result after a user turn abandons the pending call', () => { + const folded = foldToolMessages([ + msg({ id: 'c', role: 'assistant', blocks: [{ type: 'tool-call', name: 'Bash', input: {} }] }), + msg({ id: 'u', role: 'user', blocks: [{ type: 'text', text: 'stop' }] }), + msg({ id: 't', role: 'tool', blocks: [{ type: 'tool-result', output: 'x' }] }) + ]) + expect(folded.map((m) => m.id)).toEqual(['c', 'u']) + }) + + it('keeps a hidden interruption as an attribution boundary', () => { + const folded = stripNoiseMessages( + foldToolMessages([ + msg({ + id: 'c', + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'Bash', input: {} }] + }), + msg({ + id: 'i', + role: 'user', + blocks: [{ type: 'text', text: '[Request interrupted by user]' }] + }), + msg({ id: 't', role: 'tool', blocks: [{ type: 'tool-result', output: 'stale' }] }) + ]) + ) + + expect(folded.map((message) => message.id)).toEqual(['c']) + expect(folded[0]?.blocks).toEqual([{ type: 'tool-call', name: 'Bash', input: {} }]) + }) + + it('removes a result folded into assistant prose without mutating the source', () => { + const assistant = msg({ + id: 'a', + role: 'assistant', + blocks: [{ type: 'text', text: 'continuing' }] + }) + + const folded = foldToolMessages([ + assistant, + msg({ id: 't', role: 'tool', blocks: [{ type: 'tool-result', output: 'stale' }] }) + ]) + + expect(folded).toEqual([assistant]) + expect(folded[0]).not.toBe(assistant) }) it('does not fold a message carrying prose alongside a tool block', () => { @@ -60,6 +115,80 @@ describe('foldToolMessages', () => { ]) expect(folded.map((m) => m.id)).toEqual(['a', 'b']) }) + + it('attributes a Claude tool result carried with a harness sidecar', () => { + const folded = foldToolMessages([ + msg({ + id: 'a', + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'Read', input: {} }] + }), + msg({ + id: 'u', + role: 'user', + blocks: [ + { type: 'tool-result', output: 'important output' }, + { type: 'text', text: 'continue' } + ] + }) + ]) + + expect(folded).toEqual([ + expect.objectContaining({ + id: 'a', + blocks: [ + { type: 'tool-call', name: 'Read', input: {} }, + { type: 'tool-result', output: 'important output' } + ] + }), + expect.objectContaining({ + id: 'u', + blocks: [{ type: 'text', text: 'continue' }] + }) + ]) + }) + + it('does not traverse an interruption sidecar carrying a stale result', () => { + const folded = foldToolMessages([ + msg({ + id: 'a', + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'Read', input: {} }] + }), + msg({ + id: 'i', + role: 'user', + blocks: [ + { type: 'tool-result', output: 'stale' }, + { type: 'text', text: '[Request interrupted by user]' } + ] + }) + ]) + + expect(folded).toEqual([ + expect.objectContaining({ + id: 'a', + blocks: [{ type: 'tool-call', name: 'Read', input: {} }] + }), + expect.objectContaining({ + id: 'i', + blocks: [{ type: 'text', text: '[Request interrupted by user]' }] + }) + ]) + }) + + it('folds through a harness noise boundary but not a real user turn', () => { + const folded = foldToolMessages([ + msg({ id: 'a', role: 'assistant', blocks: [{ type: 'tool-call', name: 'Read', input: {} }] }), + msg({ id: 'n', role: 'user', blocks: [{ type: 'text', text: 'done' }] }), + msg({ id: 'r', role: 'tool', blocks: [{ type: 'tool-result', output: 'ok' }] }) + ]) + + expect(folded.find((message) => message.id === 'a')?.blocks).toEqual([ + { type: 'tool-call', name: 'Read', input: {} }, + { type: 'tool-result', output: 'ok' } + ]) + }) }) describe('splitNativeChatBlocks', () => { diff --git a/src/shared/native-chat-tool-fold.ts b/src/shared/native-chat-tool-fold.ts index 1b363a280fa..f4cc46124a0 100644 --- a/src/shared/native-chat-tool-fold.ts +++ b/src/shared/native-chat-tool-fold.ts @@ -6,6 +6,8 @@ import { type NativeChatToolCallBlock, type NativeChatToolResultBlock } from './native-chat-types' +import { isKnownHarnessInjectedUserTurnText } from './harness-injected-user-turns' +import { isNoiseMessage } from './native-chat-noise' function isToolOnlyMessage(message: NativeChatMessage): boolean { return ( @@ -14,25 +16,107 @@ function isToolOnlyMessage(message: NativeChatMessage): boolean { ) } +function isHarnessSidecarToolMessage(message: NativeChatMessage): boolean { + if ( + message.role !== 'user' || + isInterruptionBoundary(message) || + !message.blocks.some(isToolResultBlock) + ) { + return false + } + const textBlocks = message.blocks.filter((block) => block.type === 'text') + return ( + textBlocks.length > 0 && + message.blocks.every( + (block) => + isToolResultBlock(block) || + (block.type === 'text' && isKnownHarnessInjectedUserTurnText(block.text)) + ) + ) +} + +function isInterruptionBoundary(message: NativeChatMessage): boolean { + return message.blocks.some( + (block) => + block.type === 'text' && block.text.trim().toLowerCase().startsWith('[request interrupted') + ) +} + +/** Drop tool results the renderer cannot pair within their folded message. */ +function dropUnattributableToolResults(message: NativeChatMessage): NativeChatMessage | null { + const blocks: NativeChatBlock[] = [] + let unansweredCalls = 0 + for (const block of message.blocks) { + if (isToolCallBlock(block)) { + unansweredCalls += 1 + } else if (isToolResultBlock(block)) { + if (unansweredCalls === 0) { + continue + } + unansweredCalls -= 1 + } + blocks.push(block) + } + if (blocks.length === message.blocks.length) { + return message + } + return blocks.length > 0 ? { ...message, blocks } : null +} + /** Fold consecutive tool-only messages into their preceding assistant turn. */ export function foldToolMessages(messages: readonly NativeChatMessage[]): NativeChatMessage[] { const output: NativeChatMessage[] = [] let mutableAssistantIndex = -1 + let clonedAssistantIndex = -1 for (const message of messages) { - const previous = output.at(-1) - if (isToolOnlyMessage(message) && previous?.role === 'assistant') { - const index = output.length - 1 - if (mutableAssistantIndex !== index) { - output[index] = { ...previous, blocks: [...previous.blocks] } - mutableAssistantIndex = index + if (isHarnessSidecarToolMessage(message) && mutableAssistantIndex >= 0) { + const index = mutableAssistantIndex + const assistant = output[index] + if (assistant?.role === 'assistant') { + if (clonedAssistantIndex !== index) { + output[index] = { ...assistant, blocks: [...assistant.blocks] } + clonedAssistantIndex = index + } + output[index].blocks.push(...message.blocks.filter(isToolResultBlock)) + output.push({ + ...message, + blocks: message.blocks.filter((block) => !isToolResultBlock(block)) + }) + continue + } + } + if (isToolOnlyMessage(message) && mutableAssistantIndex >= 0) { + const index = mutableAssistantIndex + const assistant = output[index] + if (assistant?.role !== 'assistant') { + output.push(message) + mutableAssistantIndex = -1 + continue + } + if (clonedAssistantIndex !== index) { + output[index] = { ...assistant, blocks: [...assistant.blocks] } + clonedAssistantIndex = index } output[index]!.blocks.push(...message.blocks) - } else { - output.push(message) + continue + } + output.push(message) + if (message.role === 'assistant') { + mutableAssistantIndex = output.length - 1 + clonedAssistantIndex = -1 + } else if (!isNoiseMessage(message) || isInterruptionBoundary(message)) { mutableAssistantIndex = -1 + clonedAssistantIndex = -1 } } - return output + const attributedOutput: NativeChatMessage[] = [] + for (const message of output) { + const attributed = dropUnattributableToolResults(message) + if (attributed) { + attributedOutput.push(attributed) + } + } + return attributedOutput } export type NativeChatToolPair = { From 5a59bc5bc48720e516dec17cd71784c5c63d1775 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:48:52 -0700 Subject: [PATCH 006/152] fix(grok): stop Orca's Grok hooks from costing anything outside Orca (#16666) * fix(grok): stop Orca's Grok hooks from costing anything outside Orca Orca registers Grok agent-status hooks in the global $GROK_HOME/hooks. Grok loads that directory on every session, so a Grok run that Orca did not launch still paid for the hook on every event, and Orca rewrote the file even after a user had emptied it to opt out (#15518). The registered POSIX command now guards on ORCA_PANE_KEY before doing anything. That variable is part of the pane identity Orca injects into terminals it launches, and unlike the port and token it never comes from the endpoint file, so it is present exactly when the session belongs to Orca. A standalone session short-circuits without spawning a shell for the managed script at all. The same guard is applied to the remote install, because a remote host runs standalone Grok sessions too. PreToolUse is no longer registered. It is a blocking hook, so Orca sat on the critical path of every tool call and doubled the per-tool spawns, for a transition PostToolUse already reports. Windows cannot use the guard: the command there must be a single spawnable token, so it is a bare script path with no shell to evaluate a test. For that case the hooks are removed when Orca quits -- locally, on WSL guests, and on connected SSH hosts -- and reinstalled on the next launch. A config the user has emptied is left alone on startup; turning the setting back on in Settings is an explicit and later choice, so that path reinstalls. Removal is careful about what it is deleting. It strips only Orca's own entries, keeps user-authored ones, and deletes the file only when no hook entries remain -- keying that off the whole object would leave a stray non-hook key behind, and the emptied-config check would then read that remnant as a deliberate opt-out and never reinstall. A config the user has symlinked into a dotfiles repo is written through rather than unlinked, and is exempt from the emptied-config check for the same reason: after a quit it is a file Orca emptied, not one the user did. Writes go through temp+rename. Grok refuses to build a sandbox profile for a hook JSON with more than one hard link, so publishing by hard link would fail any session that started during the write. Install and removal on remote hosts now read the platform from the same field. They did not, so a Windows remote whose bridge env was incomplete had hooks installed and never removed. Co-authored-by: Siddiqui Qamar <137684575+siddqamar@users.noreply.github.com> * fix(grok): preserve hook state outside Orca --------- Co-authored-by: Siddiqui Qamar <137684575+siddqamar@users.noreply.github.com> --- config/tsconfig.cli.json | 7 + .../managed-agent-hook-controls.test.ts | 48 +- .../managed-agent-hook-controls.ts | 41 +- .../managed-agent-hook-registry.ts | 16 +- src/main/agent-hooks/posix-hook-command.ts | 15 +- .../remote-hook-service-installers.test.ts | 2 +- src/main/grok/grok-hook-config-cleanup.ts | 58 +++ src/main/grok/grok-hook-config-file.test.ts | 207 +++++++++ src/main/grok/grok-hook-config-file.ts | 156 +++++++ src/main/grok/grok-hook-config.ts | 66 +++ src/main/grok/grok-hook-owners.test.ts | 64 +++ src/main/grok/grok-hook-owners.ts | 113 +++++ .../grok/grok-hook-remnant-removal.test.ts | 60 +++ src/main/grok/grok-hook-remote-install.ts | 73 +++ src/main/grok/grok-hook-script.ts | 61 +++ .../grok/grok-hook-symlink-cleanup-marker.ts | 58 +++ src/main/grok/hook-service.test.ts | 183 +++++++- src/main/grok/hook-service.ts | 418 ++++++++---------- src/main/index.ts | 37 +- src/main/ipc/settings.ts | 1 + .../quit-path-durable-write-blocking.test.ts | 52 ++- src/main/quit-teardown-deadline.ts | 32 ++ tests/e2e/fixtures/golden-stub-agent/grok | 2 + tests/e2e/fixtures/golden-stub-agent/grok.cmd | 1 + tests/e2e/grok-hook-session-cleanup.spec.ts | 85 ++++ 25 files changed, 1604 insertions(+), 252 deletions(-) create mode 100644 src/main/grok/grok-hook-config-cleanup.ts create mode 100644 src/main/grok/grok-hook-config-file.test.ts create mode 100644 src/main/grok/grok-hook-config-file.ts create mode 100644 src/main/grok/grok-hook-config.ts create mode 100644 src/main/grok/grok-hook-owners.test.ts create mode 100644 src/main/grok/grok-hook-owners.ts create mode 100644 src/main/grok/grok-hook-remnant-removal.test.ts create mode 100644 src/main/grok/grok-hook-remote-install.ts create mode 100644 src/main/grok/grok-hook-script.ts create mode 100644 src/main/grok/grok-hook-symlink-cleanup-marker.ts create mode 100755 tests/e2e/fixtures/golden-stub-agent/grok create mode 100644 tests/e2e/fixtures/golden-stub-agent/grok.cmd create mode 100644 tests/e2e/grok-hook-session-cleanup.spec.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 65116437cc1..e5cf60dd5a7 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -85,6 +85,13 @@ "../src/main/cursor/hook-service.ts", "../src/main/droid/hook-service.ts", "../src/main/gemini/hook-service.ts", + "../src/main/grok/grok-hook-config.ts", + "../src/main/grok/grok-hook-config-cleanup.ts", + "../src/main/grok/grok-hook-config-file.ts", + "../src/main/grok/grok-hook-owners.ts", + "../src/main/grok/grok-hook-remote-install.ts", + "../src/main/grok/grok-hook-script.ts", + "../src/main/grok/grok-hook-symlink-cleanup-marker.ts", "../src/main/grok/hook-service.ts", "../src/main/grok/windows-grok-hook-script.ts", "../src/main/devin/hook-settings.ts", diff --git a/src/main/agent-hooks/managed-agent-hook-controls.test.ts b/src/main/agent-hooks/managed-agent-hook-controls.test.ts index 60004f94df0..6ea28d66247 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.test.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.test.ts @@ -6,6 +6,8 @@ const mocks = vi.hoisted(() => ({ installCodex: vi.fn(), removeClaude: vi.fn(), removeCodex: vi.fn(), + removeClaudeAsync: vi.fn(), + removeCodexAsync: vi.fn(), statusClaude: vi.fn(), statusCodex: vi.fn(), refreshClaude: vi.fn(), @@ -25,6 +27,10 @@ vi.mock('./managed-agent-hook-registry', () => ({ ['claude', mocks.removeClaude], ['codex', mocks.removeCodex] ], + MANAGED_AGENT_HOOK_ASYNC_REMOVERS: [ + ['claude', mocks.removeClaudeAsync], + ['codex', mocks.removeCodexAsync] + ], MANAGED_AGENT_HOOK_STATUS_READERS: [ ['claude', mocks.statusClaude], ['codex', mocks.statusCodex] @@ -37,7 +43,9 @@ vi.mock('./managed-agent-hook-registry', () => ({ import { applyAgentStatusHooksEnabled, - installManagedAgentHooks + installManagedAgentHooks, + removeManagedAgentHooksAsync, + shouldContinueManagedHookStartup } from './managed-agent-hook-controls' function status(agent: 'claude' | 'codex', state: 'installed' | 'not_installed') { @@ -57,6 +65,8 @@ describe('managed agent hook controls', () => { mocks.installCodex.mockReturnValue(status('codex', 'installed')) mocks.removeClaude.mockReturnValue(status('claude', 'not_installed')) mocks.removeCodex.mockReturnValue(status('codex', 'not_installed')) + mocks.removeClaudeAsync.mockResolvedValue(status('claude', 'not_installed')) + mocks.removeCodexAsync.mockResolvedValue(status('codex', 'not_installed')) mocks.refreshClaude.mockResolvedValue(undefined) mocks.refreshCodex.mockResolvedValue(undefined) }) @@ -119,7 +129,10 @@ describe('managed agent hook controls', () => { }) const install = installManagedAgentHooks({ agentCmdOverrides: {} }) - await Promise.resolve() + // Why waitFor and not a fixed microtask tick: the install path awaits session reconcilers + // before refreshing scripts, so the number of ticks before this point is an implementation + // detail. The assertion below is the real contract: no CLI probe until the refresh resolves. + await vi.waitFor(() => expect(mocks.refreshClaude).toHaveBeenCalled()) expect(mocks.detect).not.toHaveBeenCalled() releaseRefresh?.() @@ -206,6 +219,29 @@ describe('managed agent hook controls', () => { expect(mocks.installCodex).toHaveBeenCalledTimes(1) }) + it('does not finish a startup install after shutdown begins', async () => { + let releaseDetection: ((value: Record) => void) | undefined + mocks.detect.mockImplementation( + () => + new Promise((resolve) => { + releaseDetection = resolve + }) + ) + const settings = { agentStatusHooksEnabled: true, disabledTuiAgents: [] } + let isQuitting = false + + const install = installManagedAgentHooks(settings, { + shouldContinue: (agent) => shouldContinueManagedHookStartup(isQuitting, settings, agent) + }) + await vi.waitFor(() => expect(mocks.detect).toHaveBeenCalledTimes(1)) + isQuitting = true + releaseDetection?.({ claude: { state: 'found' }, codex: { state: 'found' } }) + await install + + expect(mocks.installClaude).not.toHaveBeenCalled() + expect(mocks.installCodex).not.toHaveBeenCalled() + }) + it('does not remove an agent enabled by a newer settings update', async () => { mocks.detect.mockResolvedValue({ codex: { state: 'found' } }) @@ -228,4 +264,12 @@ describe('managed agent hook controls', () => { expect(mocks.removeClaude).toHaveBeenCalledTimes(1) expect(mocks.removeCodex).toHaveBeenCalledTimes(1) }) + + it('awaits only the selected asynchronous removers during quit', async () => { + const results = await removeManagedAgentHooksAsync({ agents: ['codex'] }) + + expect(mocks.removeClaudeAsync).not.toHaveBeenCalled() + expect(mocks.removeCodexAsync).toHaveBeenCalledTimes(1) + expect(results).toEqual([expect.objectContaining({ agent: 'codex', state: 'not_installed' })]) + }) }) diff --git a/src/main/agent-hooks/managed-agent-hook-controls.ts b/src/main/agent-hooks/managed-agent-hook-controls.ts index 6f9f969668c..043cc1bb1ea 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.ts @@ -7,6 +7,7 @@ import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection' import type { GlobalSettings } from '../../shared/global-settings-types' import { detectLocalManagedAgentCliPresence } from './local-agent-cli-presence' import { + MANAGED_AGENT_HOOK_ASYNC_REMOVERS, MANAGED_AGENT_HOOK_INSTALLERS, MANAGED_AGENT_HOOK_REMOVERS, MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS, @@ -18,10 +19,12 @@ export { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-registry' export { prepareManagedCodexHomeBeforeShellLaunch } from '../codex/managed-home-shell-preflight' type ManagedHookSettings = Partial< - Pick + Pick > | null type InstallOptions = { + /** Set only for an explicit user action, never for startup reconciliation. */ + userInitiated?: boolean shouldHydrateShellPath?: boolean onInstallError?: (agent: AgentHookTarget, error: unknown) => void shouldContinue?: (agent: AgentHookTarget) => boolean @@ -38,6 +41,18 @@ export function isAgentStatusHooksEnabled( return settings?.agentStatusHooksEnabled !== false } +export function shouldContinueManagedHookStartup( + isQuitting: boolean, + settings: ManagedHookSettings, + agent: AgentHookTarget +): boolean { + return ( + !isQuitting && + isAgentStatusHooksEnabled(settings) && + !normalizeDisabledTuiAgents(settings?.disabledTuiAgents).includes(agent) + ) +} + function errorStatus(agent: AgentHookTarget, error: unknown): AgentHookInstallStatus { return { agent, @@ -73,11 +88,12 @@ function selectedInstallers(options: InstallOptions): readonly ManagedAgentHookI function runInstaller( entry: ManagedAgentHookInstaller, - onInstallError: InstallOptions['onInstallError'] + onInstallError: InstallOptions['onInstallError'], + userInitiated?: boolean ): AgentHookInstallStatus { const [agent, install] = entry try { - return install() + return install({ userInitiated }) } catch (error) { console.error(`[agent-hooks] Failed to install ${agent} managed hooks:`, error) try { @@ -161,7 +177,7 @@ export async function installManagedAgentHooks( ) continue } - results.push(runInstaller(entry, options.onInstallError)) + results.push(runInstaller(entry, options.onInstallError, options.userInitiated)) } return results } @@ -179,6 +195,23 @@ export function removeManagedAgentHooks(options: RemoveOptions = {}): AgentHookI }) } +export async function removeManagedAgentHooksAsync( + options: RemoveOptions = {} +): Promise { + const allowed = options.agents ? new Set(options.agents) : null + return await Promise.all( + MANAGED_AGENT_HOOK_ASYNC_REMOVERS.filter( + ([agent]) => allowed === null || allowed.has(agent) + ).map(async ([agent, remove]) => { + try { + return await remove() + } catch (error) { + return errorStatus(agent, error) + } + }) + ) +} + export function getManagedAgentHookStatuses(): AgentHookInstallStatus[] { return MANAGED_AGENT_HOOK_STATUS_READERS.map(([agent, getStatus]) => { try { diff --git a/src/main/agent-hooks/managed-agent-hook-registry.ts b/src/main/agent-hooks/managed-agent-hook-registry.ts index 4772a763b6c..c94d367bd79 100644 --- a/src/main/agent-hooks/managed-agent-hook-registry.ts +++ b/src/main/agent-hooks/managed-agent-hook-registry.ts @@ -15,9 +15,17 @@ import { hermesHookService } from '../hermes/hook-service' import { kimiHookService } from '../kimi/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' -export type ManagedAgentHookInstaller = readonly [HookInstallAgent, () => AgentHookInstallStatus] +export type ManagedAgentHookInstallOptions = { userInitiated?: boolean } +export type ManagedAgentHookInstaller = readonly [ + HookInstallAgent, + (options?: ManagedAgentHookInstallOptions) => AgentHookInstallStatus +] export type ManagedAgentHookScriptRefresher = readonly [HookInstallAgent, () => Promise] export type ManagedAgentHookRemover = readonly [HookInstallAgent, () => AgentHookInstallStatus] +export type ManagedAgentHookAsyncRemover = readonly [ + HookInstallAgent, + () => Promise +] export type ManagedAgentHookStatusReader = readonly [HookInstallAgent, () => AgentHookInstallStatus] export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] = [ @@ -30,7 +38,7 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] ['cursor', () => cursorHookService.install()], ['droid', () => droidHookService.install()], ['command-code', () => commandCodeHookService.install()], - ['grok', () => grokHookService.install()], + ['grok', (options) => grokHookService.install(options)], ['copilot', () => copilotHookService.install()], ['hermes', () => hermesHookService.install()], ['devin', () => devinHookService.install()], @@ -75,6 +83,10 @@ export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [ ['kimi', () => kimiHookService.remove()] ] +export const MANAGED_AGENT_HOOK_ASYNC_REMOVERS: readonly ManagedAgentHookAsyncRemover[] = [ + ['grok', () => grokHookService.removeAsync()] +] + export const MANAGED_AGENT_HOOK_STATUS_READERS: readonly ManagedAgentHookStatusReader[] = [ ['claude', () => claudeHookService.getStatus()], ['openclaude', () => openClaudeHookService.getStatus()], diff --git a/src/main/agent-hooks/posix-hook-command.ts b/src/main/agent-hooks/posix-hook-command.ts index 9a5899987d2..728fc30bf18 100644 --- a/src/main/agent-hooks/posix-hook-command.ts +++ b/src/main/agent-hooks/posix-hook-command.ts @@ -9,7 +9,7 @@ export function wrapPosixHookCommand( scriptPath: string, env: Record = {}, // Why: silence is a hard deny on gate events (Antigravity PreToolUse, #2426); those callers need the guard to still answer. - options: { fallbackStdout?: string } = {} + options: { fallbackStdout?: string; requiredEnvVar?: string } = {} ): string { // Why: single-quote escape so $, `, ", \ in scriptPath stay literal — avoids shell injection from an arbitrary path. const quoted = quotePosixShellString(scriptPath) @@ -21,5 +21,16 @@ export function wrapPosixHookCommand( options.fallbackStdout === undefined ? POSIX_HOOK_STDIN_DRAIN_COMMAND : `printf '%s\\n' ${quotePosixShellString(options.fallbackStdout)}; ${POSIX_HOOK_STDIN_DRAIN_COMMAND}` - return `if [ -f ${quoted} ] && [ -r ${quoted} ] && [ -x ${quoted} ]; then ${invocation}; else ${fallback}; fi` + // Why an env guard and not just a file test: the managed script always exists, so without this + // the agent spawns a shell for it on every event and the script only then discovers Orca is not + // listening and exits. The spawn has already happened by that point, which is the whole cost a + // standalone session was paying. `requiredEnvVar` names a variable Orca sets on the panes it + // launches, so a session Orca did not start short-circuits before spawning anything. + const guards = [ + ...(options.requiredEnvVar ? [`[ -n "$${options.requiredEnvVar}" ]`] : []), + `[ -f ${quoted} ]`, + `[ -r ${quoted} ]`, + `[ -x ${quoted} ]` + ].join(' && ') + return `if ${guards}; then ${invocation}; else ${fallback}; fi` } diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index b5ea0583ee1..28d11c73f0f 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -415,7 +415,7 @@ describe('remote hook service installers', () => { const definition = grokConfig.hooks[eventName]?.[0] const command = definition?.hooks?.[0]?.command expect(command).toContain('/home/dev/.orca/agent-hooks/grok-hook.sh') - expect(command).toMatch(/^if \[ -f /) + expect(command).toMatch(/^if \[ -n "\$ORCA_PANE_KEY" \] && /) } // Why: Grok tool matchers are real regexes; bare `*` is invalid match-all. expect(grokConfig.hooks.PreToolUse?.[0]?.matcher).toBe('.*') diff --git a/src/main/grok/grok-hook-config-cleanup.ts b/src/main/grok/grok-hook-config-cleanup.ts new file mode 100644 index 00000000000..2356ed088ff --- /dev/null +++ b/src/main/grok/grok-hook-config-cleanup.ts @@ -0,0 +1,58 @@ +import { + createManagedCommandMatcher, + hookDefinitionHasManagedCommand, + removeManagedCommands, + type HooksConfig +} from '../agent-hooks/installer-utils' + +export type GrokHookConfigCleanup = { + config: HooksConfig + removedAny: boolean +} + +export function removeManagedGrokHookEntries( + config: HooksConfig, + scriptFileName: string +): GrokHookConfigCleanup { + const nextConfig = { ...config } + const nextHooks = { ...config.hooks } + const isManagedCommand = createManagedCommandMatcher(scriptFileName) + let removedAny = false + + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (!Array.isArray(definitions)) { + continue + } + if ( + definitions.some((definition) => + hookDefinitionHasManagedCommand(definition, isManagedCommand) + ) + ) { + removedAny = true + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + + if (!removedAny) { + return { config, removedAny: false } + } + if (Object.keys(nextHooks).length === 0) { + delete nextConfig.hooks + } else { + nextConfig.hooks = nextHooks + } + return { config: nextConfig, removedAny: true } +} + +// Why not "the whole object is empty": orca-status.json is Orca-owned, so once no hook entries +// remain the file has no reason to exist. Keying off total emptiness leaves a stray non-hook key +// (a `$schema`, say) behind, and the user-cleared install guard then reads that remnant as a +// deliberate opt-out and never reinstalls. +export function isOrcaOwnedRemnant(config: HooksConfig): boolean { + return Object.keys(config.hooks ?? {}).length === 0 +} diff --git a/src/main/grok/grok-hook-config-file.test.ts b/src/main/grok/grok-hook-config-file.test.ts new file mode 100644 index 00000000000..446fa427b81 --- /dev/null +++ b/src/main/grok/grok-hook-config-file.test.ts @@ -0,0 +1,207 @@ +import { + chmodSync, + existsSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + removeGrokHookConfigIfUnchanged, + writeGrokHookConfigIfUnchanged +} from './grok-hook-config-file' + +describe('guarded Grok hook config mutation', () => { + const dirs: string[] = [] + + function makeConfigPath(): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-grok-guard-')) + dirs.push(dir) + return join(dir, 'orca-status.json') + } + + afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('does not delete a newer user edit', async () => { + const configPath = makeConfigPath() + const installed = '{"hooks":{"SessionStart":[]}}\n' + const userEdit = + '{"hooks":{"Notification":[{"hooks":[{"type":"command","command":"user"}]}]}}\n' + writeFileSync(configPath, installed) + writeFileSync(configPath, userEdit) + + await expect(removeGrokHookConfigIfUnchanged(configPath, installed)).resolves.toBe(false) + expect(readFileSync(configPath, 'utf8')).toBe(userEdit) + }) + + it('treats a newer user deletion as a generation mismatch', async () => { + const configPath = makeConfigPath() + const installed = '{"hooks":{"SessionStart":[]}}\n' + writeFileSync(configPath, installed) + rmSync(configPath) + + await expect(removeGrokHookConfigIfUnchanged(configPath, installed)).resolves.toBe(false) + }) + + it('does not overwrite a newer user edit', async () => { + const configPath = makeConfigPath() + const installed = '{"hooks":{"SessionStart":[]}}\n' + const userEdit = '{"hooks":{"Notification":[]}}\n' + writeFileSync(configPath, installed) + writeFileSync(configPath, userEdit) + + await expect( + writeGrokHookConfigIfUnchanged(configPath, installed, '{"hooks":{}}\n') + ).resolves.toBe(false) + expect(readFileSync(configPath, 'utf8')).toBe(userEdit) + }) + + it('does not overwrite an edit published after it holds the expected generation', async () => { + const configPath = makeConfigPath() + const installed = '{"hooks":{"SessionStart":[]}}\n' + const userEdit = '{"hooks":{"Notification":[]}}\n' + writeFileSync(configPath, installed) + + await expect( + writeGrokHookConfigIfUnchanged(configPath, installed, '{"hooks":{}}\n', { + shouldCommit: async () => { + writeFileSync(configPath, userEdit) + return true + } + }) + ).resolves.toBe(false) + expect(readFileSync(configPath, 'utf8')).toBe(userEdit) + }) + + it('does not delete an edit published after it holds the expected generation', async () => { + const configPath = makeConfigPath() + const installed = '{"hooks":{"SessionStart":[]}}\n' + const userEdit = '{"hooks":{"Notification":[]}}\n' + writeFileSync(configPath, installed) + + await expect( + removeGrokHookConfigIfUnchanged(configPath, installed, { + shouldCommit: async () => { + writeFileSync(configPath, userEdit) + return true + } + }) + ).resolves.toBe(false) + expect(readFileSync(configPath, 'utf8')).toBe(userEdit) + }) + + it('restores the original config when ownership blocks cleanup', async () => { + const configPath = makeConfigPath() + const installed = '{"hooks":{"SessionStart":[]}}\n' + writeFileSync(configPath, installed) + + await expect( + removeGrokHookConfigIfUnchanged(configPath, installed, { + shouldCommit: async () => false + }) + ).resolves.toBe(false) + expect(readFileSync(configPath, 'utf8')).toBe(installed) + expect(statSync(configPath).nlink).toBe(1) + }) + + it('keeps the live config in place while the ownership probe is pending', async () => { + const configPath = makeConfigPath() + const installed = '{"hooks":{"SessionStart":[]}}\n' + writeFileSync(configPath, installed) + let finishProbe!: (allowed: boolean) => void + const probe = new Promise((resolve) => { + finishProbe = resolve + }) + let probeStarted!: () => void + const started = new Promise((resolve) => { + probeStarted = resolve + }) + + const cleanup = removeGrokHookConfigIfUnchanged(configPath, installed, { + beforeHold: async () => { + probeStarted() + return await probe + } + }) + await started + + expect(readFileSync(configPath, 'utf8')).toBe(installed) + expect(readdirSync(dirname(configPath))).toEqual(['orca-status.json']) + finishProbe(false) + await expect(cleanup).resolves.toBe(false) + }) + + it('preserves the existing file mode on replacement', async () => { + const configPath = makeConfigPath() + const installed = '{"hooks":{"SessionStart":[]}}\n' + writeFileSync(configPath, installed) + chmodSync(configPath, 0o600) + + await expect( + writeGrokHookConfigIfUnchanged(configPath, installed, '{"hooks":{}}\n') + ).resolves.toBe(true) + expect(statSync(configPath).mode & 0o777).toBe(0o600) + }) + + // Why this is pinned: Grok stats every global hook JSON and refuses to build a sandbox profile + // for one whose st_nlink != 1, failing the whole session. A publish-by-hard-link swap leaves the + // config at nlink 2 for the width of the write, so any Grok session starting in that window dies. + it('never publishes the config through a second hard link', async () => { + const configPath = makeConfigPath() + const installed = '{"hooks":{"SessionStart":[]}}\n' + writeFileSync(configPath, installed) + + const linkCounts: number[] = [] + const sampler = setInterval(() => { + try { + linkCounts.push(statSync(configPath).nlink) + } catch { + /* absent mid-swap is fine; only a second link is not */ + } + }, 0) + try { + for (let i = 0; i < 40; i += 1) { + const next = `{"hooks":{"SessionStart":[],"n":${i}}}\n` + await expect( + writeGrokHookConfigIfUnchanged(configPath, readFileSync(configPath, 'utf8'), next) + ).resolves.toBe(true) + } + } finally { + clearInterval(sampler) + } + + expect(linkCounts.length).toBeGreaterThan(0) + expect(Math.max(...linkCounts)).toBe(1) + expect(statSync(configPath).nlink).toBe(1) + }) + + it('removes the config and reports the removal', async () => { + const configPath = makeConfigPath() + const installed = '{"hooks":{"SessionStart":[]}}\n' + writeFileSync(configPath, installed) + + await expect(removeGrokHookConfigIfUnchanged(configPath, installed)).resolves.toBe(true) + expect(existsSync(configPath)).toBe(false) + }) + + it('leaves no temp file behind after a successful write', async () => { + const configPath = makeConfigPath() + const dir = dirname(configPath) + const installed = '{"hooks":{"SessionStart":[]}}\n' + writeFileSync(configPath, installed) + + await expect( + writeGrokHookConfigIfUnchanged(configPath, installed, '{"hooks":{}}\n') + ).resolves.toBe(true) + expect(readdirSync(dir)).toEqual(['orca-status.json']) + }) +}) diff --git a/src/main/grok/grok-hook-config-file.ts b/src/main/grok/grok-hook-config-file.ts new file mode 100644 index 00000000000..b9a51f8836f --- /dev/null +++ b/src/main/grok/grok-hook-config-file.ts @@ -0,0 +1,156 @@ +import { randomUUID } from 'node:crypto' +import { constants } from 'node:fs' +import { copyFile, lstat, open, readFile, realpath, rename, rm, stat } from 'node:fs/promises' +import { parseHooksJsonText } from '../agent-hooks/hooks-json-read' +import type { HooksConfig } from '../agent-hooks/installer-utils' + +export type AsyncGrokHookConfigSnapshot = { + raw: string | null + config: HooksConfig | null +} + +export async function readGrokHookConfigSnapshot( + targetPath: string +): Promise { + const raw = await readFileOrNull(targetPath) + return raw === null ? { raw: null, config: {} } : { raw, config: parseHooksJsonText(raw) } +} + +export async function writeGrokHookConfigIfUnchanged( + targetPath: string, + expectedContents: string, + contents: string, + options?: GrokHookConfigMutationOptions +): Promise { + return await mutateGrokHookConfigIfUnchanged(targetPath, expectedContents, contents, options) +} + +/** + * Removes the managed config. Returns false when the caller must write through instead: a config + * the user has symlinked belongs to them, so we strip our entries rather than unlink their link. + */ +export async function removeGrokHookConfigIfUnchanged( + targetPath: string, + expectedContents: string, + options?: GrokHookConfigMutationOptions +): Promise { + return await mutateGrokHookConfigIfUnchanged(targetPath, expectedContents, null, options) +} + +/** True when the config is a symlink, so it must be written through and never unlinked. */ +export async function isGrokHookConfigSymlink(targetPath: string): Promise { + try { + return (await lstat(targetPath)).isSymbolicLink() + } catch { + return false + } +} + +async function resolveWriteTarget(targetPath: string): Promise { + return (await isGrokHookConfigSymlink(targetPath)) ? await realpath(targetPath) : targetPath +} + +async function mutateGrokHookConfigIfUnchanged( + targetPath: string, + expectedContents: string, + contents: string | null, + options?: GrokHookConfigMutationOptions +): Promise { + if (options?.beforeHold && !(await options.beforeHold())) { + return false + } + // Why resolve first: moving the link path would detach a config kept in the user's dotfiles. + const writePath = await resolveWriteTarget(targetPath) + const heldPath = `${writePath}.${process.pid}.${randomUUID()}.held` + try { + await rename(writePath, heldPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false + } + throw error + } + + let restoreOnFailure = true + try { + if ((await readFileOrNull(heldPath)) !== expectedContents) { + restoreOnFailure = false + await restoreHeldFile(heldPath, writePath) + return false + } + if (options?.shouldCommit && !(await options.shouldCommit())) { + restoreOnFailure = false + await restoreHeldFile(heldPath, writePath) + return false + } + if ((await readFileOrNull(writePath)) !== null) { + // A concurrent writer published a newer generation while the old one was held. + restoreOnFailure = false + await rm(heldPath, { force: true }) + return false + } + if (contents === null) { + restoreOnFailure = false + await rm(heldPath, { force: true }) + return true + } + + const mode = (await stat(heldPath)).mode + let handle + try { + handle = await open(writePath, 'wx', mode) + await handle.writeFile(contents, 'utf8') + await handle.chmod(mode) + await handle.sync() + } catch (error) { + await handle?.close().catch(() => {}) + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + await rm(heldPath, { force: true }) + return false + } + await rm(writePath, { force: true }) + throw error + } + await handle.close() + restoreOnFailure = false + await rm(heldPath, { force: true }) + return true + } catch (error) { + if (restoreOnFailure) { + await restoreHeldFile(heldPath, writePath) + } + throw error + } +} + +async function restoreHeldFile(heldPath: string, targetPath: string): Promise { + try { + // Why exclusive copy: it never overwrites a concurrent writer and works on Windows/network + // filesystems that do not support hard links. Keep heldPath until the complete copy succeeds. + await copyFile(heldPath, targetPath, constants.COPYFILE_EXCL) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + // Preserve the held file as the only complete generation when restoration itself fails. + throw error + } + } + await rm(heldPath, { force: true }) +} + +type GrokHookConfigMutationOptions = { + /** Slow ownership work runs before the live config is moved out of place. */ + beforeHold?: () => Promise + /** Fast generation check that closes the gap after ownership was released. */ + shouldCommit?: () => Promise +} + +async function readFileOrNull(targetPath: string): Promise { + try { + return await readFile(targetPath, 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null + } + throw error + } +} diff --git a/src/main/grok/grok-hook-config.ts b/src/main/grok/grok-hook-config.ts new file mode 100644 index 00000000000..52ee2ecfdb2 --- /dev/null +++ b/src/main/grok/grok-hook-config.ts @@ -0,0 +1,66 @@ +import { + buildManagedCommandHook, + createManagedCommandMatcher, + removeManagedCommands, + type HookDefinition, + type HooksConfig +} from '../agent-hooks/installer-utils' + +export const GROK_TOOL_EVENT_MATCHER = '.*' + +export const GROK_EVENTS = [ + { eventName: 'SessionStart', definition: { hooks: [{ type: 'command', command: '' }] } }, + { eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } }, + { eventName: 'Stop', definition: { hooks: [{ type: 'command', command: '' }] } }, + { eventName: 'StopFailure', definition: { hooks: [{ type: 'command', command: '' }] } }, + { eventName: 'SessionEnd', definition: { hooks: [{ type: 'command', command: '' }] } }, + { + // Why: Orca needs the pre-event to show in-flight tools and detect ask_user_question waits; + // PostToolUse arrives only after both states have ended. + eventName: 'PreToolUse', + definition: { matcher: GROK_TOOL_EVENT_MATCHER, hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'PostToolUse', + definition: { matcher: GROK_TOOL_EVENT_MATCHER, hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'PostToolUseFailure', + definition: { matcher: GROK_TOOL_EVENT_MATCHER, hooks: [{ type: 'command', command: '' }] } + }, + { eventName: 'Notification', definition: { hooks: [{ type: 'command', command: '' }] } } +] as const + +export function buildInstalledGrokConfig( + config: HooksConfig, + command: string, + scriptFileName: string +): void { + const nextHooks = { ...config.hooks } + const isManagedCommand = createManagedCommandMatcher(scriptFileName) + const managedEvents = new Set(GROK_EVENTS.map((event) => event.eventName)) + + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (managedEvents.has(eventName) || !Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + + for (const event of GROK_EVENTS) { + const current = Array.isArray(nextHooks[event.eventName]) ? nextHooks[event.eventName] : [] + const cleaned = removeManagedCommands(current, isManagedCommand) + const definition: HookDefinition = { + ...event.definition, + hooks: [buildManagedCommandHook(command)] + } + nextHooks[event.eventName] = [...cleaned, definition] + } + + config.hooks = nextHooks +} diff --git a/src/main/grok/grok-hook-owners.test.ts b/src/main/grok/grok-hook-owners.test.ts new file mode 100644 index 00000000000..a36c02ca5b1 --- /dev/null +++ b/src/main/grok/grok-hook-owners.test.ts @@ -0,0 +1,64 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { hasLivePeer, hasRegisteredGrokHookOwner } from './grok-hook-owners' + +describe('Grok hook owners', () => { + const directories: string[] = [] + + async function makeDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'orca-grok-owners-')) + directories.push(directory) + return directory + } + + afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))) + }) + + it('keeps the shared hook while another Orca process is live', async () => { + const directory = await makeDirectory() + const peer = { token: '11111111-1111-4111-8111-111111111111', pid: 42 } + await writeFile(join(directory, `owner-${peer.token}.json`), JSON.stringify(peer)) + + await expect(hasLivePeer(directory, 'self', vi.fn().mockResolvedValue(true))).resolves.toBe( + true + ) + }) + + it('prunes dead and malformed owners before allowing cleanup', async () => { + const directory = await makeDirectory() + const dead = { token: '22222222-2222-4222-8222-222222222222', pid: 43 } + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, `owner-${dead.token}.json`), JSON.stringify(dead)) + await writeFile(join(directory, 'owner-33333333-3333-4333-8333-333333333333.json'), 'not json') + + await expect(hasLivePeer(directory, 'self', vi.fn().mockResolvedValue(false))).resolves.toBe( + false + ) + await expect( + readFile(join(directory, `owner-${dead.token}.json`), 'utf8') + ).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('preserves the hook when peer liveness is unverifiable', async () => { + const directory = await makeDirectory() + const peer = { token: '44444444-4444-4444-8444-444444444444', pid: 44 } + await writeFile(join(directory, `owner-${peer.token}.json`), JSON.stringify(peer)) + + await expect( + hasLivePeer(directory, 'self', vi.fn().mockResolvedValue(undefined)) + ).resolves.toBe(true) + }) + + it('detects a new owner record without another liveness probe', async () => { + const directory = await makeDirectory() + const peer = { token: '55555555-5555-4555-8555-555555555555', pid: 45 } + await writeFile(join(directory, `owner-${peer.token}.json`), JSON.stringify(peer)) + + await expect(hasRegisteredGrokHookOwner(directory)).resolves.toBe(true) + }) +}) diff --git a/src/main/grok/grok-hook-owners.ts b/src/main/grok/grok-hook-owners.ts new file mode 100644 index 00000000000..68509c02a12 --- /dev/null +++ b/src/main/grok/grok-hook-owners.ts @@ -0,0 +1,113 @@ +import { randomUUID } from 'node:crypto' +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { readdir, readFile, rm } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' + +const ownerToken = randomUUID() +const OWNER_FILE_PATTERN = /^owner-([\da-f-]{36})\.json$/i + +type GrokHookOwner = { token: string; pid: number } + +function ownerDirectory(): string { + return join(homedir(), '.orca', 'agent-hooks', 'grok-owners') +} + +function ownerPath(directory = ownerDirectory()): string { + return join(directory, `owner-${ownerToken}.json`) +} + +export function registerGrokHookOwner(): void { + const directory = ownerDirectory() + mkdirSync(directory, { recursive: true }) + try { + writeFileSync( + ownerPath(directory), + `${JSON.stringify({ token: ownerToken, pid: process.pid } satisfies GrokHookOwner)}\n`, + { encoding: 'utf8', flag: 'wx', mode: 0o600 } + ) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + throw error + } + } +} + +export function unregisterGrokHookOwnerSync(): void { + rmSync(ownerPath(), { force: true }) +} + +export async function releaseGrokHookOwnerAndCheckForPeers(): Promise { + const directory = ownerDirectory() + await rm(ownerPath(directory), { force: true }) + return await hasLivePeer(directory, ownerToken, probeProcess) +} + +/** Fast second check after liveness pruning closes the registration race before cleanup commits. */ +export async function hasRegisteredGrokHookOwner(directory = ownerDirectory()): Promise { + try { + return (await readdir(directory)).some((entry) => OWNER_FILE_PATTERN.test(entry)) + } catch (error) { + // Why: unverifiable ownership must preserve the shared hook, never delete it. + return (error as NodeJS.ErrnoException).code !== 'ENOENT' + } +} + +export async function hasLivePeer( + directory: string, + currentToken: string, + probe: (pid: number) => Promise +): Promise { + let entries: string[] + try { + entries = await readdir(directory) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false + } + // Why: unverifiable ownership must preserve the shared hook, never delete it. + return true + } + + for (const entry of entries) { + const token = OWNER_FILE_PATTERN.exec(entry)?.[1] + if (!token || token === currentToken) { + continue + } + const path = join(directory, entry) + let owner: GrokHookOwner | null = null + try { + const candidate = JSON.parse(await readFile(path, 'utf8')) as Partial + if ( + candidate.token === token && + Number.isSafeInteger(candidate.pid) && + (candidate.pid ?? 0) > 0 + ) { + owner = candidate as GrokHookOwner + } + } catch { + // Invalid Orca-owned records cannot prove a live peer and are pruned below. + } + if (!owner) { + await rm(path, { force: true }) + continue + } + const live = await probe(owner.pid) + if (live === false) { + await rm(path, { force: true }) + continue + } + // true proves liveness; undefined means the platform could not prove death. + return true + } + return false +} + +async function probeProcess(pid: number): Promise { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'ESRCH' ? false : undefined + } +} diff --git a/src/main/grok/grok-hook-remnant-removal.test.ts b/src/main/grok/grok-hook-remnant-removal.test.ts new file mode 100644 index 00000000000..037d8969c60 --- /dev/null +++ b/src/main/grok/grok-hook-remnant-removal.test.ts @@ -0,0 +1,60 @@ +import { existsSync, 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' + +const { homedirMock } = vi.hoisted(() => ({ homedirMock: vi.fn<() => string>() })) +vi.mock('os', async () => { + const actual = (await vi.importActual('os')) as Record + return { ...actual, homedir: homedirMock } +}) + +import { GrokHookService } from './hook-service' +// Why one file covers both local removal paths: nothing exercised this rule more than once. Removal +// used to unlink only when the WHOLE config object was empty, but it strips just the `hooks` key -- +// so any other top-level key left a remnant, and the user-cleared install guard then read that +// remnant as a deliberate opt-out and never reinstalled. Silently. Forever. +const SCHEMA = 'https://grok.example/hooks.schema.json' + +describe('Grok hook removal leaves no remnant that blocks reinstall', () => { + let homeDir: string + let configPath: string + + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), 'orca-grok-remnant-')) + homedirMock.mockReturnValue(homeDir) + configPath = join(homeDir, '.grok', 'hooks', 'orca-status.json') + }) + + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + function installThenAddSchema(service: GrokHookService): void { + expect(service.install().state).toBe('installed') + const config = JSON.parse(readFileSync(configPath, 'utf8')) as Record + config.$schema = SCHEMA + writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8') + } + + it('removes it on the sync path', () => { + const service = new GrokHookService() + installThenAddSchema(service) + + expect(service.remove().state).toBe('not_installed') + + expect(existsSync(configPath)).toBe(false) + expect(service.install().state).toBe('installed') + }) + + it('removes it on the quit path', async () => { + const service = new GrokHookService() + installThenAddSchema(service) + + await service.removeAsync() + + expect(existsSync(configPath)).toBe(false) + expect(service.install().state).toBe('installed') + }) +}) diff --git a/src/main/grok/grok-hook-remote-install.ts b/src/main/grok/grok-hook-remote-install.ts new file mode 100644 index 00000000000..b67a8597a3a --- /dev/null +++ b/src/main/grok/grok-hook-remote-install.ts @@ -0,0 +1,73 @@ +import type { SFTPWrapper } from 'ssh2' + +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { wrapPosixHookCommand } from '../agent-hooks/installer-utils' +import { + readHooksJsonRemote, + writeHooksJsonRemote, + writeManagedScriptRemote +} from '../agent-hooks/installer-utils-remote' +import { buildInstalledGrokConfig } from './grok-hook-config' +import { GROK_HOME_ENVELOPE_MAX_LENGTH } from './windows-grok-hook-script' + +function status(configPath: string, detail: string | null = null): AgentHookInstallStatus { + return { + agent: 'grok', + state: detail ? 'error' : 'not_installed', + configPath, + managedHooksPresent: false, + detail + } +} + +function remoteGrokHome(remoteHome: string, remoteGrokHome?: string): string { + const home = remoteHome.replace(/\/+$/, '') || remoteHome + const candidate = remoteGrokHome?.trim() + if ( + candidate && + candidate === remoteGrokHome && + candidate.startsWith('/') && + !candidate.includes('\\') && + candidate.length <= GROK_HOME_ENVELOPE_MAX_LENGTH && + !Array.from(candidate).some((character) => { + const code = character.charCodeAt(0) + return code <= 0x1f || code === 0x7f + }) + ) { + return candidate.replace(/\/+$/, '') || '/' + } + return `${home}/.grok` +} + +export async function installRemoteGrokHook( + sftp: SFTPWrapper, + remoteHome: string, + remoteGrokHomeDir: string | undefined, + script: string +): Promise { + const home = remoteHome.replace(/\/$/, '') + const configPath = `${remoteGrokHome(home, remoteGrokHomeDir)}/hooks/orca-status.json` + const scriptPath = `${home}/.orca/agent-hooks/grok-hook.sh` + try { + const config = await readHooksJsonRemote(sftp, configPath) + if (!config) { + return status(configPath, 'Could not parse remote Grok hook config') + } + buildInstalledGrokConfig( + config, + wrapPosixHookCommand(scriptPath, {}, { requiredEnvVar: 'ORCA_PANE_KEY' }), + 'grok-hook.sh' + ) + await writeManagedScriptRemote(sftp, scriptPath, script) + await writeHooksJsonRemote(sftp, configPath, config) + return { + agent: 'grok', + state: 'installed', + configPath, + managedHooksPresent: true, + detail: null + } + } catch (error) { + return status(configPath, error instanceof Error ? error.message : String(error)) + } +} diff --git a/src/main/grok/grok-hook-script.ts b/src/main/grok/grok-hook-script.ts new file mode 100644 index 00000000000..cf4c57580b9 --- /dev/null +++ b/src/main/grok/grok-hook-script.ts @@ -0,0 +1,61 @@ +import { + getSharedManagedScriptPath, + wrapPosixHookCommand, + wrapWindowsCmdHookCommand +} from '../agent-hooks/installer-utils' +import { buildPosixHookPayloadCapture } from '../agent-hooks/hook-stdin-contract' +import { + buildWindowsGrokHookScript, + GROK_HOME_ENVELOPE_MAX_LENGTH +} from './windows-grok-hook-script' + +export function getGrokManagedScriptFileName(): string { + return process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh' +} + +export function getGrokManagedScriptPath(): string { + return getSharedManagedScriptPath(getGrokManagedScriptFileName()) +} + +export function getGrokManagedCommand(scriptPath: string): string { + // A cmd-safe bare path avoids two extra Windows interpreters; POSIX can gate before spawning the + // managed script because Orca injects ORCA_PANE_KEY only into panes it owns. + return process.platform === 'win32' + ? wrapWindowsCmdHookCommand(scriptPath) + : wrapPosixHookCommand(scriptPath, {}, { requiredEnvVar: 'ORCA_PANE_KEY' }) +} + +export function getGrokManagedScript(target: 'local' | 'posix' = 'local'): string { + if (target === 'local' && process.platform === 'win32') { + return buildWindowsGrokHookScript() + } + + return [ + '#!/bin/sh', + ...buildPosixHookPayloadCapture(), + 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', + ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', + 'fi', + 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' exit 0', + 'fi', + 'grok_home=', + `if [ -n "\${GROK_HOME:-}" ] && [ "\${#GROK_HOME}" -le ${GROK_HOME_ENVELOPE_MAX_LENGTH} ]; then`, + ' grok_home=$GROK_HOME', + 'fi', + 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/grok" \\', + ' --connect-timeout 0.5 --max-time 1.5 \\', + ' -H "Content-Type: application/x-www-form-urlencoded" \\', + ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', + ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', + ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', + ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', + ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', + ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', + ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', + ' --data-urlencode "grokHome=${grok_home}" \\', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + 'exit 0', + '' + ].join('\n') +} diff --git a/src/main/grok/grok-hook-symlink-cleanup-marker.ts b/src/main/grok/grok-hook-symlink-cleanup-marker.ts new file mode 100644 index 00000000000..f1487c3d0b6 --- /dev/null +++ b/src/main/grok/grok-hook-symlink-cleanup-marker.ts @@ -0,0 +1,58 @@ +import { createHash } from 'node:crypto' +import { readFileSync, realpathSync, rmSync, statSync } from 'node:fs' +import { realpath, stat, writeFile } from 'node:fs/promises' + +type SymlinkCleanupMarker = { + realPath: string + mtimeMs: number + size: number + contentsHash: string +} + +function markerPath(configPath: string): string { + return `${configPath}.orca-cleaned-symlink` +} + +function hash(contents: string): string { + return createHash('sha256').update(contents).digest('hex') +} + +export async function recordGrokSymlinkCleanup( + configPath: string, + contents: string +): Promise { + const realPath = await realpath(configPath) + const stats = await stat(realPath) + const marker: SymlinkCleanupMarker = { + realPath, + mtimeMs: stats.mtimeMs, + size: stats.size, + contentsHash: hash(contents) + } + await writeFile(markerPath(configPath), `${JSON.stringify(marker)}\n`, { + encoding: 'utf8', + mode: 0o600 + }) +} + +export function matchesRecordedGrokSymlinkCleanup(configPath: string, contents: string): boolean { + try { + const marker = JSON.parse( + readFileSync(markerPath(configPath), 'utf8') + ) as Partial + const realPath = realpathSync(configPath) + const stats = statSync(realPath) + return ( + marker.realPath === realPath && + marker.mtimeMs === stats.mtimeMs && + marker.size === stats.size && + marker.contentsHash === hash(contents) + ) + } catch { + return false + } +} + +export function clearGrokSymlinkCleanupMarker(configPath: string): void { + rmSync(markerPath(configPath), { force: true }) +} diff --git a/src/main/grok/hook-service.test.ts b/src/main/grok/hook-service.test.ts index 57e6da47368..89bd53f22c0 100644 --- a/src/main/grok/hook-service.test.ts +++ b/src/main/grok/hook-service.test.ts @@ -1,6 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { spawn } from 'node:child_process' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' import { createServer } from 'node:http' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -235,9 +245,9 @@ describe('GrokHookService', () => { expect(Object.keys(config.hooks).sort()).toEqual( [ 'Notification', + 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', - 'PreToolUse', 'SessionEnd', 'SessionStart', 'Stop', @@ -245,8 +255,11 @@ describe('GrokHookService', () => { 'UserPromptSubmit' ].sort() ) - // Why: Grok matchers are real regexes; bare `*` does not match-all. + // Why: PreToolUse drives in-flight tool state and ask_user_question waits. The pane guard below + // keeps it inert outside Orca; PostToolUse cannot replace a state that has already ended. expect(config.hooks.PreToolUse[0].matcher).toBe('.*') + expect(registersManagedGrokScript(config.hooks.PreToolUse[0].hooks[0].command)).toBe(true) + // Why: Grok matchers are real regexes; bare `*` does not match-all. expect(config.hooks.PostToolUseFailure[0].matcher).toBe('.*') expect(config.hooks.PostToolUse[0].matcher).toBe('.*') // Why: StopFailure must not carry a tool matcher — lifecycle-only event. @@ -260,9 +273,13 @@ describe('GrokHookService', () => { // Why: build the invalid pattern at runtime so static lint does not flag it. const bareStar = ['*', ''].join('') expect(() => new RegExp(bareStar)).toThrow() - expect(registersManagedGrokScript(config.hooks.PreToolUse[0].hooks[0].command)).toBe(true) + expect(registersManagedGrokScript(config.hooks.PostToolUse[0].hooks[0].command)).toBe(true) if (process.platform !== 'win32') { - expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca')) + const command = config.hooks.PostToolUse[0].hooks[0].command + expect(command).toContain(join(homeDir, '.orca')) + // Why: with no Orca pane in the environment the guard short-circuits, so a standalone Grok + // session never spawns a shell for the managed script at all. + expect(command).toMatch(/^if \[ -n "\$ORCA_PANE_KEY" \] && /) } const script = readFileSync( @@ -340,6 +357,162 @@ describe('GrokHookService', () => { } }) + it('does not restore a user-cleared global hook config on the next launch', () => { + const service = new GrokHookService() + const configPath = join(homeDir, '.grok', 'hooks', 'orca-status.json') + const userClearedConfig = '{ "hooks" : {} }\n' + + expect(service.install().state).toBe('installed') + writeFileSync(configPath, userClearedConfig, 'utf8') + + service.install() + + expect(readFileSync(configPath, 'utf8')).toBe(userClearedConfig) + }) + + it('preserves a user-cleared config during session cleanup', () => { + const service = new GrokHookService() + const configPath = join(homeDir, '.grok', 'hooks', 'orca-status.json') + const userClearedConfig = '{ "hooks" : {} }\n' + + expect(service.install().state).toBe('installed') + writeFileSync(configPath, userClearedConfig, 'utf8') + + expect(service.remove().state).toBe('not_installed') + expect(readFileSync(configPath, 'utf8')).toBe(userClearedConfig) + }) + + it('removes the empty managed config when hooks are disabled', () => { + const service = new GrokHookService() + const configPath = join(homeDir, '.grok', 'hooks', 'orca-status.json') + + service.install() + expect(service.remove().state).toBe('not_installed') + expect(() => readFileSync(configPath, 'utf8')).toThrow() + }) + + // Why: remove() used to unlink only when the whole config object was empty, so any non-hook key + // left a remnant behind — and install()'s user-cleared guard then read that remnant as a + // deliberate opt-out and never reinstalled again. + it('removes the managed config even when a non-hook key remains', () => { + const service = new GrokHookService() + const configPath = join(homeDir, '.grok', 'hooks', 'orca-status.json') + + expect(service.install().state).toBe('installed') + const withSchema = JSON.parse(readFileSync(configPath, 'utf8')) as Record + withSchema.$schema = 'https://grok.example/hooks.schema.json' + writeFileSync(configPath, `${JSON.stringify(withSchema, null, 2)}\n`, 'utf8') + + expect(service.remove().state).toBe('not_installed') + + expect(existsSync(configPath)).toBe(false) + expect(service.install().state).toBe('installed') + }) + + // Why: the reported workaround for #15518 was emptying this file by hand, so Orca must not + // rewrite it on startup. But then turning the setting back on has to work, or the toggle + // silently lies and the only way back is deleting a file in a hidden directory. + it('leaves a user-cleared config alone on a startup install', () => { + const service = new GrokHookService() + const configPath = join(homeDir, '.grok', 'hooks', 'orca-status.json') + const userCleared = '{ "hooks" : {} }\n' + + expect(service.install().state).toBe('installed') + writeFileSync(configPath, userCleared, 'utf8') + + service.install() + + expect(readFileSync(configPath, 'utf8')).toBe(userCleared) + }) + + it('restores a user-cleared config when the user turns hooks back on', () => { + const service = new GrokHookService() + const configPath = join(homeDir, '.grok', 'hooks', 'orca-status.json') + const userCleared = '{ "hooks" : {} }\n' + + expect(service.install().state).toBe('installed') + writeFileSync(configPath, userCleared, 'utf8') + + expect(service.install({ userInitiated: true }).state).toBe('installed') + + expect(readFileSync(configPath, 'utf8')).not.toBe(userCleared) + expect(service.getStatus().managedHooksPresent).toBe(true) + }) + + // Why: hook-config-write-path.ts exists because users symlink these configs into dotfiles repos. + // Unlinking or renaming onto the link path destroys that link and silently detaches the file they + // version-control. Their file is theirs -- strip our entries and write through it. + it('writes through a symlinked config instead of destroying the link', async () => { + const service = new GrokHookService() + const configPath = join(homeDir, '.grok', 'hooks', 'orca-status.json') + const realConfig = join(homeDir, 'dotfiles', 'orca-status.json') + + expect(service.install().state).toBe('installed') + mkdirSync(dirname(realConfig), { recursive: true }) + renameSync(configPath, realConfig) + symlinkSync(realConfig, configPath) + + await service.removeAsync() + + expect(lstatSync(configPath).isSymbolicLink()).toBe(true) + expect(existsSync(realConfig)).toBe(true) + expect(service.getStatus().managedHooksPresent).toBe(false) + }) + + it('respects an empty user-managed symlink as an opt-out', () => { + const service = new GrokHookService() + const configPath = join(homeDir, '.grok', 'hooks', 'orca-status.json') + const realConfig = join(homeDir, 'dotfiles', 'orca-status.json') + const userCleared = '{"hooks":{}}\n' + mkdirSync(dirname(configPath), { recursive: true }) + mkdirSync(dirname(realConfig), { recursive: true }) + writeFileSync(realConfig, userCleared) + symlinkSync(realConfig, configPath) + + expect(service.install().state).toBe('not_installed') + expect(readFileSync(realConfig, 'utf8')).toBe(userCleared) + expect(lstatSync(configPath).isSymbolicLink()).toBe(true) + }) + + it('writes through a symlinked config on the sync remove path too', () => { + const service = new GrokHookService() + const configPath = join(homeDir, '.grok', 'hooks', 'orca-status.json') + const realConfig = join(homeDir, 'dotfiles', 'orca-status.json') + + expect(service.install().state).toBe('installed') + mkdirSync(dirname(realConfig), { recursive: true }) + renameSync(configPath, realConfig) + symlinkSync(realConfig, configPath) + + expect(service.remove().state).toBe('not_installed') + + expect(lstatSync(configPath).isSymbolicLink()).toBe(true) + expect(existsSync(realConfig)).toBe(true) + expect(service.getStatus().managedHooksPresent).toBe(false) + }) + + // Why: a symlinked config is written THROUGH on removal rather than unlinked, so after a quit it + // is an empty file WE emptied -- byte-identical to one a user cleared. Letting the user-cleared + // heuristic see it meant a symlinked config was silently never reinstalled after the first quit. + it('reinstalls into a symlinked config on the launch after a quit', async () => { + const service = new GrokHookService() + const configPath = join(homeDir, '.grok', 'hooks', 'orca-status.json') + const realConfig = join(homeDir, 'dotfiles', 'orca-status.json') + + expect(service.install().state).toBe('installed') + mkdirSync(dirname(realConfig), { recursive: true }) + renameSync(configPath, realConfig) + symlinkSync(realConfig, configPath) + await service.removeAsync() + expect(service.getStatus().managedHooksPresent).toBe(false) + + expect(new GrokHookService().install().state).toBe('installed') + + expect(lstatSync(configPath).isSymbolicLink()).toBe(true) + expect(existsSync(realConfig)).toBe(true) + expect(service.getStatus().managedHooksPresent).toBe(true) + }) + it('preserves user-authored hook entries in the Orca Grok config file', () => { const configPath = join(homeDir, '.grok', 'hooks', 'orca-status.json') mkdirSync(dirname(configPath), { recursive: true }) diff --git a/src/main/grok/hook-service.ts b/src/main/grok/hook-service.ts index 41e1550fe28..f6ccd9fccaa 100644 --- a/src/main/grok/hook-service.ts +++ b/src/main/grok/hook-service.ts @@ -1,59 +1,41 @@ -import { join } from 'node:path' +import { lstatSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { dirname, join } from 'node:path' import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' import { resolveGrokHomeDir } from '../../shared/grok-session-paths' import { - buildManagedCommandHook, - createManagedCommandMatcher, - getSharedManagedScriptPath, readHooksJson, - removeManagedCommands, - wrapPosixHookCommand, - wrapWindowsCmdHookCommand, + readHooksJsonWithRaw, writeHooksJson, - writeManagedScript, - type HookDefinition + writeManagedScript } from '../agent-hooks/installer-utils' import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' +import { isOrcaOwnedRemnant, removeManagedGrokHookEntries } from './grok-hook-config-cleanup' +import { buildInstalledGrokConfig, GROK_EVENTS, GROK_TOOL_EVENT_MATCHER } from './grok-hook-config' +import { installRemoteGrokHook } from './grok-hook-remote-install' import { - readHooksJsonRemote, - writeHooksJsonRemote, - writeManagedScriptRemote -} from '../agent-hooks/installer-utils-remote' -import { buildPosixHookPayloadCapture } from '../agent-hooks/hook-stdin-contract' + getGrokManagedCommand, + getGrokManagedScript, + getGrokManagedScriptFileName, + getGrokManagedScriptPath +} from './grok-hook-script' import { - buildWindowsGrokHookScript, - GROK_HOME_ENVELOPE_MAX_LENGTH -} from './windows-grok-hook-script' - -// Why: Grok's tool-event matcher is a real regex (see Grok hooks docs). Bare -// `*` is not a valid "match all" pattern and can fail to load/match, so tool -// lifecycle hooks never fire. `.*` matches every tool name (same as Command -// Code's managed hooks). -const GROK_TOOL_EVENT_MATCHER = '.*' - -const GROK_EVENTS = [ - { eventName: 'SessionStart', definition: { hooks: [{ type: 'command', command: '' }] } }, - { eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } }, - { eventName: 'Stop', definition: { hooks: [{ type: 'command', command: '' }] } }, - // Why: Grok can end a turn on API error without a normal Stop; without this - // the sidebar can stick on working (same rationale as Claude StopFailure). - { eventName: 'StopFailure', definition: { hooks: [{ type: 'command', command: '' }] } }, - { eventName: 'SessionEnd', definition: { hooks: [{ type: 'command', command: '' }] } }, - { - eventName: 'PreToolUse', - definition: { matcher: GROK_TOOL_EVENT_MATCHER, hooks: [{ type: 'command', command: '' }] } - }, - { - eventName: 'PostToolUse', - definition: { matcher: GROK_TOOL_EVENT_MATCHER, hooks: [{ type: 'command', command: '' }] } - }, - { - eventName: 'PostToolUseFailure', - definition: { matcher: GROK_TOOL_EVENT_MATCHER, hooks: [{ type: 'command', command: '' }] } - }, - { eventName: 'Notification', definition: { hooks: [{ type: 'command', command: '' }] } } -] as const + isGrokHookConfigSymlink, + readGrokHookConfigSnapshot, + removeGrokHookConfigIfUnchanged, + writeGrokHookConfigIfUnchanged +} from './grok-hook-config-file' +import { + hasRegisteredGrokHookOwner, + registerGrokHookOwner, + releaseGrokHookOwnerAndCheckForPeers, + unregisterGrokHookOwnerSync +} from './grok-hook-owners' +import { + clearGrokSymlinkCleanupMarker, + matchesRecordedGrokSymlinkCleanup, + recordGrokSymlinkCleanup +} from './grok-hook-symlink-cleanup-marker' /** Test seam: the matcher string written for Pre/Post tool lifecycle hooks. */ export function getGrokToolEventMatcherForTests(): string { @@ -68,142 +50,51 @@ function getConfigPath(): string { return join(resolveGrokHomeDir(), 'hooks', 'orca-status.json') } -/** Validated guest Grok home with a login-home fallback. */ -function getRemoteGrokHome(remoteHome: string, remoteGrokHome?: string): string { - // Why: SFTP paths are always POSIX — never use host path.join here. - const home = remoteHome.replace(/\/+$/, '') || remoteHome - const candidate = remoteGrokHome?.trim() - if ( - candidate && - candidate === remoteGrokHome && - candidate.startsWith('/') && - !candidate.includes('\\') && - candidate.length <= GROK_HOME_ENVELOPE_MAX_LENGTH && - !hasControlCharacter(candidate) - ) { - return candidate.replace(/\/+$/, '') || '/' - } - return `${home}/.grok` -} - -function hasControlCharacter(value: string): boolean { - return Array.from(value).some((character) => { - const code = character.charCodeAt(0) - return code <= 0x1f || code === 0x7f - }) -} - -function getManagedScriptFileName(): string { - return process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh' -} - -function getManagedScriptPath(): string { - return getSharedManagedScriptPath(getManagedScriptFileName()) -} - -function getManagedCommand(scriptPath: string): string { - // Why (#14828): Grok runs a hook command containing a space as `pwsh -Command `, so the - // encoded PowerShell launcher cost two interpreters before reaching the script — - // `grok.exe -> pwsh.exe -> powershell.exe -> cmd.exe`, ~610ms per event, and the agent's hook - // console stays up for all of it. A cmd-safe bare path is spawned directly - // (`grok.exe -> cmd.exe`, ~110ms), the same shape Codex/Devin/Antigravity already register - // (#8430). Paths that are not cmd-safe still fall back to the encoded launcher (#6078). - // Tradeoff, as for those agents: a bare path cannot carry the launcher's missing-script - // guard, so a deleted script surfaces as a per-event `command not found` in the agent's log - // instead of a silent drain. Grok fails open — the tool still runs, including on PreToolUse. - return process.platform === 'win32' - ? wrapWindowsCmdHookCommand(scriptPath) - : wrapPosixHookCommand(scriptPath) -} - /** Test seam: the command registered for `scriptPath` on the current platform. */ export function getManagedCommandForTests(scriptPath: string): string { - return getManagedCommand(scriptPath) + return getGrokManagedCommand(scriptPath) } -function getManagedScript(target: 'local' | 'posix' = 'local'): string { - if (target === 'local' && process.platform === 'win32') { - return buildWindowsGrokHookScript() +function isSymbolicLinkSync(configPath: string): boolean { + try { + return lstatSync(configPath).isSymbolicLink() + } catch { + return false } - - return [ - '#!/bin/sh', - ...buildPosixHookPayloadCapture(), - 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', - ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', - 'fi', - 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', - ' exit 0', - 'fi', - 'grok_home=', - `if [ -n "\${GROK_HOME:-}" ] && [ "\${#GROK_HOME}" -le ${GROK_HOME_ENVELOPE_MAX_LENGTH} ]; then`, - ' grok_home=$GROK_HOME', - 'fi', - // Timeout caps best-effort hook posts if the local listener stalls. - // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline - // `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl - // command line (EDR command-line false positives). Wire body is identical. - 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/grok" \\', - ' --connect-timeout 0.5 --max-time 1.5 \\', - ' -H "Content-Type: application/x-www-form-urlencoded" \\', - ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', - ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', - ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', - ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', - ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', - ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', - ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "grokHome=${grok_home}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', - 'exit 0', - '' - ].join('\n') } -function buildInstalledConfig( - config: NonNullable>, - command: string, - scriptFileName: string -): void { - const nextHooks = { ...config.hooks } - const isManagedCommand = createManagedCommandMatcher(scriptFileName) - const managedEvents = new Set(GROK_EVENTS.map((event) => event.eventName)) - - // Why: Orca owns only grok-hook.* entries. Sweep stale managed commands out - // of retired events while preserving any user-authored hooks in this file. - for (const [eventName, definitions] of Object.entries(nextHooks)) { - if (managedEvents.has(eventName) || !Array.isArray(definitions)) { - continue - } - const cleaned = removeManagedCommands(definitions, isManagedCommand) - if (cleaned.length === 0) { - delete nextHooks[eventName] - } else { - nextHooks[eventName] = cleaned +function readGrokHookConfigRawSync(configPath: string): string | null { + try { + return readFileSync(configPath, 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null } + throw error } +} - for (const event of GROK_EVENTS) { - const current = Array.isArray(nextHooks[event.eventName]) ? nextHooks[event.eventName] : [] - const cleaned = removeManagedCommands(current, isManagedCommand) - const definition: HookDefinition = { - ...event.definition, - hooks: [buildManagedCommandHook(command)] - } - nextHooks[event.eventName] = [...cleaned, definition] +function notInstalledStatus( + configPath: string, + detail: string | null = null +): AgentHookInstallStatus { + return { + agent: 'grok', + state: detail ? 'error' : 'not_installed', + configPath, + managedHooksPresent: false, + detail } - - config.hooks = nextHooks } export class GrokHookService { async refreshManagedScripts(): Promise { - await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + await refreshManagedScriptIfPresent(getGrokManagedScriptPath(), getGrokManagedScript()) } getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() - const scriptPath = getManagedScriptPath() + const scriptPath = getGrokManagedScriptPath() const config = readHooksJson(configPath) if (!config) { return { @@ -215,7 +106,7 @@ export class GrokHookService { } } - const command = getManagedCommand(scriptPath) + const command = getGrokManagedCommand(scriptPath) const missing: string[] = [] let presentCount = 0 for (const event of GROK_EVENTS) { @@ -248,10 +139,11 @@ export class GrokHookService { return { agent: 'grok', state, configPath, managedHooksPresent, detail } } - install(): AgentHookInstallStatus { + install(options?: { userInitiated?: boolean }): AgentHookInstallStatus { const configPath = getConfigPath() - const scriptPath = getManagedScriptPath() - const config = readHooksJson(configPath) + const scriptPath = getGrokManagedScriptPath() + const snapshot = readHooksJsonWithRaw(configPath) + const config = snapshot.config if (!config) { return { agent: 'grok', @@ -262,10 +154,52 @@ export class GrokHookService { } } - buildInstalledConfig(config, getManagedCommand(scriptPath), getManagedScriptFileName()) - writeManagedScript(scriptPath, getManagedScript()) - writeHooksJson(configPath, config) - return this.getStatus() + // Why: an existing empty file is an explicit user choice, not a missing config -- the reported + // workaround for #15518 was emptying it by hand. Why userInitiated overrides: turning the + // setting back on is an equally explicit choice, and the later one. Without this the toggle + // silently does nothing forever and the only way back is deleting a file in a hidden directory. + // A symlinked empty config is also respected unless its content and file identity match the + // marker written by Orca's own prior cleanup. + const configIsSymlink = isSymbolicLinkSync(configPath) + const reinstallsOwnSymlinkCleanup = + configIsSymlink && + snapshot.raw !== null && + matchesRecordedGrokSymlinkCleanup(configPath, snapshot.raw) + if ( + options?.userInitiated !== true && + snapshot.raw !== null && + Object.keys(config.hooks ?? {}).length === 0 && + !reinstallsOwnSymlinkCleanup + ) { + return this.getStatus() + } + + buildInstalledGrokConfig( + config, + getGrokManagedCommand(scriptPath), + getGrokManagedScriptFileName() + ) + writeManagedScript(scriptPath, getGrokManagedScript()) + mkdirSync(dirname(configPath), { recursive: true }) + if (readGrokHookConfigRawSync(configPath) !== snapshot.raw) { + return notInstalledStatus(configPath, 'Grok hook config changed during installation') + } + const ownsWindowsHook = process.platform === 'win32' + try { + if (ownsWindowsHook) { + registerGrokHookOwner() + } + writeHooksJson(configPath, config) + if (configIsSymlink) { + clearGrokSymlinkCleanupMarker(configPath) + } + return this.getStatus() + } catch (error) { + if (ownsWindowsHook) { + unregisterGrokHookOwnerSync() + } + throw error + } } async installRemote( @@ -273,75 +207,95 @@ export class GrokHookService { remoteHome: string, remoteGrokHome?: string ): Promise { - const home = remoteHome.replace(/\/$/, '') - // Why: only a guest-resolved path can describe remote Grok; never apply the - // host process's GROK_HOME to SFTP paths. - const remoteConfigPath = `${getRemoteGrokHome(home, remoteGrokHome)}/hooks/orca-status.json` - const remoteScriptPath = `${home}/.orca/agent-hooks/grok-hook.sh` - try { - const config = await readHooksJsonRemote(sftp, remoteConfigPath) - if (!config) { - return { - agent: 'grok', - state: 'error', - configPath: remoteConfigPath, - managedHooksPresent: false, - detail: 'Could not parse remote Grok hook config' - } - } - - buildInstalledConfig(config, wrapPosixHookCommand(remoteScriptPath), 'grok-hook.sh') - await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) - await writeHooksJsonRemote(sftp, remoteConfigPath, config) - - return { - agent: 'grok', - state: 'installed', - configPath: remoteConfigPath, - managedHooksPresent: true, - detail: null - } - } catch (err) { - return { - agent: 'grok', - state: 'error', - configPath: remoteConfigPath, - managedHooksPresent: false, - detail: err instanceof Error ? err.message : String(err) - } - } + return await installRemoteGrokHook( + sftp, + remoteHome, + remoteGrokHome, + getGrokManagedScript('posix') + ) } remove(): AgentHookInstallStatus { const configPath = getConfigPath() - const config = readHooksJson(configPath) + if (process.platform === 'win32') { + unregisterGrokHookOwnerSync() + } + clearGrokSymlinkCleanupMarker(configPath) + const snapshot = readHooksJsonWithRaw(configPath) + const config = snapshot.config if (!config) { - return { - agent: 'grok', - state: 'error', - configPath, - managedHooksPresent: false, - detail: 'Could not parse Grok hook config' - } + return notInstalledStatus(configPath, 'Could not parse Grok hook config') } - - const nextHooks = { ...config.hooks } - const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) - for (const [eventName, definitions] of Object.entries(nextHooks)) { - if (!Array.isArray(definitions)) { - continue - } - const cleaned = removeManagedCommands(definitions, isManagedCommand) - if (cleaned.length === 0) { - delete nextHooks[eventName] - } else { - nextHooks[eventName] = cleaned - } + if (snapshot.raw === null) { + return notInstalledStatus(configPath) } + const cleanup = removeManagedGrokHookEntries(config, getGrokManagedScriptFileName()) + if (!cleanup.removedAny) { + return notInstalledStatus(configPath) + } + if (readGrokHookConfigRawSync(configPath) !== snapshot.raw) { + return notInstalledStatus(configPath, 'Grok hook config changed during cleanup') + } + // Why the symlink check: unlinking would delete the user's link, not our file. A config they + // symlinked into a dotfiles repo is theirs -- strip our entries and write through it instead. + // writeHooksJson already resolves the link, so the file they version-control stays connected. + if (isOrcaOwnedRemnant(cleanup.config) && !isSymbolicLinkSync(configPath)) { + rmSync(configPath, { force: true }) + } else { + writeHooksJson(configPath, cleanup.config) + } + return notInstalledStatus(configPath) + } - config.hooks = nextHooks - writeHooksJson(configPath, config) - return this.getStatus() + async removeAsync(): Promise { + const configPath = getConfigPath() + const ownsWindowsHook = process.platform === 'win32' + let peerOwnsWindowsHook = false + const releaseOwner = async (): Promise => { + peerOwnsWindowsHook = await releaseGrokHookOwnerAndCheckForPeers() + return !peerOwnsWindowsHook + } + const mutationOptions = ownsWindowsHook + ? { + beforeHold: releaseOwner, + shouldCommit: async () => !(await hasRegisteredGrokHookOwner()) + } + : undefined + const snapshot = await readGrokHookConfigSnapshot(configPath) + if (!snapshot.config) { + if (ownsWindowsHook) { + await releaseOwner() + } + return notInstalledStatus(configPath, 'Could not parse Grok hook config') + } + if (snapshot.raw === null) { + if (ownsWindowsHook) { + await releaseOwner() + } + return notInstalledStatus(configPath) + } + const cleanup = removeManagedGrokHookEntries(snapshot.config, getGrokManagedScriptFileName()) + if (!cleanup.removedAny) { + if (ownsWindowsHook) { + await releaseOwner() + } + return notInstalledStatus(configPath) + } + const configIsSymlink = await isGrokHookConfigSymlink(configPath) + const unlinkable = isOrcaOwnedRemnant(cleanup.config) && !configIsSymlink + const serialized = `${JSON.stringify(cleanup.config, null, 2)}\n` + const updated = unlinkable + ? await removeGrokHookConfigIfUnchanged(configPath, snapshot.raw, mutationOptions) + : await writeGrokHookConfigIfUnchanged(configPath, snapshot.raw, serialized, mutationOptions) + if (peerOwnsWindowsHook) { + return this.getStatus() + } + if (updated && configIsSymlink) { + await recordGrokSymlinkCleanup(configPath, serialized) + } + return updated + ? notInstalledStatus(configPath) + : notInstalledStatus(configPath, 'Grok hook config changed during cleanup') } } diff --git a/src/main/index.ts b/src/main/index.ts index ab0782c9a25..f2657430ad2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -90,7 +90,9 @@ import { import { applyAgentStatusHooksEnabled, isAgentStatusHooksEnabled, - removeManagedAgentHooks + removeManagedAgentHooks, + removeManagedAgentHooksAsync, + shouldContinueManagedHookStartup } from './agent-hooks/managed-agent-hook-controls' import { initCohortClassifier } from './telemetry/cohort-classifier' import { initOnboardingCohortClassifier } from './telemetry/onboarding-cohort-classifier' @@ -323,7 +325,7 @@ import { createRuntimeAutomationRunTerminalObserver } from './automations/runtim import { AgentAwakeService } from './agent-awake-service' import { normalizeComputerAwakeMode } from '../shared/computer-awake-mode' import { registerSystemResumeBroadcast } from './system-resume-broadcast' -import { settleTeardownWithinDeadline } from './quit-teardown-deadline' +import { settleTeardownWithinDeadline, settleWithinMs } from './quit-teardown-deadline' import { quitTeardownStartGate } from './quit-teardown-start-gate' import { beginSshShutdown } from './ipc/ssh-shutdown-drain' import { PluginService } from './plugins/plugin-service' @@ -3079,7 +3081,7 @@ void app.whenReady().then(async () => { onInstallError: recordManagedHookInstallFailure, shouldContinue: (agent) => { const settings = managedHookStore.getSettings() - return isAgentStatusHooksEnabled(settings) && !settings.disabledTuiAgents.includes(agent) + return shouldContinueManagedHookStartup(isQuitting, settings, agent) } }).catch((error) => { console.warn('[agent-hooks] failed to reconcile managed hooks on startup:', error) @@ -3437,6 +3439,9 @@ app.on('before-quit', () => { // Why: will-quit fires twice — first pass preventDefaults and runs teardown; second pass exits. let daemonDisconnectDone = false +// Why 2s: a config delete is best-effort, not durable state. +const GROK_HOOK_CLEANUP_DEADLINE_MS = 2_000 + app.on('will-quit', (e) => { // Why return instead of re-running teardown: the second pass is Electron re-firing after // our own app.quit(), so every step below already ran and every durable write already @@ -3482,6 +3487,31 @@ app.on('will-quit', (e) => { pluginService = null setUnreadDockBadgeCount(0) agentHookServer.stop() + // Why Windows only: POSIX hooks short-circuit on ORCA_PANE_KEY, while Windows must register a + // bare script path that cannot express the guard and would otherwise keep spawning after quit. + // Why bounded here: every other teardown member carries its own ceiling, and this one reaches + // $GROK_HOME -- which can be a stalled network mount, where the fs calls never settle and the + // shared 20s deadline becomes the only thing ending the quit. + const grokHookCleanup = + process.platform === 'win32' + ? settleWithinMs( + removeManagedAgentHooksAsync({ agents: ['grok'] }), + GROK_HOOK_CLEANUP_DEADLINE_MS + ).then((settled) => { + if (settled.outcome === 'timed-out') { + console.warn('[agent-hooks] Grok hook cleanup on quit timed out') + return + } + if (settled.outcome === 'failed') { + console.warn('[agent-hooks] Grok hook cleanup on quit failed:', settled.error) + return + } + // Why: removers report failures as statuses, so inspect details even after fulfillment. + for (const status of settled.value.filter((entry) => entry.detail)) { + console.warn(`[agent-hooks] ${status.agent} hook cleanup on quit: ${status.detail}`) + } + }) + : Promise.resolve() // Why: cancels relay restart/reinstall timers and kills wsl.exe children deterministically, not via stdio-pipe teardown. wslHookRelayManager.disposeAll() const statsFlush = stats?.flushAsync() ?? Promise.resolve() @@ -3548,6 +3578,7 @@ app.on('will-quit', (e) => { { name: 'ssh', promise: sshShutdown }, { name: 'plugin-hosts', promise: pluginHostShutdown }, { name: 'skill-uploads', promise: skillUploadShutdown }, + { name: 'grok-hooks', promise: grokHookCleanup }, { name: 'codex-backfill-recovery', promise: codexBackfillRecoveryShutdown }, { name: 'usage-cache', promise: usageCacheFlush }, { name: 'stats', promise: statsFlush }, diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index ddf9272240b..08c1f57d367 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -210,6 +210,7 @@ export function registerSettingsHandlers( if (hookSettingChanged) { try { await applyAgentStatusHooksEnabled(result.agentStatusHooksEnabled, result, { + userInitiated: true, shouldHydrateShellPath: app.isPackaged, onInstallError: recordManagedHookInstallFailure, shouldContinue: (agent) => { diff --git a/src/main/quit-path-durable-write-blocking.test.ts b/src/main/quit-path-durable-write-blocking.test.ts index 58e055c5297..5d09e2057a7 100644 --- a/src/main/quit-path-durable-write-blocking.test.ts +++ b/src/main/quit-path-durable-write-blocking.test.ts @@ -62,7 +62,18 @@ vi.mock('node:fs', async (importOriginal) => { vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal() const patched: Record = { ...actual } - for (const name of ['stat', 'access', 'rename', 'copyFile', 'rm', 'mkdir', 'open', 'writeFile']) { + for (const name of [ + 'stat', + 'access', + 'rename', + 'copyFile', + 'rm', + 'mkdir', + 'open', + 'readFile', + 'writeFile', + 'link' + ]) { const fn = (actual as unknown as Record unknown>)[name] patched[name] = async (...args: unknown[]): Promise => { if (fsCalls.inScope(args[0]) && fsCalls.holdAsync) { @@ -255,6 +266,45 @@ describe('quit-path durable writes never park the main thread', () => { expect(JSON.parse(readFileSync(statsFile(dir), 'utf-8')).aggregates.totalAgentsSpawned).toBe(1) }) + it('Grok session cleanup issues no synchronous fs syscalls', async () => { + const dir = makeDir() + const previousGrokHome = process.env.GROK_HOME + process.env.GROK_HOME = dir + vi.resetModules() + const { GrokHookService } = await import('./grok/hook-service') + const service = new GrokHookService() + try { + const configPath = join(dir, 'hooks', 'orca-status.json') + const configDir = join(dir, 'hooks') + const { mkdirSync } = await import('node:fs') + mkdirSync(configDir, { recursive: true }) + writeFileSync( + configPath, + `${JSON.stringify({ + hooks: { + SessionStart: [ + { hooks: [{ type: 'command', command: '/home/test/.orca/agent-hooks/grok-hook.sh' }] } + ] + } + })}\n` + ) + fsCalls.dirPrefix = dir + fsCalls.recording = true + await service.removeAsync() + fsCalls.recording = false + + expect(fsCalls.syncCalls).toEqual([]) + expect(existsSync(configPath)).toBe(false) + } finally { + fsCalls.recording = false + if (previousGrokHome === undefined) { + delete process.env.GROK_HOME + } else { + process.env.GROK_HOME = previousGrokHome + } + } + }) + it('stats.flushAsync() closes out live agents before it returns, not when its write lands', async () => { // will-quit calls this before killAllPty(), which skips runtime.onPtyExit(). If the // closeout moved behind the await, every agent still running at quit would lose its diff --git a/src/main/quit-teardown-deadline.ts b/src/main/quit-teardown-deadline.ts index 70328fa2ffe..33f5031bdd0 100644 --- a/src/main/quit-teardown-deadline.ts +++ b/src/main/quit-teardown-deadline.ts @@ -33,3 +33,35 @@ export async function settleTeardownWithinDeadline( clearTimeout(timer) return outcome === 'deadline' ? [...pendingNames] : [] } + +export type SettledWithinMs = + | { outcome: 'settled'; value: T } + | { outcome: 'failed'; error: unknown } + | { outcome: 'timed-out' } + +/** + * Races a teardown against a deadline. Why the three-way result rather than a nullable value: a + * rejection and a timeout are different diagnoses on the quit path, and collapsing them makes a + * genuine failure read as "timed out" in the log. Every teardown here is best-effort, so this never + * throws. The timer is unref'd so it can never itself hold the process open. + */ +export async function settleWithinMs( + promise: Promise, + timeoutMs: number +): Promise> { + let timer: ReturnType | undefined + try { + return await Promise.race([ + promise.then( + (value): SettledWithinMs => ({ outcome: 'settled', value }), + (error): SettledWithinMs => ({ outcome: 'failed', error }) + ), + new Promise>((resolve) => { + timer = setTimeout(() => resolve({ outcome: 'timed-out' }), timeoutMs) + timer.unref?.() + }) + ]) + } finally { + clearTimeout(timer) + } +} diff --git a/tests/e2e/fixtures/golden-stub-agent/grok b/tests/e2e/fixtures/golden-stub-agent/grok new file mode 100755 index 00000000000..039e4d0069c --- /dev/null +++ b/tests/e2e/fixtures/golden-stub-agent/grok @@ -0,0 +1,2 @@ +#!/bin/sh +exit 0 diff --git a/tests/e2e/fixtures/golden-stub-agent/grok.cmd b/tests/e2e/fixtures/golden-stub-agent/grok.cmd new file mode 100644 index 00000000000..dd161e028fe --- /dev/null +++ b/tests/e2e/fixtures/golden-stub-agent/grok.cmd @@ -0,0 +1 @@ +@exit /b 0 diff --git a/tests/e2e/grok-hook-session-cleanup.spec.ts b/tests/e2e/grok-hook-session-cleanup.spec.ts new file mode 100644 index 00000000000..e8b2f2f3541 --- /dev/null +++ b/tests/e2e/grok-hook-session-cleanup.spec.ts @@ -0,0 +1,85 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { ElectronApplication } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { createRestartSession } from './helpers/orca-restart' + +const STUB_AGENT_DIR = path.join(process.cwd(), 'tests', 'e2e', 'fixtures', 'golden-stub-agent') +const LAUNCH_ENV = { PATH: `${STUB_AGENT_DIR}${path.delimiter}${process.env.PATH ?? ''}` } + +function grokConfigPath(userDataDir: string): string { + return path.join(userDataDir, 'home', '.grok', 'hooks', 'orca-status.json') +} + +async function waitForManagedConfig(configPath: string): Promise { + await expect + .poll( + () => { + if (!existsSync(configPath)) { + return false + } + const config = JSON.parse(readFileSync(configPath, 'utf8')) as { + hooks?: Record + } + return Boolean(config.hooks?.SessionStart && config.hooks?.UserPromptSubmit) + }, + { timeout: 30_000, message: 'Orca did not install the managed Grok hook config' } + ) + .toBe(true) +} + +test('removes the managed Grok hook config on quit', async (// oxlint-disable-next-line no-empty-pattern -- this test owns its Electron launch. +{}, testInfo) => { + test.skip(process.platform !== 'win32', 'POSIX hooks are pane-guarded and stay installed') + const session = createRestartSession(testInfo, LAUNCH_ENV) + const configPath = grokConfigPath(session.userDataDir) + let app: ElectronApplication | null = null + try { + const launch = await session.launch() + app = launch.app + await waitForManagedConfig(configPath) + + await session.close(app) + app = null + + expect(existsSync(configPath)).toBe(false) + } finally { + if (app) { + await session.close(app).catch(() => undefined) + } + await session.dispose() + } +}) + +test('preserves a user-cleared Grok config through quit and relaunch', async (// oxlint-disable-next-line no-empty-pattern -- this test owns both Electron launches. +{}, testInfo) => { + const session = createRestartSession(testInfo, LAUNCH_ENV) + const configPath = grokConfigPath(session.userDataDir) + const userClearedConfig = '{ "hooks" : {} }\n' + let app: ElectronApplication | null = null + try { + const first = await session.launch() + app = first.app + await waitForManagedConfig(configPath) + writeFileSync(configPath, userClearedConfig) + + await session.close(app) + app = null + expect(readFileSync(configPath, 'utf8')).toBe(userClearedConfig) + + const second = await session.launch() + app = second.app + await expect + .poll(() => readFileSync(configPath, 'utf8'), { timeout: 10_000 }) + .toBe(userClearedConfig) + + await session.close(app) + app = null + expect(readFileSync(configPath, 'utf8')).toBe(userClearedConfig) + } finally { + if (app) { + await session.close(app).catch(() => undefined) + } + await session.dispose() + } +}) From 256f23c7a00fb6b911fdfcbd0a955caeb30890d7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:58:41 -0700 Subject: [PATCH 007/152] fix(automations): validate create destination projects --- .../automations/AutomationsPage.create-destination.test.tsx | 4 ++-- src/renderer/src/components/automations/AutomationsPage.tsx | 5 +---- .../automations/automation-scoped-list-client.test.ts | 2 +- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx index 8525545668a..d13ab127e21 100644 --- a/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx @@ -152,7 +152,7 @@ describe('AutomationsPage create destination', () => { expect(api.automations.create).not.toHaveBeenCalled() expect(runtimeCreateCalls()).toHaveLength(0) - expect(mocks.editorDialog?.notice?.message).toBeTruthy() + expect(mocks.toastError).toHaveBeenCalled() expect(mocks.editorDialog?.open).toBe(true) }) @@ -169,7 +169,7 @@ describe('AutomationsPage create destination', () => { // A repo with no connection ID is not evidence of local: this one is the // runtime's, and the desktop's Self host cannot hold an automation for it. expect(api.automations.create).not.toHaveBeenCalled() - expect(mocks.editorDialog?.notice?.message).toBeTruthy() + expect(mocks.toastError).toHaveBeenCalled() expect(mocks.editorDialog?.open).toBe(true) }) diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 85b4ddf5929..882ac49ea2f 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -992,9 +992,6 @@ export default function AutomationsPage(): React.JSX.Element { ? getAutomationOwnerTarget(editingAutomation, automationHostTarget) : getAutomationListTarget(settings) const isOrcaForm = createTarget === 'orca' && editingExternalTarget === null - const dialogRepos = isOrcaForm - ? getAutomationCreateRepos(repos, automationDialogTarget) - : getAutomationCreateRepos(repos, { kind: 'local' }) const destinationForProject = useCallback( (projectId: string): AutomationCreateDestination | null => { @@ -1554,7 +1551,7 @@ export default function AutomationsPage(): React.JSX.Element { ) return } - if (isOrcaForm && !dialogRepos.some((repo) => repo.id === draft.projectId)) { + if (isOrcaForm && !editorProjects.some((repo) => repo.id === draft.projectId)) { toast.error( translate( 'auto.components.automations.AutomationsPage.destinationProjectUnavailable', diff --git a/src/renderer/src/components/automations/automation-scoped-list-client.test.ts b/src/renderer/src/components/automations/automation-scoped-list-client.test.ts index 494e0f706cc..c5e2efe1b07 100644 --- a/src/renderer/src/components/automations/automation-scoped-list-client.test.ts +++ b/src/renderer/src/components/automations/automation-scoped-list-client.test.ts @@ -159,7 +159,7 @@ describe('owner-fenced mutations', () => { ) expect(callRuntimeRpc.mock.calls[0]?.[2]).toEqual({ id: 'a1', - updates: { repo: 'repo-ssh' }, + updates: { repo: 'id:repo-ssh' }, expectedOwner: { selector: { kind: 'self' } }, destination: { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } } }) From 8a07bbd8cf58dcaca62868101627eddc6feeb0ac Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:22:09 -0700 Subject: [PATCH 008/152] fix(orchestration): enforce nested worker depth instead of an accidental fence (#16668) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(orchestration): enforce nested worker depth instead of an accidental fence Orca documented that "dispatched workers cannot spawn their own sub-workers (worker-start is coordinator-fenced)". No such check existed. What existed was a single Run-binding check in the workerStart RPC: a worker's terminal is not bound to a Run, so worker-start happened to fail. The rule was emergent, asserted by no test, and written in no doc — and it leaked. A worker could run-create its own Run, task-create, and worker-start: now bound, the check passed. Replace it with a real, configurable depth cap. Depth is derived from the caller's own active Dispatch rather than from Run binding, which is what dissolves the run-create bypass: creating a Run does not stop you being a worker. Enforcement lives in a single dispatch-row writer that owns all three INSERTs that mint a live worker — the generic claim, the supervised worker-start path (including every retry), and the remote attachment. Two of those were missed by earlier drafts of this change, so `creator` and `maxDepth` are required parameters: a new spawn path cannot compile without deciding, and a boundary test refuses the SQL anywhere else. Schema v30 adds depth to dispatch_contexts and remote_dispatch_attachments, NOT NULL DEFAULT 1 and backfilled to 1 so an unstamped or pre-upgrade row fails closed rather than reading as a root coordinator. The attachment pane indexes widen to the five states in which a remote worker may still be running: loss of contact is not evidence of process death, so an unverifiable worker still counts as a nesting parent. Also adds the caller-evidence assertion that workerStart was the only Run-scoped verb to skip, so a declared --from cannot name another terminal's pane and inherit its depth. Default is 1, so behaviour is unchanged unless the new setting is raised. Two limitations are deliberate and documented rather than papered over: this is a guardrail and not a security boundary, since a caller whose launch evidence is unverifiable (any ordinary restored terminal) can declare another handle; and it is enforced at supervised dispatch creation, so a settled worker whose process is still alive counts as a root again. * fix(orchestration): share caller resolution and pin worker gaps * refactor(orchestration): make the caller resolver's pane contract explicit Overloads so requireStablePane callers get a non-null string instead of casting, and rename the attestation opt-out to say what it means: the caller asserts it itself. A flag called assertEvidence:false reads as "attestation optional", which is the hole this helper exists to close. * fix(orchestration): propagate dispatch depth to federated workers * chore(cli): refresh bundled orchestration guide --- skill-guides/orchestration.md | 27 ++ src/cli/bundled-skill-guides.ts | 2 +- .../applying-settings/settings-update.ts | 6 + .../runtime/exit-provenance-audit.test.ts | 6 +- src/main/runtime/orca-runtime.test.ts | 22 +- src/main/runtime/orca-runtime.ts | 7 + ...estration-mailbox-detached-routing.test.ts | 14 +- ...n-mailbox-notification-consistency.test.ts | 3 +- ...rchestration-mailbox-routing-races.test.ts | 13 +- .../coordinator-decision-gates.test.ts | 10 +- .../coordinator-escalation-triage.test.ts | 8 +- .../coordinator-runtime-contract.ts | 2 + .../coordinator-task-dispatch.ts | 17 +- .../runtime/orchestration/coordinator.test.ts | 15 +- src/main/runtime/orchestration/coordinator.ts | 3 + ...ty-dispatch-shortcircuit.benchmark.test.ts | 5 +- .../db-heartbeat-straggler-guard.test.ts | 3 +- .../db-task-dispatch-invariant.test.ts | 47 +-- .../db-task-dispatch-lifecycle-guards.test.ts | 45 ++- .../db-task-dispatch-races.test.ts | 14 +- src/main/runtime/orchestration/db.test.ts | 85 +++--- src/main/runtime/orchestration/db.ts | 2 +- .../db/attach-orchestration-db-methods.ts | 2 + .../orchestration/db/contract-constants.ts | 2 +- .../dispatch-context-store.ts | 82 ++---- .../orchestration/db/dispatch-depth.test.ts | 271 ++++++++++++++++++ .../orchestration/db/dispatch-depth.ts | 156 ++++++++++ .../db/dispatch-row-writer-boundary.test.ts | 139 +++++++++ .../orchestration/db/dispatch-row-writer.ts | 144 ++++++++++ .../remote-dispatch-attachment-create.ts | 24 +- .../db/orchestration-db-methods.ts | 2 + .../db/root-dispatch-test-fixture.ts | 28 ++ .../db/schema/create-graph-tables-sql.ts | 14 +- ...{migrate-v13-v29.ts => migrate-v13-v30.ts} | 30 +- .../orchestration/db/schema/migrate.ts | 4 +- .../worker-dispatch/worker-dispatch-start.ts | 20 +- .../dispatch-failure-idempotency.test.ts | 7 +- .../orchestration/federation-sync.test.ts | 4 + .../lifecycle-reconciliation.test.ts | 39 +-- ...tweight-run-worker-exit-escalation.test.ts | 20 +- .../nested-worker-depth-migration.test.ts | 114 ++++++++ .../orchestration-adopted-run-binding.test.ts | 3 +- ...tion-creator-authority-performance.test.ts | 23 +- ...hestration-db-retention-pagination.test.ts | 7 +- .../orchestration-legacy-storage-db.test.ts | 15 +- ...chestration-legacy-storage-test-fixture.ts | 10 +- ...orchestration-mutation-question-db.test.ts | 5 +- .../orchestration-reset-db.test.ts | 2 + .../orchestration-run-delivery-db.test.ts | 5 +- .../orchestration-schema-version-skew.ts | 4 +- ...chestration-version-skew-migration.test.ts | 3 +- .../orchestration-worker-dispatch-db.test.ts | 65 ++++- src/main/runtime/orchestration/types.ts | 4 + .../rpc/methods/orchestration-ask.test.ts | 5 +- .../rpc/methods/orchestration-check.test.ts | 7 +- .../orchestration-composed-workers.test.ts | 59 ++++ .../methods/orchestration-dispatch-creator.ts | 27 ++ .../orchestration-federated-worker-start.ts | 6 + ...hestration-federation-agent-launch.test.ts | 2 + ...hestration-federation-control-mail.test.ts | 2 + .../orchestration-federation-setup.test.ts | 2 + .../orchestration-federation-start-schema.ts | 2 + .../rpc/methods/orchestration-federation.ts | 1 + ...ration-manual-dispatch-observation.test.ts | 40 ++- ...hestration-manual-dispatch-release.test.ts | 2 + .../orchestration-migration-behavior.test.ts | 3 +- .../orchestration-recipient-routing.test.ts | 7 +- .../rpc/methods/orchestration-run-scope.ts | 48 +++- .../runtime/rpc/methods/orchestration-runs.ts | 49 ++-- ...hestration-send-dispatch-authority.test.ts | 14 +- .../rpc/methods/orchestration-send.test.ts | 34 ++- .../orchestration-tasks-dispatch.test.ts | 11 +- ...hestration-worker-interactive-wait.test.ts | 7 +- ...ation-worker-stop-liveness-verdict.test.ts | 2 + .../orchestration-workers-recovery.test.ts | 6 + .../rpc/methods/orchestration-workers.ts | 16 +- src/main/runtime/rpc/methods/orchestration.ts | 15 +- ...tion-11745-regression-verification.test.ts | 4 +- ...ation-current-authority-precedence.test.ts | 6 +- ...y-compatibility-dispatcher-test-fixture.ts | 3 +- ...on-legacy-compatibility-dispatcher.test.ts | 7 +- ...hestration-legacy-coordinator-race.test.ts | 3 +- ...estration-legacy-question-takeover.test.ts | 3 +- ...estration-legacy-takeover-delivery.test.ts | 3 +- ...tration-legacy-takeover-dispatcher.test.ts | 3 +- .../rpc/orchestration-mutation-ledger.test.ts | 5 +- ...stration-runtime-update-settlement.test.ts | 3 +- ...hestration-task-dispatch-invariant.test.ts | 21 +- .../runtime-rpc-long-poll-transport.test.ts | 3 +- src/main/runtime/runtime-rpc-test-harness.ts | 3 +- src/main/ssh/ssh-remote-orca-cli.test.ts | 12 +- ...remote-orchestration-compatibility.test.ts | 3 +- src/shared/constants.ts | 1 + src/shared/global-settings-types.ts | 4 + src/shared/nested-worker-depth.test.ts | 44 +++ src/shared/nested-worker-depth.ts | 42 +++ 96 files changed, 1788 insertions(+), 356 deletions(-) create mode 100644 src/main/runtime/orchestration/db/dispatch-depth.test.ts create mode 100644 src/main/runtime/orchestration/db/dispatch-depth.ts create mode 100644 src/main/runtime/orchestration/db/dispatch-row-writer-boundary.test.ts create mode 100644 src/main/runtime/orchestration/db/dispatch-row-writer.ts create mode 100644 src/main/runtime/orchestration/db/root-dispatch-test-fixture.ts rename src/main/runtime/orchestration/db/schema/{migrate-v13-v29.ts => migrate-v13-v30.ts} (84%) create mode 100644 src/main/runtime/orchestration/nested-worker-depth-migration.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-dispatch-creator.ts create mode 100644 src/shared/nested-worker-depth.test.ts create mode 100644 src/shared/nested-worker-depth.ts diff --git a/skill-guides/orchestration.md b/skill-guides/orchestration.md index 4e65a07e733..ef8181dd4e3 100644 --- a/skill-guides/orchestration.md +++ b/skill-guides/orchestration.md @@ -176,6 +176,33 @@ Dispatch rules: - After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed. - Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag. +## How deep workers can nest + +A dispatched worker normally cannot dispatch sub-workers. Attempting it fails with +`nested_worker_depth_exceeded` and a message telling the worker to complete the task +itself. Do that — do not try to route around it. + +The limit is a number, not an on/off switch. `Settings -> Agents -> Nested worker depth` +sets how many generations are allowed: + +- `1` (default): a coordinator dispatches workers; those workers do not dispatch. +- `2`: workers may dispatch one further generation. + +Depth is counted from the terminal that issues the command, not from the Run. Creating a +new Run does not reset it — a worker that runs `run-create` then `worker-start` is still a +worker, and still counted. This is the part that changed: the old behaviour rejected +sub-dispatch only because a worker's terminal was not bound to a Run, so creating a Run was +enough to slip past it. + +Two limits worth knowing: + +- **It is a guardrail, not a security boundary.** A caller that declares another terminal's + handle while its own launch evidence is unverifiable (an ordinary restored terminal, for + example) can be counted as that terminal instead. Orca does not treat workers as hostile. +- **It applies while a Dispatch is active.** After `worker_done`, or after a coordinator + settles the task, the terminal is no longer a worker and is counted as a root again. The + process may still be alive; that is the documented boundary, not an accident. + ## Preferred Supervised Worker Loop Use `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts. diff --git a/src/cli/bundled-skill-guides.ts b/src/cli/bundled-skill-guides.ts index 33f3e18f3a7..66f55c065e7 100644 --- a/src/cli/bundled-skill-guides.ts +++ b/src/cli/bundled-skill-guides.ts @@ -30,7 +30,7 @@ const ORCA_LINEAR_MARKDOWN = "---\nname: orca-linear\ndescription: >-\n Use Orc const ORCA_PER_WORKSPACE_ENV_MARKDOWN = "---\nname: orca-per-workspace-env\ndescription: >-\n Set up, review, debug, or validate Orca per-workspace environment recipes —\n on-demand, disposable runtimes (cloud sandboxes, VMs, or local) created fresh\n for each workspace. Covers first-time setup (provider prerequisites, the\n reusable base snapshot, the coding-agent auth snapshot, credentials, and\n state), not just the per-workspace lifecycle scripts. Use to stand up\n per-workspace environments, fix an `environmentRecipes` entry in `orca.yaml`, scaffold\n provider lifecycle scripts, or resolve an `orca vm recipe doctor` failure.\n---\n\n# Per-Workspace Environments\n\nHelp a user stand up and maintain a repo-owned per-workspace environment recipe end to end. Each\nworkspace gets its own on-demand, disposable runtime (a cloud sandbox, a VM, or a local one),\ncreated fresh and torn down after.\n\nOrca is a **thin wrapper**: you guide, detect, and scaffold; you never own the user's cloud account,\nbilling, images, or credentials.\n\n- **You DO:** sequence the setup, detect what's detectable (provider CLI present/logged-in? recipe\n present? `doctor` passing?), scaffold provider-templated scripts the user fills in, drive the slow\n snapshot/auth phases with the user, and always show the next action.\n- **You DO NOT:** create accounts, choose plans/regions, invent org/project/scope ids, store or print\n secrets, or run anything that spends money without an explicit user OK.\n\nFirst-time setup has **four phases before the per-workspace recipe runs** — easy to miss, so walk\nthem in order:\n\n1. **Prerequisites** — cloud account, provider CLI, scope/project, plan limits, git token (§2).\n2. **Base snapshot** — reusable image: tools + repo + headless build, snapshotted once (§3).\n3. **Agent-auth snapshot** — boot the base, run interactive device-auth, re-snapshot (§4).\n4. **State** — thread snapshot id / scope / project / port between phases via a state file (§6).\n\nThen the **per-workspace contract** (create/suspend/resume/destroy) runs fast (§8).\n\n**The one branch that shapes everything — connection mode:** **Orca-server** (`create` runs `orca serve`\nin the env and emits a `pairingCode`; §7c/§7f) vs **SSH** (`create` runs no server and emits a\n`connection.type:\"ssh\"` block Orca dials into; §7g/§7h). Settle this first — it changes the `create`\noutput shape and half the templates.\n\nKeep Orca's checkout behavior unchanged by default: omit `checkoutMode`, emit schema version 1, and\nlet Orca create a linked worktree. Only use `checkoutMode: provisioned-root` when the user explicitly\nwants one ephemeral machine to clone the finished workspace itself. This niche mode currently requires\ndirect SSH, an ordinary non-bare/non-sparse primary checkout at `projectRoot`, and schema version 2.\n\n**Quick-start (happy path):** interview the user (connection mode Orca-server vs SSH, provider, agent CLI,\ngit auth — §1.2) + read the provider's CLI docs → scaffold `scripts/orca-vm/` from §7 → run the\nbase-snapshot script, then the auth script (you invoke these by hand; not via `orca.yaml`) → wire\n`environmentRecipes` in `orca.yaml` → `orca vm recipe doctor --json` (free) → then the `--provision`\nself-test loop (§9) until it passes.\n\n---\n\n## 1. Setup workflow\n\nDrive these with the user. **[CHECKPOINT]** steps need explicit confirmation — they spend money, take\na long time, or need the user at the keyboard. Never create an Orca workspace or commit unless asked.\n\n1. **Inspect the repo** for an existing `environmentRecipes` entry, `scripts/orca-vm/`, a state file, or setup\n notes. If a working recipe exists, jump to Doctor (§9) instead of rebuilding.\n2. **Interview the user up front** — gather these choices and confirm them back before scaffolding\n anything. Don't pick for them (§11); don't guess.\n - **Connection mode:** how Orca attaches to the environment — an **Orca server** (the VM runs\n `orca serve` and Orca pairs over its pairing URL; worked example §7f) or **SSH** (Orca connects to\n the host over SSH; §7g). This decides the recipe's connection shape, so settle it first.\n - **Checkout ownership:** do not ask by default. Only when the user requires the environment to\n create the exact final checkout, confirm `provisioned-root` and direct SSH; otherwise omit it.\n - **Provider:** Vercel Sandbox, Fly, Modal, an existing SSH host, … For non-obvious providers, also\n ask scope/project/region and plan limits (§2). Then **read that provider's CLI/SDK docs** (or\n ` --help`) before scaffolding — you need its exact create/exec/snapshot/remove verbs.\n If a provider advertises `ssh`, verify whether it exposes a real dialable SSH target\n (host/port/user/key or proxy command) or only a provider-mediated interactive shell; Orca SSH mode\n needs the former.\n - **Coding-agent CLI + account:** which agent runs in the VM (`codex`, `claude`, …) and that the user\n has an account for it — it gets logged in during the Phase-3 auth snapshot (§4).\n - **Git auth:** the token source for cloning a private repo (`GH_TOKEN`/`GITHUB_TOKEN` or `gh auth\n token`; §5).\n3. **Check prerequisites (§2)** — detect the provider CLI + auth and confirm the items above are in\n place before any paid step.\n4. **Scaffold scripts + state file** from §7 (worked Vercel example: §7f; SSH host: §7g; Docker SSH:\n §7h; Windows: §7i), filling in the provider's real commands. Make them executable.\n5. **[CHECKPOINT] Build the base snapshot (§3)** — paid, slow.\n6. **[CHECKPOINT] Authenticate the agent (§4)** — interactive; the user follows a URL/code. **You cannot\n drive this step** — you run commands non-interactively, so there's no TTY for `docker exec -it` /\n `ssh -t` to prompt against. The **user** runs the Phase-3 login in their own terminal (or via the\n Claude Code harness bang-prefix — `! `, with the required space after `!`); you scaffold and drive\n the non-interactive phases around it. After kicking it off, **ask the user to report back once the login\n finishes** — you can't observe it completing, and you need that confirmation before resuming the\n non-interactive steps (base/auth commit, doctor, provision).\n7. **Wire the recipe** so `orca.yaml` points create/suspend/resume/destroy at the scripts (§8). The\n workspace composer reads `environmentRecipes` from the project's primary checkout of `orca.yaml`, **not** from\n a feature branch or worktree. So a recipe added only on a branch won't appear as a \"Run on\" option\n until that `orca.yaml` change is committed and merged to the project's primary branch. Tell the user\n this up front: `doctor`/`--provision` validate the scripts from the working copy on any branch, but\n creating a workspace from the recipe in the picker needs it on primary.\n8. **Dry-run doctor** — `orca vm recipe doctor --repo-path --json` (free, static; §9).\n Fix every failure before going live.\n9. **[CHECKPOINT] Live self-test** — get the user's OK once, then run\n `orca vm recipe doctor --provision --json` as a loop: it runs create → validates →\n destroys, and on failure returns a full transcript. Read it, fix the scripts, and re-run yourself until\n it passes (§9). Spends cloud money; the one approval covers the loop.\n10. **[CHECKPOINT] Optional workspace test** — only if asked: create a workspace via the picker, then\n verify sleep/wake/delete.\n\n---\n\n## 2. Phase 1 — Prerequisites\n\nThe user's responsibility; verify what's verifiable, ask for the rest, invent nothing. State which\nitems you verified vs. which the user asserted.\n\n- **Connection mode** (Orca server vs SSH) confirmed with the user — see §1 step 2; it shapes the recipe.\n- **Cloud account + plan** that allows sandboxes/VMs. Ask.\n- **Provider CLI installed + authenticated** — detect (`command -v `), check auth (e.g.\n `vercel whoami`). If missing, point at the provider's docs; don't log them in.\n- **Scope / project / region** the sandboxes live under. Ask; flows into every script via state.\n- **Plan / timeout / RAM caps.** Record them — e.g. Vercel Hobby caps sandbox timeout at **45m**,\n which limits both the base build and per-workspace runtime (see §10).\n- **Git token for private repos** (`GH_TOKEN`/`GITHUB_TOKEN`, or the provider's git auth; can fall back\n to `gh auth token`). See §5.\n- **Coding-agent CLI choice** (`codex`, `claude`…) and that the user has an account — it gets\n authenticated into the VM in Phase 3.\n\n---\n\n## 3. Phase 2 — Base snapshot (the reusable image)\n\nBuild **once**, snapshot, and every workspace boots from it in seconds instead of rebuilding.\nProvisioning + building takes a while (often ~20–30 min), so it runs behind a checkpoint. The script\nshape is §7a; key points:\n\n- Build the **headless Electron main only** (not the renderer) so it fits in plan RAM.\n- Use the VM image's package manager (`apt`/`dnf`/`apk`, per the base distro — not the provider brand).\n- Clone with the git token via `GIT_ASKPASS` (§5).\n- **Trap errors and remove the half-built sandbox** so a crash doesn't leave a paid resource running.\n- Snapshot the stopped sandbox, parse the snapshot id, and write it + scope/project/port/repo to state.\n\n---\n\n## 4. Phase 3 — Agent-auth snapshot (interactive)\n\nThe base snapshot has the agent CLI installed but **not logged in**, and per-workspace VMs are\nephemeral — so authenticate once and bake it into a second snapshot layer. Script shape is §7b:\n\n1. Boot a sandbox from the base `snapshotId` (from state).\n2. Run the agent's login **interactively** (`--interactive --tty`); the user completes the URL/code in\n their browser. On a **headless VM this must be the device-auth flow** (e.g. `codex login --device-auth`),\n **not** plain `codex login`: the default OAuth login starts a loopback callback server on a container\n port the host browser can't reach, so it hangs. Device-auth instead prints a URL + code the user opens\n on the **host**.\n3. Verify login; **refuse to snapshot an unauthenticated VM.** Prefer the status command's **exit code**\n (most agent CLIs exit non-zero when unauthenticated). If you grep instead, agent status often goes to\n **stderr** (e.g. `codex login status` prints \"Logged in using ChatGPT\" there), so **fold stderr first**\n (`... 2>&1 | grep …`) and match the agent's **exact success line** — never `grep -qi 'logged in'`, which\n also matches \"**not** logged in\" and would commit an unauthenticated image.\n4. Re-snapshot, parse the new id, and overwrite `snapshotId` in state to the authenticated image\n (recording `authSourceSnapshotId`). Remove the auth sandbox.\n\n**You can't drive step 2 yourself** (you run commands non-interactively — no TTY). The **user** runs it in\ntheir own terminal, or via the Claude Code harness bang-prefix (`! `, with the required space after\n`!`). You scaffold/boot the sandbox and run steps 3–4, but **you cannot observe the interactive login\nfinishing** — so **ask the user to tell you when it's done** before you verify and re-snapshot.\n\nIf the agent's credentials are short-lived, warn that the snapshot may need periodic re-auth (§10).\n\nFor disposable runtimes, do **not** treat a host agent config directory (for example `~/.codex`) as the\nauth snapshot by bind-mounting or copying it wholesale. Agent homes often contain sqlite state, hook\napproval state, caches, logs, and host-specific env/config. Instead, authenticate/configure the agent\ninside the disposable runtime and snapshot/commit that runtime layer.\n\n---\n\n## 5. Credentials\n\n- **Never** commit secrets or put them in `userData`, recipe JSON, comments, docs, or the state file.\n- **Git token:** read from env (`GH_TOKEN`/`GITHUB_TOKEN`), falling back to `gh auth token`. Pass to the\n VM only via the provider's ephemeral `--env`. Inside the VM, use a `GIT_ASKPASS` helper with\n `x-access-token` (not the token in the clone URL) and `GIT_TERMINAL_PROMPT=0` so a missing token fails\n fast instead of hanging. When you write the helper from inside `bash -lc` under `set -u`, escape the\n positional arg and the token (`\\$1`, `\\$GH_TOKEN`) so they land **literally** and resolve at git-runtime\n — an unescaped `$1` aborts with \"unbound variable\", and a literal `$GH_TOKEN` keeps the real token out of\n the written file. `rm -f` the helper after the clone/fetch.\n- **Provider auth:** rely on the provider CLI's logged-in session, not checked-in keys.\n- **Agent auth:** lives in the authenticated snapshot (Phase 3) — never a file you write or commit.\n- State holds only **non-secret** wiring (snapshot ids, scope, project, port, repo url/ref).\n\n---\n\n## 6. State file\n\nA repo-local JSON file (e.g. `scripts/orca-vm/-state.json`) threads non-secret values between\nphases. Each script resolves values as **env var → state → built-in fallback**, and merges its outputs\nback. Phase 2 writes the base `snapshotId`; Phase 3 overwrites it with the authenticated snapshot;\nper-workspace `create` boots from `snapshotId`.\n\n```json\n{\n \"baseName\": \"orca-base\",\n \"snapshotId\": \"snap_authenticated_image_id\",\n \"authSourceSnapshotId\": \"snap_base_image_id\",\n \"scope\": \"\",\n \"project\": \"\",\n \"port\": 7331,\n \"repoUrl\": \"https://host/org/repo.git\",\n \"repoRef\": \"main\",\n \"projectRoot\": \"/abs/path/on/remote/repo\"\n}\n```\n\n---\n\n## 7. Script templates (provider-agnostic shapes)\n\nScaffold under `scripts/orca-vm/`. These are **shapes** — fill in the provider's real commands. All\nreserve stdout for the final JSON and log progress to stderr. Include a shared `json_value ` /\n`env_value ` reader (env → state → fallback) in each.\n\n**Where each script runs:**\n\n- **Local-side** (`create`/`suspend`/`resume`/`destroy` + the base-snapshot/auth scripts the user\n invokes) runs **on the user's desktop**, so it must run on their OS. macOS/Linux: `#!/usr/bin/env\n bash`, `set -euo pipefail`, quoted paths. **Windows:** a bare `.sh` won't run — scaffold `.ps1`/`.cmd`\n or require WSL/Git-Bash and point `orca.yaml` at the right launcher.\n- **Remote-side** (commands you `exec` *inside* the Linux VM) always runs in the VM's Linux shell, so\n bash is fine there regardless of the user's OS.\n\n### 7a. Base-snapshot (`-base-snapshot.sh`) — Phase 2\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve base_name/repo_url/repo_ref/project_root/port/scope/project/timeout (env→state→fallback)\n# resolve gh token: GH_TOKEN | GITHUB_TOKEN | `gh auth token`\n# 1. provision a sandbox (timeout/vcpus/published port/snapshot retention); trap: remove on error\n# 2. remote exec (long timeout): install pkgs + gh + corepack/pnpm + agent CLI;\n# clone with GIT_ASKPASS(token); write headless main-only build config;\n# dev setup; pnpm install; build CLI; build headless electron main; smoke-check tools\n# 3. snapshot stopped sandbox; parse snapshot id (fail if unparseable)\n# 4. merge { baseName, snapshotId, projectRoot, repoUrl, repoRef, port, scope, project } into state\n# print only the state JSON to stdout\n```\n\nWorked Vercel commands for this phase are in §7f. You run this script by hand (not via `orca.yaml`),\nafter exporting the first-run inputs the state file doesn't have yet — e.g. provider scope/project, the\nrepo URL/ref, and a git token (`GH_TOKEN`); later runs read them back from state.\n\n### 7b. Auth (`-base-auth.sh`) — Phase 3\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read source snapshot from state.snapshotId (fail if absent); auth_name=\"${base_name}-auth\"\n# 1. boot sandbox from source snapshot; trap: remove on error\n# 2. INTERACTIVE/TTY remote exec: agent login — user completes URL/code. Headless VM: MUST use the\n# device-auth flow (e.g. `codex login --device-auth`) — plain OAuth login binds a loopback callback\n# port the host can't reach and hangs. User runs this themselves (you have no interactive TTY); ask\n# them to report back when it's done before continuing.\n# 3. verify login, then refuse to snapshot if not logged in. Prefer the status command's EXIT CODE (most\n# agent CLIs exit non-zero when unauthenticated) over string-matching. If you must grep, fold stderr\n# first (`status 2>&1 | grep …` — many agents print the success line there) and match the agent's exact\n# success line; never `grep -qi 'logged in'`, which also matches \"not logged in\". Codex example: §7f.\n# 4. snapshot; parse new id\n# 5. merge { snapshotId:, authSourceSnapshotId: } into state; remove auth sandbox\n# print only the state JSON to stdout\n```\n\n### 7c. Create (`-create.sh`) — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read authenticated snapshotId/scope/project/port/repo*/project_root (env→state→fallback)\n# fail clearly if snapshotId is missing (point back to Phases 2–3)\n# name = orca-${ORCA_RECIPE_ID}-${ORCA_VM_INSTANCE_ID} (sanitized, length-capped)\n# 1. boot sandbox from snapshotId with a published port; capture the public URL → pairing address\n# (an externally reachable wss:// URL); trap: remove sandbox on error\n# 2. remote exec: ensure repo at desired commit; rebuild only if commit changed (cache marker)\n# 3. remote exec: start orca serve in the background and read the recipe JSON it writes (see below)\n# 4. print serve's JSON to stdout, optionally enriched with userData:\n# { schemaVersion:1, pairingCode, projectRoot, userData:{ provider, resourceId:name, snapshotId } }\n```\n\n**The exact `orca serve` invocation and its output (verified — do not improvise the flags).** Inside the\nVM, run:\n\n```bash\norca serve \\\n --port \"$PORT\" \\\n --project-root \"$ABS_REPO_PATH_ON_REMOTE\" \\\n --pairing-address \"$EXTERNAL_WSS_URL\" \\\n --recipe-json\n```\n\n**Binary name:** in a VM built from source (the Phase-2 flow), run it as `pnpm exec orca-dev serve …`\nfrom the repo root — `orca-dev` is the in-repo entrypoint and is what the §7f example uses. Plain\n`orca serve …` is the same command when the built CLI is installed on the VM's PATH. The flags/output\nare identical either way.\n\nThere is **no `--host` flag**. `--project-root` must be an absolute directory on the remote. With\n`--recipe-json` the server **stays running** and prints exactly this single object to **stdout**, then\nkeeps serving:\n\n```json\n{ \"schemaVersion\": 1, \"pairingCode\": \"\", \"projectRoot\": \"\" }\n```\n\n`pairingCode` is the pairing URL, already pointing at whatever you passed as `--pairing-address` — so set\n`--pairing-address` to the externally reachable address and **pass `pairingCode` through unchanged; never\nhand-rewrite it**. Because serve runs in the foreground and doesn't exit, redirect its stdout to a file\nand poll until that file parses as JSON (and bail if the process dies — dump its stderr log). Your\n`create` script then prints that JSON (optionally merging `userData`). Concrete pattern: §7f.\n\n### 7d. Suspend / resume / destroy — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\npayload=\"$(cat)\" # Orca passes lifecycle JSON on stdin\nresource_id=\"$(node -e 'const d=JSON.parse(process.argv[1]); process.stdout.write(d.recipeResult?.userData?.resourceId ?? \"\")' \"$payload\")\"\n[ -n \"$resource_id\" ] || { echo \"No resource id in lifecycle payload\" >&2; exit 1; }\n# suspend: provider suspend \"$resource_id\"\n# resume: provider resume \"$resource_id\"; then RE-EMIT fresh recipe JSON (pairing may change)\n# destroy: provider remove \"$resource_id\" (or set destroy: none in orca.yaml)\n```\n\n### 7e. State file — scaffold with scope/project/repo filled in and snapshot ids empty (§6).\n\n### 7f. Worked example — Vercel Sandbox (all three phases)\n\nA real, working shape (the Vercel surface is a CLI: `vercel sandbox create|exec|snapshot|remove`). Adapt\nnames; verify flags against `vercel sandbox --help` for the user's CLI version before relying on them.\nThese ground §7a (base snapshot) and §7b (auth), which are otherwise generic skeletons.\n\n**Phase 2 — base snapshot (§7a):** provision → install tools + clone + headless build → snapshot.\n\n```bash\n# provision a fresh build sandbox (retain a couple of snapshots); trap-remove on error\nvercel sandbox create --name \"$base\" --runtime node24 --timeout 30m --vcpus 4 --publish-port \"$port\" \\\n --snapshot-expiration 30d --keep-last-snapshots 2 \"${vercel_args[@]}\" >&2\n# remote build (long timeout): install pkgs+gh+pnpm+agent CLI, clone with GIT_ASKPASS (write the helper\n# with LITERAL \\$1/\\$GH_TOKEN so they resolve at git-runtime, not write-time — see §5/§7f create — then\n# `rm -f /tmp/askpass.sh`), write the headless main-only build config (drop the renderer), dev setup,\n# build CLI + headless main, smoke-check\nvercel sandbox exec \"$base\" \"${vercel_args[@]}\" --timeout 25m --env \"GH_TOKEN=$gh_token\" … -- bash -lc '…build…' >&2\n# snapshot the STOPPED sandbox and parse the id from CLI output (fail if unparseable)\nout=\"$(vercel sandbox snapshot \"$base\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nsnapshot_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# merge { baseName, snapshotId, scope, project, port, repoUrl, repoRef, projectRoot } into state; print state JSON\n```\n\n**Phase 3 — agent-auth snapshot (§7b):** boot the base, log the agent in interactively, re-snapshot.\n(`codex` below is an example — substitute the user's chosen agent's login/status verbs, e.g. `claude`.)\n\n```bash\nvercel sandbox create --name \"$auth\" --snapshot \"$snapshot_id\" --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" >&2\n# INTERACTIVE — the USER runs this in their own terminal (you have no interactive TTY) and completes the\n# URL/code on the HOST. --device-auth is MANDATORY on a headless VM: plain `codex login` binds a loopback\n# callback port the host browser can't reach and hangs. Ask the user to report back when login finishes.\nvercel sandbox exec --interactive --tty \"$auth\" \"${vercel_args[@]}\" -- bash -lc 'codex login --device-auth'\n# refuse to snapshot an unauthenticated VM — fold stderr, match codex's exact success line (§4)\nvercel sandbox exec \"$auth\" \"${vercel_args[@]}\" --timeout 30s -- bash -lc 'codex login status 2>&1' | grep -Eqi 'Logged in using ChatGPT|Logged in via device' \\\n || { echo \"agent not logged in; not snapshotting\" >&2; exit 1; }\nout=\"$(vercel sandbox snapshot \"$auth\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nnew_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# overwrite state.snapshotId = new_id, record authSourceSnapshotId = snapshot_id; remove the auth sandbox\n```\n\n**Per-workspace `create`** (the fast path):\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback: snapshot_id, scope, project, port, repo_url, repo_ref, project_root\nvercel_args=(); [ -n \"$scope\" ] && vercel_args+=(--scope \"$scope\"); [ -n \"$project\" ] && vercel_args+=(--project \"$project\")\n[ -n \"$snapshot_id\" ] || { echo \"snapshotId missing — run Phases 2–3 first\" >&2; exit 1; }\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nrecipe_id=\"${ORCA_RECIPE_ID:-vercel-sandbox}\"\nrecipe_id=\"${recipe_id//./-}\" # Vercel names forbid dots.\ninstance_id=\"${ORCA_VM_INSTANCE_ID:-$(date +%s)}\"\nmax_recipe_id_length=$((128 - ${#instance_id} - 6)) # Preserve the unique instance suffix.\n[ \"$max_recipe_id_length\" -gt 0 ] || { echo \"ORCA_VM_INSTANCE_ID is too long for a Vercel sandbox name\" >&2; exit 1; }\nname=\"orca-${recipe_id:0:max_recipe_id_length}-${instance_id}\"\n\n# Arm cleanup BEFORE create so a failing create can't leak a half-built paid sandbox.\ncleanup_on_error() { [ \"$?\" -ne 0 ] && vercel sandbox remove \"$name\" \"${vercel_args[@]}\" >/dev/null 2>&1 || true; }\ntrap cleanup_on_error EXIT\n\n# 1. boot from the authenticated snapshot, publish the serve port\ncreate_output=\"$(vercel sandbox create --name \"$name\" --snapshot \"$snapshot_id\" \\\n --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$create_output\" >&2\n# Vercel prints the published https URL; derive the external wss:// pairing address from it\npublic_url=\"$(printf '%s\\n' \"$create_output\" | sed -nE 's#.*(https://[^[:space:]]+\\.vercel\\.run).*#\\1#p' | head -1)\"\n[ -n \"$public_url\" ] || { echo \"no published URL in create output\" >&2; exit 1; }\npairing_ws=\"${public_url/https:\\/\\//wss://}\"\n\n# 2. (remote) ensure the repo is at the right commit; rebuild only if the commit changed (cache marker)\nvercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 20m \\\n --env \"GH_TOKEN=$gh_token\" --env \"ORCA_PROJECT_ROOT=$project_root\" \\\n --env \"ORCA_REPO_URL=$repo_url\" --env \"ORCA_REPO_REF=$repo_ref\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; \\\n # Re-establish git auth for the private-repo fetch (why + full rationale: §5); else it hangs on a prompt.\n # Load-bearing escaping: \\$1 and \\$GH_TOKEN must land LITERALLY and resolve at git-runtime. Test after\n # any edit here — reformatting the nested printf/node quoting silently breaks the fetch or leaks the token.\n if [ -n \"${GH_TOKEN:-}\" ]; then \\\n printf \"%s\\n\" \"#!/usr/bin/env bash\" \"case \\\"\\$1\\\" in *Username*) echo x-access-token;; *Password*) echo \\\"\\$GH_TOKEN\\\";; esac\" > /tmp/askpass.sh; \\\n chmod 700 /tmp/askpass.sh; export GIT_ASKPASS=/tmp/askpass.sh GIT_TERMINAL_PROMPT=0; fi; \\\n git fetch origin \"$ORCA_REPO_REF\"; \\\n git checkout -B \"$ORCA_REPO_REF\" FETCH_HEAD; \\\n rm -f /tmp/askpass.sh; \\\n c=\"$(git rev-parse HEAD)\"; [ -f .orca-built ] && [ \"$(cat .orca-built)\" = \"$c\" ] || { \\\n pnpm install --prefer-offline && pnpm run build:cli && \\\n node config/scripts/run-electron-vite-build.mjs --config config/electron-vite.vm-serve.config.ts && \\\n printf \"%s\" \"$c\" > .orca-built; }' >&2\n\n# 3. (remote) start orca serve in the background, writing recipe JSON to a file; poll until it parses\nrecipe_json=\"$(vercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 60s \\\n --env \"ORCA_PORT=$port\" --env \"ORCA_PROJECT_ROOT=$project_root\" --env \"ORCA_PAIRING_ADDRESS=$pairing_ws\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; rm -f /tmp/orca-recipe.json /tmp/orca-serve.log; \\\n nohup pnpm exec orca-dev serve --port \"$ORCA_PORT\" --project-root \"$ORCA_PROJECT_ROOT\" \\\n --pairing-address \"$ORCA_PAIRING_ADDRESS\" --recipe-json >/tmp/orca-recipe.json 2>/tmp/orca-serve.log /dev/null 2>&1 && { cat /tmp/orca-recipe.json; exit 0; }; \\\n kill -0 \"$pid\" 2>/dev/null || { cat /tmp/orca-serve.log >&2; exit 1; }; sleep 0.25; \\\n done; cat /tmp/orca-serve.log >&2; echo \"serve recipe JSON timed out\" >&2; exit 1')\"\n\n# 4. print serve's JSON enriched with userData (single object on stdout)\nnode -e 'const p=JSON.parse(process.argv[1]); console.log(JSON.stringify({...p, schemaVersion:1,\n userData:{...p.userData, provider:\"vercel-sandbox\", resourceId:process.argv[2], snapshotId:process.argv[3]}}))' \\\n \"$recipe_json\" \"$name\" \"$snapshot_id\"\ntrap - EXIT\n```\n\n`suspend`/`resume`/`destroy` use `vercel sandbox stop|...|remove \"$resource_id\"` reading\n`userData.resourceId` from stdin (§7d). This is the **Orca-server** connection mode (the recipe emits a\npairing URL). If the user chose **SSH** in the §1 interview, use §7g instead.\n\n### 7g. Worked example — existing SSH host (SSH connection mode)\n\nSSH mode is **fundamentally different from §7c/§7f**, not a relabeling of them:\n\n- **`create` does NOT run `orca serve` and does NOT emit a `pairingCode`.** Orca itself connects to the\n host over its SSH relay, brings up the git + filesystem providers, and imports the repo. The script's\n only job is to make the host ready and **print SSH connection details** Orca will dial.\n- The result uses a `connection` block with `type: \"ssh\"` and a `target`, **not** the flat\n `pairingCode`/`projectRoot` shape. Exact shape (Orca rejects anything else):\n\n```json\n{\n \"schemaVersion\": 1,\n \"connection\": {\n \"type\": \"ssh\",\n \"projectRoot\": \"/abs/path/to/repo/on/host\",\n \"target\": {\n \"label\": \"my-box\",\n \"host\": \"192.0.2.10\",\n \"port\": 22,\n \"username\": \"ubuntu\",\n \"identityFile\": \"~/.ssh/id_ed25519\",\n \"jumpHost\": \"bastion.example.com\",\n \"proxyCommand\": \"cloudflared access ssh --hostname %h\",\n \"relayGracePeriodSeconds\": 0,\n \"portForwards\": []\n }\n }\n}\n```\n\n`label`, `host`, `port`, `username` are required; the rest are optional — omit any you don't need.\n\nFor an explicitly requested one-VM-per-workspace checkout, the create script must read\n`ORCA_RECIPE_RESULT_SCHEMA_VERSION`, `ORCA_REPO_URL`, `ORCA_REPO_REF`, `ORCA_REPO_REF_HEAD`, and\n`ORCA_REPO_BRANCH`. Use `ORCA_REPO_REF` to fetch the selected source, but create\n`ORCA_REPO_BRANCH` at the exact `ORCA_REPO_REF_HEAD` commit; resolving the symbolic ref again can race\nwith an upstream update. `ORCA_REPO_URL` and `ORCA_REPO_REF` are a matched fetch pair, including when\nthe desktop source uses multiple remotes. Return that primary checkout at `projectRoot` and emit the\nsame SSH result with:\n\n```bash\n[ -n \"${ORCA_REPO_REF_HEAD:-}\" ] || { echo \"missing pinned source commit\" >&2; exit 1; }\ngit fetch origin \"$ORCA_REPO_REF\"\ngit cat-file -e \"${ORCA_REPO_REF_HEAD}^{commit}\"\ngit checkout -B \"$ORCA_REPO_BRANCH\" \"$ORCA_REPO_REF_HEAD\"\n```\n\n```json\n{\n \"schemaVersion\": 2,\n \"checkoutMode\": \"provisioned-root\",\n \"connection\": {\n \"type\": \"ssh\",\n \"projectRoot\": \"/abs/repo\",\n \"target\": { \"label\": \"my-box\", \"host\": \"192.0.2.10\", \"port\": 22, \"username\": \"ubuntu\" }\n }\n}\n```\n\nFail if the requested schema is not `2`; do not silently fall back to the ordinary recipe shape.\n\n**Networking → which `target` fields to set** (how *your desktop* reaches the box — there is no\n`orca serve` URL in SSH mode):\n\n- Public IP / DNS, or a Tailscale/VPN address → `host`; SSH port → `port` (usually 22).\n- Key auth → `identityFile` (add `identitiesOnly: true` if the agent has many keys).\n- Through a bastion → `jumpHost` (a `user@host` ProxyJump) **or** a full `proxyCommand` (e.g. an access\n proxy). Use one, not both.\n- A service port the workspace needs → add entries to `portForwards`.\n- `relayGracePeriodSeconds` (optional): how long Orca keeps the SSH relay alive after the workspace\n detaches before tearing it down; `0` = tear down immediately. Leave it off unless the user wants a\n reconnect grace window.\n\n**Toolchain & agent auth on a persistent (no-snapshot) host — do this ONCE, by hand, before wiring the\nrecipe** (there's no base image to bake; the host *is* the base). Run the §7f Phase-2 install steps and\nthe §7f Phase-3 ` login --device-auth` **directly over SSH on the host** (interactive, e.g.\n`ssh -t user@host ' login --device-auth'`). After that the host stays ready across workspaces.\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback (default unset optionals to \"\"): ssh_username, host,\n# ssh_port (default 22), identity_file, jump_host, proxy_command, project_root, repo_url, repo_ref\n: \"${identity_file:=}\"; : \"${jump_host:=}\"; : \"${proxy_command:=}\" # avoid set -u aborts on optionals\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nssh_target=\"${ssh_username}@${host}\"\nssh_opts=(-p \"$ssh_port\"); [ -n \"$identity_file\" ] && ssh_opts+=(-i \"$identity_file\")\n# Why: a fresh host's key isn't in known_hosts; a StrictHostKeyChecking prompt would HANG a\n# non-interactive create. Pre-add the key (or set the option) so it can't block.\nssh-keyscan -p \"$ssh_port\" \"$host\" >> \"$HOME/.ssh/known_hosts\" 2>/dev/null || true\n\n# 1. ensure the repo is present and at the right commit on the host (NO orca serve here)\nssh \"${ssh_opts[@]}\" \"$ssh_target\" \\\n \"GH_TOKEN='$gh_token' GIT_TERMINAL_PROMPT=0 bash -lc '\n set -euo pipefail\n [ -d \\\"$project_root/.git\\\" ] || git clone \\\"$repo_url\\\" \\\"$project_root\\\"\n cd \\\"$project_root\\\" && git fetch origin \\\"$repo_ref\\\" && git checkout -B \\\"$repo_ref\\\" FETCH_HEAD\n '\" >&2\n\n# 2. print the SSH connection block (NO pairingCode, NO orca serve). host/port/username tell Orca's\n# relay how to dial in; identityFile/jumpHost/proxyCommand/portForwards are emitted when set.\nnode -e 'const [host,port,user,idf,jh,pc,root]=process.argv.slice(1);\n const target={ label:\"per-workspace-host\", host, port:Number(port), username:user };\n if(idf) target.identityFile=idf; if(jh) target.jumpHost=jh; if(pc) target.proxyCommand=pc;\n // add target.portForwards=[...] here if the workspace needs forwarded service ports\n console.log(JSON.stringify({ schemaVersion:1, connection:{ type:\"ssh\", projectRoot:root, target } }))' \\\n \"$host\" \"$ssh_port\" \"$ssh_username\" \"$identity_file\" \"$jump_host\" \"$proxy_command\" \"$project_root\"\n```\n\n`suspend`/`resume`/`destroy`: on a persistent host there's usually nothing to tear down — set\n`destroy: none` and omit suspend/resume. (Orca still disconnects/reconnects its own SSH relay on\nsleep/wake/delete — that's separate from these scripts.)\n\nIf the SSH host is instead an **ephemeral/snapshot-capable VM** (your hypervisor, or a cloud VM with\nimage support), keep the §7f Phase-2/3 base-image model for provisioning, but still emit the\n`connection.type:\"ssh\"` block above instead of starting `orca serve`.\n\n### 7h. Worked example — local Docker SSH (SSH connection mode)\n\nLocal Docker can model an ephemeral SSH VM without cloud cost: build a base image with `sshd`, tools,\nrepo prerequisites, and the agent CLI; run an **interactive auth container** once; then `docker commit`\nthat container as the authenticated image used by per-workspace `create`.\n\nKey points:\n\n- Publish container SSH to a random localhost port (`-p 127.0.0.1::22`) and emit\n `connection.type:\"ssh\"` with `host:\"127.0.0.1\"`, that port, `username`, `identityFile`, and\n `identitiesOnly:true`.\n- Generate a repo-local SSH key if needed, but gitignore the private/public key files.\n- **Bake SSH host keys into the base image** (`ssh-keygen -A` at **build** time; at runtime only generate\n if absent). Ephemeral containers all present the **same** host key, so `known_hosts` on `127.0.0.1`\n doesn't churn as the published port rotates across workspaces (otherwise every container's freshly\n generated key collides on `localhost` and trips host-key-changed warnings).\n- The auth image is the Docker equivalent of Phase 3: the **user** runs the agent login **inside** the\n container (you can't drive it — you have no interactive TTY), configures proxy env/config, approves\n hooks, and you commit once they report it's done. On a headless container use the **device-auth** flow\n (§4). Verify login before committing — exit code, or fold stderr and match the exact success line (§4).\n- Do not bind-mount or copy the host's full agent home into the image. Let each container have writable\n agent state; only the committed auth image should carry reusable authenticated state.\n- If committing from an interactive shell, force the runtime entrypoint back to `sshd`:\n `docker commit --change='ENTRYPOINT [\"/usr/local/bin/orca-docker-ssh-entrypoint\"]' …`.\n- `destroy` should read `recipeResult.userData.resourceId` and run `docker rm -f \"$resource_id\"`.\n\nValidation before wiring/live use:\n\n```bash\ndocker image inspect \"$auth_image\" --format '{{json .Config.Entrypoint}}'\ndocker run -d --name \"$name\" -p 127.0.0.1::22 -e \"ORCA_SSH_PUBLIC_KEY=$pubkey\" \"$auth_image\"\ndocker ps -a --filter \"name=$name\"\ndocker logs \"$name\"\nssh -i \"$key\" -p \"$port\" -o IdentitiesOnly=yes user@127.0.0.1 'codex --version'\n```\n\nIf the container exits immediately, inspect logs before the cleanup trap removes it; a committed\ninteractive image with `ENTRYPOINT [\"bash\"]` is a common cause.\n\nAlso confirm the **host key is stable** across containers: the SSH `ssh -i … 127.0.0.1` dial should not\ntrigger a host-key-changed warning when a second container reuses the port. If it does, the host keys\nweren't baked into the base image (see the `ssh-keygen -A` point above).\n\n### 7i. Windows local-side scripts\n\nThe local-side scripts run on the user's desktop. On **Windows**, a bare `.sh` won't execute. Either\nrequire WSL/Git-Bash (and point `orca.yaml` at e.g. `bash ./scripts/orca-vm/.sh` via a `.cmd`\nlauncher), or scaffold PowerShell equivalents. Minimal PowerShell shape:\n\n```powershell\n#requires -Version 5\n$ErrorActionPreference = 'Stop'\n# resolve env→state→fallback; run the provider CLI / ssh the same way;\n# capture provider output; build the result object for the chosen mode and write ONE line of JSON to stdout.\n# Orca-server mode: @{ schemaVersion=1; pairingCode=$pairingCode; projectRoot=$projectRoot; userData=@{...} }\n# SSH mode: @{ schemaVersion=1; connection=@{ type=\"ssh\"; projectRoot=$projectRoot;\n# target=@{ label=$label; host=$host; port=$port; username=$user } } } (see §7g/§7h)\n($result | ConvertTo-Json -Compress -Depth 6)\n# progress/errors → Write-Error / the error stream, never stdout.\n```\n\nThe remote-side commands you run *inside* the Linux VM stay bash regardless of the desktop OS.\n\n---\n\n## 8. Per-workspace recipe contract (the fast path)\n\nOnce the authenticated snapshot exists, this runs on every workspace create. Define recipes in\n`orca.yaml`:\n\n```yaml\nenvironmentRecipes:\n - id: cloud-sandbox\n name: Cloud Sandbox\n create: ./scripts/orca-vm/cloud-sandbox-create.sh\n suspend: ./scripts/orca-vm/cloud-sandbox-suspend.sh\n resume: ./scripts/orca-vm/cloud-sandbox-resume.sh\n destroy: ./scripts/orca-vm/cloud-sandbox-destroy.sh\n```\n\n`create` runs **locally from the repo root** and prints **one** JSON object to stdout. Its shape depends\non the connection mode chosen in §1:\n\n**Orca-server mode** — boot the env, start `orca serve` in it, and print serve's result:\n\n```json\n{\n \"schemaVersion\": 1,\n \"pairingCode\": \"orca-pairing-code-or-url\",\n \"projectRoot\": \"/absolute/path/to/repo/on/remote\",\n \"userData\": { \"provider\": \"example\", \"resourceId\": \"provider-resource-id\" }\n}\n```\n\nHere `pairingCode` (from `orca serve --recipe-json`) and `projectRoot` are required; `schemaVersion` (`1`)\nand `userData` are optional.\n\n**SSH mode** — do **not** run `orca serve`; print the `connection.type:\"ssh\"` block instead (full shape +\nworked script in §7g). `pairingCode` is **not** used in SSH mode.\n\n**Optional provisioned root** — only for direct SSH and only when explicitly requested. Add\n`checkoutMode: provisioned-root` to the recipe, require `ORCA_RECIPE_RESULT_SCHEMA_VERSION=2`, create\nthe requested `ORCA_REPO_BRANCH` at the pinned `ORCA_REPO_REF_HEAD` commit (use `ORCA_REPO_REF` only\nto fetch that commit) at the returned `projectRoot`, and emit schema version 2 with\n`checkoutMode: \"provisioned-root\"`. All recipes without this field retain the schema-v1 behavior above.\n\nLifecycle hooks (all run locally):\n\n- `create`: required. Prints recipe result JSON.\n- `suspend`: optional. Sleep; reads lifecycle payload on stdin.\n- `resume`: optional. Wake; reads payload on stdin and **prints fresh recipe JSON** (pairing may change).\n- `destroy`: optional unless `destroy: none`. Delete/cleanup; reads payload on stdin.\n\nStart Orca remotely with `orca serve --port \"$PORT\" --project-root \"$ABS_ROOT\" --pairing-address\n\"$EXTERNAL_WSS_URL\" --recipe-json` (exact flags + output in §7c). Set `--pairing-address` to the\nexternally reachable address so the emitted `pairingCode` is reachable; tunneling/port mapping is the\nscript's job.\n\nBackward compatibility: `command`→`create`, `cleanup`→`destroy`, `cleanup: none`→`destroy: none`.\nPrefer the lifecycle names.\n\n---\n\n## 9. Doctor and validation\n\nValidate in two stages — the cheap dry run first, then the live self-test.\n\n### Dry run (free, non-destructive) — always do this first\n\n`orca vm recipe doctor --repo-path --json` validates **static wiring only** — it does\n**not** boot anything. It checks: local-host execution (v1), repo path, recipe id exists,\ncreate/destroy/suspend/resume command paths resolve, suspend/resume are paired, and each script is\nexecutable (POSIX exec bit; skipped on Windows). Fix every failure here before spending any cloud money.\n\n### Live self-test (`--provision`) — diagnose and iterate yourself\n\n`orca vm recipe doctor --repo-path --provision --json` actually runs the recipe end\nto end: it executes `create`, validates the returned recipe JSON, then runs `destroy` to **tear the\nenvironment back down** (so the test leaves nothing running, as long as `destroy` works). It spends real\ncloud money, so get the user's OK **once** before starting — that one approval covers the whole loop\nbelow; do not re-ask before each run.\n\nOn failure, the JSON result includes a `provisionTranscript` with the **complete** captured output of\neach stage so you can self-diagnose without asking the user to relay logs:\n\n```json\n{\n \"ok\": false,\n \"checks\": [ { \"id\": \"recipe.provision\", \"status\": \"fail\", \"message\": \"…\" } ],\n \"provisionTranscript\": {\n \"provision\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\", \"parseError\": \"…\" },\n \"destroy\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\" }\n }\n}\n```\n\n**Run it as a loop:** read `provisionTranscript.provision.stderr` / `.stdout` / `.parseError` (and\n`destroy.*`), fix the script, and re-run `--provision` until `ok` is `true` — iterating on your own\nrather than waiting for the user to paste errors. Common reads: a non-empty `stderr` with `exitCode 0`\nplus a `parseError` means `create` ran but printed something other than the single recipe-result JSON on\nstdout (often a stray `echo` — route it to stderr, see §10); a non-zero `exitCode` is a provider/script\nfailure described in `stderr`. Each stream is redacted and capped (head+tail) — large logs keep both the\nsetup context and the failure.\n\nThe self-test cannot see provider-side truth beyond what the scripts print, so still confirm: state has a\npopulated **authenticated** `snapshotId` (Phases 2–3 done), and `destroy` is implemented/tested (or\nexplicitly `none` — in which case the self-test won't tear down, so clean up manually).\n\nFor SSH recipes, also smoke-test the exact emitted target before declaring success: dial the host/port\nwith the identity/proxy settings, run `pwd`, verify the repo path, check the agent binary, and confirm\n`destroy` removes the provider resource/container. For Docker, inspect the auth image entrypoint and do a\nstartup-only `docker run` before the full clone/install path.\n\n---\n\n## 10. Failure modes\n\n- **Build exceeds plan timeout (e.g. Hobby 45m).** Use enough vCPUs and a timeout covering the build;\n else split work or use a higher plan. The cap also limits per-workspace runtime — surface it.\n- **Build exceeds plan RAM.** Build the **headless main only** (drop the renderer) — the biggest fitter.\n- **Private-repo clone hangs/fails.** Wrong/missing token. Use `GIT_ASKPASS` + `GIT_TERMINAL_PROMPT=0`\n so it fails fast instead of prompting.\n- **`GIT_ASKPASS` helper aborts the clone with \"`$1: unbound variable`\".** The `printf`/heredoc that writes\n the helper inside `bash -lc` under `set -u` expanded `$1`/`$GH_TOKEN` at **write** time. Escape them\n (`\\$1`, `\\$GH_TOKEN`) so they land literally and resolve at git-runtime; this also keeps the real token\n out of the file. `rm -f` the helper afterward (§5, §7f).\n- **Agent verified as \"not logged in\" despite a good login.** `codex login status` (and similar) print\n \"Logged in …\" to **stderr**; an stdout-only `grep` misses it. Prefer the status **exit code**; if you\n grep, fold stderr first (`status 2>&1 | grep …`) and match the exact success line — not `grep -qi\n 'logged in'`, which also matches \"not logged in\".\n- **Headless agent login hangs.** Plain OAuth `login` starts a loopback callback server on a VM/container\n port the host browser can't reach. Use the **device-auth** flow (`login --device-auth`) — it prints a\n URL + code the user opens on the host.\n- **`known_hosts` host-key churn on local Docker.** Each ephemeral container regenerating its SSH host key\n collides on `127.0.0.1` as the published port rotates. Bake host keys into the base image at build time\n (`ssh-keygen -A`; runtime generates only if absent) so all containers share one stable key (§7h).\n- **Snapshot expired/evicted.** If `create` hits an unknown snapshot id, rerun Phases 2–3 and update\n `snapshotId`.\n- **Agent auth didn't persist.** Confirm `snapshotId` points at the **authenticated** snapshot; re-run\n Phase 3. Warn that short-lived tokens may need periodic re-auth.\n- **Agent auth copied from the host breaks.** Do not bind-mount/copy a full host agent home; sqlite\n files can be unwritable or host-specific, hooks may need approval again, and config may reference\n local-only env vars. Authenticate inside the runtime and snapshot/commit that layer.\n- **Docker auth image exits immediately.** Inspect `docker image inspect … .Config.Entrypoint` and\n `docker logs`. If the image was committed from an interactive shell, reset the entrypoint to the SSH\n entrypoint during `docker commit`.\n- **Leaked paid resource.** Every long script must trap errors and remove the sandbox it created.\n- **`create` emits non-JSON on stdout.** A stray `echo` corrupts the result — stdout is for the final\n JSON only; everything else to stderr. The `--provision` self-test surfaces this as `exitCode 0` + a\n `parseError` with the offending stdout in `provisionTranscript` (§9).\n\n---\n\n## 11. Boundaries\n\n- Don't create accounts, choose plans/regions, or invent scope/project/org/image/billing ids.\n- Don't invent or store credentials; no secrets in `userData`, state, comments, docs, or commits.\n- Don't run paid/long phases (base snapshot, auth, live test) without an explicit OK.\n- Don't hide provider errors behind generic messages — preserve actionable stderr.\n- Don't make Orca own provider lifecycle beyond invoking the configured scripts.\n- Don't commit or create an Orca workspace unless asked.\n" // oxfmt-ignore -const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, coordinator loops, or decomposing work\n across agents. Use `orca-cli` instead for full ownership handoffs, including\n requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", or \"another worktree\" when the user did not explicitly ask to\n supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for\n ordinary terminal control, lightweight terminal prompts, shell commands, Orca\n worktree management, reading or waiting on terminals, and automation of the\n browser embedded inside Orca. Use Computer Use for browser windows, webviews,\n Orca app UI, or desktop UI outside Orca's embedded browser.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Contract Migration\n\nOrca adopts a live pre-update orchestration assignment into an ordinary Run. Adoption preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch; it never restarts or replaces the worker. The retired scheduler is not revived, and a newly created attempt uses the current grammar.\n\nTreat the authority label on injected or formatted messages as definitive:\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported command printed with the message, using the same CLI executable and arguments that the original prompt supplied.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded, at-least-once cutover replay. Process it idempotently and acknowledge it only through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or lifecycle action.\n- An unlabeled current message uses the current guide and current grammar.\n\nAn explicitly selected current Run, attested current Run binding, current Dispatch, or federated attachment takes precedence over legacy fallback. A retained adoption record alone never turns a current command into a legacy call.\n\nDatabase provenance, an old-looking terminal, or a legacy Run ID does not prove mutation authority. If the runtime cannot prove liveness, principal ownership, capability, or the exact legacy contract, it degrades to read-only inspection and must not fall back to local execution. Exact recovery may restore the already-live PTY once in its original inactive background tab. It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal. Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.\n\nCompatibility retries have narrow guarantees. A pending ask, a reply, a final Dispatch settlement, and a consuming check have durable recovery identities. A-era heartbeat and escalation calls remain at-least-once across a manual A-to-B retry because identical later signals may be intentional. If an A-era ask may already have been answered, run the exact non-consuming recovery check printed by the runtime first; after its answer is printed and acknowledged, a new invocation with the same question creates a new question. Never guess among multiple identical question threads.\n\nWhen a compatibility or recovery command returns structured next-step arguments, run those exact arguments with the same CLI executable. The arguments intentionally omit the executable name so the guidance works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command. Do not translate the command from memory, broaden its recipient, or retry it as a current mutation unless the returned guidance explicitly says to.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The initial command durably commits the question, prints its exact `ask --resume ` command, and exits with launcher status `75`; it does not wait for the answer. Run that exact resume command after the launcher or update boundary. Resume is idempotent and read-oriented: it waits for the already-committed question and does not create another one. For a WSL process that received compatibility proof at launch, use the printed executable `orca-ide` WSL resume command so the same distro and packaged launcher authority are preserved; do not substitute a PATH-resolved local CLI. Older WSL processes that never received the hidden launch token remain lifecycle read-only after the update, even while their terminal and filesystem work continue.\n\nLegacy inspection remains available without consuming mail:\n\n```bash\norca orchestration run-list --json\n# run_legacy_local is an empty audit tombstone after adoption.\norca orchestration run-show --id run_legacy_local --json\n# In run-list, find the ordinary Run whose objective is:\n# \"Recovered orchestration work from a contract update\"\norca orchestration run-show --id --json\norca orchestration task-list --run --json\norca orchestration inbox --full --json\norca orchestration check --terminal --peek --format --json\norca terminal read --terminal --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\nIf the original coordinator is unavailable or cannot prove its retained authority, a current coordinator may explicitly take over the adopted Run from its own live agent terminal:\n\n```bash\norca orchestration run-use --id --takeover-legacy --json\norca orchestration check --run --json\n```\n\nTakeover fences only the old coordinator, binds the current one, and moves pending worker mail into current Run Delivery. It is bound to the authenticated invoking terminal; `--from` cannot name another coordinator. Live legacy workers keep their original Tasks, Dispatches, processes, filesystems, and old prompt commands; their later questions, escalations, and completion reports route to the current coordinator. Do not use takeover while the original coordinator is still actively coordinating, because its later lifecycle mutations are rejected.\n\nDo not launch a replacement editor merely because the desktop app or runtime was updated. If adoption cannot prove continuing authority, keep the original worker as the only editor until it reaches a stable handoff point, then use a new current Dispatch in a conflict-free placement for any remaining work.\n\n## Ownership\n\nNew orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --subject [--to ] [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--ack ] [--peek|--all] [--types ] [--format] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask (--question |--resume ) [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack `. Process every message before acknowledging; `check --ack --wait` acknowledges, checks, and waits in one operation.\n- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.\n- Use `dispatch:` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.\n- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.\n- `terminal list --json` omits `visualLayouts` because handle recovery does not need topology. Add `--include-visual-layouts` only for explicit tab and pane inspection.\n- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms ` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id --body --json`, then acknowledge and keep waiting.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again.\n- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop.\n\n```bash\norca orchestration run-create --objective --json\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## Preferred Supervised Worker Loop\n\nUse `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.\n\nCreate the Run and every independent Task first, then start all independent workers before waiting:\n\n```bash\norca orchestration run-create --objective \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration worker-start --task --worktree current --agent codex --json\norca orchestration worker-start --task --worktree current --agent claude --json\n```\n\n`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `.\n\nFor a per-invocation Claude, Codex, or Cursor launch, pass an opaque provider model id with `--model`; add `--effort` only when that agent/model supports the level. These options apply only to fresh agent terminals, override general agent default arguments, and are reported under `launch.requested` and `launch.effective` in the receipt:\n\n```bash\norca orchestration worker-start --task --worktree current --agent claude --model opus --effort high --json\n```\n\n`--effort` requires `--model`, and neither option can combine with `--terminal`. A connected worker server must advertise launch-preference support before Orca forwards either option.\n\nFor a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:\n\n```bash\norca orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n# Independent/top-level:\norca orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nSetup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason.\n\nRead the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure.\n\nTo run the worker on another connected Orca server, add `--on `. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`:\n\n```bash\n# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home)\norca orchestration worker-start --task --on windows --worktree new-top-level --repo --name --agent codex --setup run --json\norca orchestration worker-show --dispatch --json\norca orchestration worker-read --dispatch --limit 50 --json\norca orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nRemote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector.\n\nThe follow-up is structured inbox mail, not prompt injection. The worker's next\n`orchestration check` receives it even when the Dispatch is on another connected Orca server.\n\n`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: \"terminal\"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path.\n\nWait until every expected Dispatch settles, not for a fixed number of batches:\n\n```bash\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n# Process every message. For each accepted worker_done that is not immediately reused:\norca orchestration worker-release --dispatch --json\n# Acknowledge only after every message and required release decision is handled:\norca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\nAfter processing each accepted `worker_done`, choose the terminal's next owner before you acknowledge the Delivery or wait again. If the same exact agent has an immediate follow-up Task, read the `worker.agent_terminal_handle` field of `worker-show --dispatch --json`, then run `orca orchestration worker-start --task --terminal --json` so Orca transfers cleanup ownership to the new Dispatch. Otherwise run `orca orchestration worker-release --dispatch --json`.\n\nRun `worker-release` after both succeeded and failed `worker_done` reports unless the user explicitly asked to keep that worker live. Release is post-completion cleanup, not cancellation: Orca first preserves inspectable output, then closes only the exact agent terminal owned by that settled Dispatch. Reused or pre-existing terminals, setup terminals, coordinators, active workers, user-taken-over terminals, and identities Orca cannot prove are retained. If the user explicitly asks to keep the live terminal for debugging, record that exception with `orca orchestration worker-retain --dispatch --json` instead of silently skipping cleanup. When the user is finished, the same Dispatch can be passed to `worker-release`, which clears the requested retention and releases the terminal.\n\nDo not release a worker because of a timeout, TUI idle state, heartbeat, status, question, escalation, or rejected/stale `worker_done`. If release returns `release_pending` or `release_unknown`, do not substitute `terminal close`; follow the exact recovery action in the receipt. A replayed Delivery may repeat `worker-release` safely.\n\nWorkers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:\n\n```bash\norca orchestration send --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a,path/b\" --json\n# On failure, use --outcome failed; never encode failure only in prose.\n```\n\nA worker question defaults to its owning Run. Timeout leaves it pending:\n\n```bash\norca orchestration ask --question \"\" --options \"yes,no\" --timeout-ms 600000 --json\norca orchestration ask --resume --timeout-ms 600000 --json\n# Coordinator:\norca orchestration reply --id --body \"\" --json\n```\n\nRecovery is conditional, never a fixed destructive sequence:\n\n- `worker-show --dispatch ` says `ready`: keep waiting or read bounded output.\n- It proves `failed` or `stopped`: start a replacement with `worker-start --task --retry-of ` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement.\n- It remains `outcome_unknown`: either `worker-stop --dispatch ` and inspect again, or explicitly `worker-abandon --dispatch ` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action.\n- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes.\n\nLow-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.\n\n`dispatch --inject` deliberately keeps an operator-started terminal unsupervised: it never creates a `worker_dispatches` row and `worker-stop`/`worker-abandon` never close that process. The dispatch context is still authoritative, so `worker-show`, `worker-read`, and `worker-list` report it as `unsupervised`; settled `worker-retain` and `worker-release` report `retained` with `no_owned_resource` and take no process action. Use `worker-start --terminal ` when supervision and worker lifecycle state are required.\n\n## Gates And Legacy Inspection\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\n```\n\nUse `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\n`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --setup run --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nThe two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --setup run --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.\n\nWhen a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\nFor every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees.\n\n```bash\norca worktree create --name --agent codex --setup run --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from agentTerminalHandle, falling back to startupTerminal.handle.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--include-visual-layouts] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:\n `orca orchestration send --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a\" --report-path \"\" --json`\n- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.\n- After sending `worker_done`, end that dispatched turn and idle at the agent prompt. Do not autonomously start more work, poll, or attempt to close the terminal yourself. A direct user instruction takes precedence and starts ordinary user-owned work: follow it without coordinator approval or a fresh Dispatch, never refuse it because of worker/coordinator roles, and do not reuse the settled Dispatch's lifecycle IDs. A coordinator-supervised follow-up still arrives with a fresh preamble + TASK block.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators must account for every settled worker terminal before waiting again or ending the turn: immediately reuse the exact worker for a new Dispatch, explicitly retain it at the user's request with `worker-retain`, or run `worker-release`. Do not leave a completed worker live merely to inspect output; released workers remain readable through `worker-read`.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology. After every accepted `worker_done`, either transfer the exact terminal to an immediate follow-up Dispatch or run `worker-release` before the next wait.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" +const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, coordinator loops, or decomposing work\n across agents. Use `orca-cli` instead for full ownership handoffs, including\n requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", or \"another worktree\" when the user did not explicitly ask to\n supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for\n ordinary terminal control, lightweight terminal prompts, shell commands, Orca\n worktree management, reading or waiting on terminals, and automation of the\n browser embedded inside Orca. Use Computer Use for browser windows, webviews,\n Orca app UI, or desktop UI outside Orca's embedded browser.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Contract Migration\n\nOrca adopts a live pre-update orchestration assignment into an ordinary Run. Adoption preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch; it never restarts or replaces the worker. The retired scheduler is not revived, and a newly created attempt uses the current grammar.\n\nTreat the authority label on injected or formatted messages as definitive:\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported command printed with the message, using the same CLI executable and arguments that the original prompt supplied.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded, at-least-once cutover replay. Process it idempotently and acknowledge it only through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or lifecycle action.\n- An unlabeled current message uses the current guide and current grammar.\n\nAn explicitly selected current Run, attested current Run binding, current Dispatch, or federated attachment takes precedence over legacy fallback. A retained adoption record alone never turns a current command into a legacy call.\n\nDatabase provenance, an old-looking terminal, or a legacy Run ID does not prove mutation authority. If the runtime cannot prove liveness, principal ownership, capability, or the exact legacy contract, it degrades to read-only inspection and must not fall back to local execution. Exact recovery may restore the already-live PTY once in its original inactive background tab. It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal. Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.\n\nCompatibility retries have narrow guarantees. A pending ask, a reply, a final Dispatch settlement, and a consuming check have durable recovery identities. A-era heartbeat and escalation calls remain at-least-once across a manual A-to-B retry because identical later signals may be intentional. If an A-era ask may already have been answered, run the exact non-consuming recovery check printed by the runtime first; after its answer is printed and acknowledged, a new invocation with the same question creates a new question. Never guess among multiple identical question threads.\n\nWhen a compatibility or recovery command returns structured next-step arguments, run those exact arguments with the same CLI executable. The arguments intentionally omit the executable name so the guidance works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command. Do not translate the command from memory, broaden its recipient, or retry it as a current mutation unless the returned guidance explicitly says to.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The initial command durably commits the question, prints its exact `ask --resume ` command, and exits with launcher status `75`; it does not wait for the answer. Run that exact resume command after the launcher or update boundary. Resume is idempotent and read-oriented: it waits for the already-committed question and does not create another one. For a WSL process that received compatibility proof at launch, use the printed executable `orca-ide` WSL resume command so the same distro and packaged launcher authority are preserved; do not substitute a PATH-resolved local CLI. Older WSL processes that never received the hidden launch token remain lifecycle read-only after the update, even while their terminal and filesystem work continue.\n\nLegacy inspection remains available without consuming mail:\n\n```bash\norca orchestration run-list --json\n# run_legacy_local is an empty audit tombstone after adoption.\norca orchestration run-show --id run_legacy_local --json\n# In run-list, find the ordinary Run whose objective is:\n# \"Recovered orchestration work from a contract update\"\norca orchestration run-show --id --json\norca orchestration task-list --run --json\norca orchestration inbox --full --json\norca orchestration check --terminal --peek --format --json\norca terminal read --terminal --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\nIf the original coordinator is unavailable or cannot prove its retained authority, a current coordinator may explicitly take over the adopted Run from its own live agent terminal:\n\n```bash\norca orchestration run-use --id --takeover-legacy --json\norca orchestration check --run --json\n```\n\nTakeover fences only the old coordinator, binds the current one, and moves pending worker mail into current Run Delivery. It is bound to the authenticated invoking terminal; `--from` cannot name another coordinator. Live legacy workers keep their original Tasks, Dispatches, processes, filesystems, and old prompt commands; their later questions, escalations, and completion reports route to the current coordinator. Do not use takeover while the original coordinator is still actively coordinating, because its later lifecycle mutations are rejected.\n\nDo not launch a replacement editor merely because the desktop app or runtime was updated. If adoption cannot prove continuing authority, keep the original worker as the only editor until it reaches a stable handoff point, then use a new current Dispatch in a conflict-free placement for any remaining work.\n\n## Ownership\n\nNew orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --subject [--to ] [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--ack ] [--peek|--all] [--types ] [--format] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask (--question |--resume ) [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack `. Process every message before acknowledging; `check --ack --wait` acknowledges, checks, and waits in one operation.\n- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.\n- Use `dispatch:` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.\n- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.\n- `terminal list --json` omits `visualLayouts` because handle recovery does not need topology. Add `--include-visual-layouts` only for explicit tab and pane inspection.\n- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms ` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id --body --json`, then acknowledge and keep waiting.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again.\n- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop.\n\n```bash\norca orchestration run-create --objective --json\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## How deep workers can nest\n\nA dispatched worker normally cannot dispatch sub-workers. Attempting it fails with\n`nested_worker_depth_exceeded` and a message telling the worker to complete the task\nitself. Do that — do not try to route around it.\n\nThe limit is a number, not an on/off switch. `Settings -> Agents -> Nested worker depth`\nsets how many generations are allowed:\n\n- `1` (default): a coordinator dispatches workers; those workers do not dispatch.\n- `2`: workers may dispatch one further generation.\n\nDepth is counted from the terminal that issues the command, not from the Run. Creating a\nnew Run does not reset it — a worker that runs `run-create` then `worker-start` is still a\nworker, and still counted. This is the part that changed: the old behaviour rejected\nsub-dispatch only because a worker's terminal was not bound to a Run, so creating a Run was\nenough to slip past it.\n\nTwo limits worth knowing:\n\n- **It is a guardrail, not a security boundary.** A caller that declares another terminal's\n handle while its own launch evidence is unverifiable (an ordinary restored terminal, for\n example) can be counted as that terminal instead. Orca does not treat workers as hostile.\n- **It applies while a Dispatch is active.** After `worker_done`, or after a coordinator\n settles the task, the terminal is no longer a worker and is counted as a root again. The\n process may still be alive; that is the documented boundary, not an accident.\n\n## Preferred Supervised Worker Loop\n\nUse `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.\n\nCreate the Run and every independent Task first, then start all independent workers before waiting:\n\n```bash\norca orchestration run-create --objective \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration worker-start --task --worktree current --agent codex --json\norca orchestration worker-start --task --worktree current --agent claude --json\n```\n\n`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `.\n\nFor a per-invocation Claude, Codex, or Cursor launch, pass an opaque provider model id with `--model`; add `--effort` only when that agent/model supports the level. These options apply only to fresh agent terminals, override general agent default arguments, and are reported under `launch.requested` and `launch.effective` in the receipt:\n\n```bash\norca orchestration worker-start --task --worktree current --agent claude --model opus --effort high --json\n```\n\n`--effort` requires `--model`, and neither option can combine with `--terminal`. A connected worker server must advertise launch-preference support before Orca forwards either option.\n\nFor a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:\n\n```bash\norca orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n# Independent/top-level:\norca orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nSetup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason.\n\nRead the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure.\n\nTo run the worker on another connected Orca server, add `--on `. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`:\n\n```bash\n# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home)\norca orchestration worker-start --task --on windows --worktree new-top-level --repo --name --agent codex --setup run --json\norca orchestration worker-show --dispatch --json\norca orchestration worker-read --dispatch --limit 50 --json\norca orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nRemote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector.\n\nThe follow-up is structured inbox mail, not prompt injection. The worker's next\n`orchestration check` receives it even when the Dispatch is on another connected Orca server.\n\n`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: \"terminal\"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path.\n\nWait until every expected Dispatch settles, not for a fixed number of batches:\n\n```bash\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n# Process every message. For each accepted worker_done that is not immediately reused:\norca orchestration worker-release --dispatch --json\n# Acknowledge only after every message and required release decision is handled:\norca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\nAfter processing each accepted `worker_done`, choose the terminal's next owner before you acknowledge the Delivery or wait again. If the same exact agent has an immediate follow-up Task, read the `worker.agent_terminal_handle` field of `worker-show --dispatch --json`, then run `orca orchestration worker-start --task --terminal --json` so Orca transfers cleanup ownership to the new Dispatch. Otherwise run `orca orchestration worker-release --dispatch --json`.\n\nRun `worker-release` after both succeeded and failed `worker_done` reports unless the user explicitly asked to keep that worker live. Release is post-completion cleanup, not cancellation: Orca first preserves inspectable output, then closes only the exact agent terminal owned by that settled Dispatch. Reused or pre-existing terminals, setup terminals, coordinators, active workers, user-taken-over terminals, and identities Orca cannot prove are retained. If the user explicitly asks to keep the live terminal for debugging, record that exception with `orca orchestration worker-retain --dispatch --json` instead of silently skipping cleanup. When the user is finished, the same Dispatch can be passed to `worker-release`, which clears the requested retention and releases the terminal.\n\nDo not release a worker because of a timeout, TUI idle state, heartbeat, status, question, escalation, or rejected/stale `worker_done`. If release returns `release_pending` or `release_unknown`, do not substitute `terminal close`; follow the exact recovery action in the receipt. A replayed Delivery may repeat `worker-release` safely.\n\nWorkers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:\n\n```bash\norca orchestration send --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a,path/b\" --json\n# On failure, use --outcome failed; never encode failure only in prose.\n```\n\nA worker question defaults to its owning Run. Timeout leaves it pending:\n\n```bash\norca orchestration ask --question \"\" --options \"yes,no\" --timeout-ms 600000 --json\norca orchestration ask --resume --timeout-ms 600000 --json\n# Coordinator:\norca orchestration reply --id --body \"\" --json\n```\n\nRecovery is conditional, never a fixed destructive sequence:\n\n- `worker-show --dispatch ` says `ready`: keep waiting or read bounded output.\n- It proves `failed` or `stopped`: start a replacement with `worker-start --task --retry-of ` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement.\n- It remains `outcome_unknown`: either `worker-stop --dispatch ` and inspect again, or explicitly `worker-abandon --dispatch ` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action.\n- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes.\n\nLow-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.\n\n`dispatch --inject` deliberately keeps an operator-started terminal unsupervised: it never creates a `worker_dispatches` row and `worker-stop`/`worker-abandon` never close that process. The dispatch context is still authoritative, so `worker-show`, `worker-read`, and `worker-list` report it as `unsupervised`; settled `worker-retain` and `worker-release` report `retained` with `no_owned_resource` and take no process action. Use `worker-start --terminal ` when supervision and worker lifecycle state are required.\n\n## Gates And Legacy Inspection\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\n```\n\nUse `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\n`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --setup run --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nThe two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --setup run --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.\n\nWhen a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\nFor every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees.\n\n```bash\norca worktree create --name --agent codex --setup run --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from agentTerminalHandle, falling back to startupTerminal.handle.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--include-visual-layouts] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:\n `orca orchestration send --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a\" --report-path \"\" --json`\n- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.\n- After sending `worker_done`, end that dispatched turn and idle at the agent prompt. Do not autonomously start more work, poll, or attempt to close the terminal yourself. A direct user instruction takes precedence and starts ordinary user-owned work: follow it without coordinator approval or a fresh Dispatch, never refuse it because of worker/coordinator roles, and do not reuse the settled Dispatch's lifecycle IDs. A coordinator-supervised follow-up still arrives with a fresh preamble + TASK block.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators must account for every settled worker terminal before waiting again or ending the turn: immediately reuse the exact worker for a new Dispatch, explicitly retain it at the user's request with `worker-retain`, or run `worker-release`. Do not leave a completed worker live merely to inspect output; released workers remain readable through `worker-read`.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology. After every accepted `worker_done`, either transfer the exact terminal to an immediate follow-up Dispatch or run `worker-release` before the next wait.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" // Why: no current guide has bundled reference documents, so --full is byte-identical for now. // oxfmt-ignore diff --git a/src/main/persistence/applying-settings/settings-update.ts b/src/main/persistence/applying-settings/settings-update.ts index ff4c55b09a8..c4ded8da753 100644 --- a/src/main/persistence/applying-settings/settings-update.ts +++ b/src/main/persistence/applying-settings/settings-update.ts @@ -1,5 +1,6 @@ import type { GlobalSettings } from '../../../shared/global-settings-types' import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' +import { resolveNestedWorkerMaxDepth } from '../../../shared/nested-worker-depth' import { normalizeTuiAgentArgsRecord, normalizeTuiAgentEnvRecord @@ -73,6 +74,11 @@ export function updateSettings( if ('agentSkillSharingEnabled' in updates) { sanitizedUpdates.agentSkillSharingEnabled = updates.agentSkillSharingEnabled === true } + if ('nestedWorkerMaxDepth' in updates) { + sanitizedUpdates.nestedWorkerMaxDepth = resolveNestedWorkerMaxDepth({ + nestedWorkerMaxDepth: updates.nestedWorkerMaxDepth + }) + } if ('disabledTuiAgents' in updates) { sanitizedUpdates.disabledTuiAgents = normalizeDisabledTuiAgents(updates.disabledTuiAgents) } diff --git a/src/main/runtime/exit-provenance-audit.test.ts b/src/main/runtime/exit-provenance-audit.test.ts index ee6b713dd61..38865c2269d 100644 --- a/src/main/runtime/exit-provenance-audit.test.ts +++ b/src/main/runtime/exit-provenance-audit.test.ts @@ -7,6 +7,7 @@ import { join } from 'node:path' import { OrcaRuntimeService } from './orca-runtime' import { OrchestrationDb } from './orchestration/db' import type { DispatchContextRow } from './orchestration/types' +import { createRootDispatch } from './orchestration/db/root-dispatch-test-fixture' const TAB_ID = '11111111-1111-4111-8111-111111111111' const LEAF_ID = '22222222-2222-4222-8222-222222222222' @@ -71,7 +72,10 @@ function dispatchOnHandle( coordinatorPaneKey: '99999999-9999-4999-8999-999999999999:88888888-8888-4888-8888-888888888888' }) const task = db.createTask({ spec, runId: run.id }) - return { ...db.createDispatchContext(task.id, HANDLE, PANE_KEY), runId: run.id } + return { + ...createRootDispatch(db, task.id, HANDLE, PANE_KEY), + runId: run.id + } } /** Where a lightweight Run's coordinator actually reads its mail (STA-4604). */ diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 381ed183516..1f9d810e977 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -144,6 +144,7 @@ import { } from './terminal-view-attribute-store' import { clearConfiguredWorktreeSharedDirectoriesCacheForTests } from '../git/worktree-shared-directories' import { setWorktreeWatcherRemoval } from '../ipc/worktree-watcher-removal' +import { createRootDispatch } from './orchestration/db/root-dispatch-test-fixture' const ORIGINAL_PLATFORM = process.platform const ORIGINAL_PLATFORM_DESCRIPTOR = Object.getOwnPropertyDescriptor(process, 'platform') @@ -22004,6 +22005,8 @@ describe('OrcaRuntimeService', () => { try { const task = db.createTask({ spec: 'continue after missing worker recovery' }) const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, taskId: task.id, startOptions: { topology: 'current', agent: 'codex' } }) @@ -22109,6 +22112,8 @@ describe('OrcaRuntimeService', () => { try { const task = db.createTask({ spec: 'retry missing worker recovery' }) const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, taskId: task.id, startOptions: { topology: 'current', agent: 'codex' } }) @@ -43844,11 +43849,12 @@ describe('OrcaRuntimeService', () => { ['worker-folder', runB.id] ].map(([name, runId]) => { const task = db.createTask({ spec: name, runId }) - return [name, db.createDispatchContext(task.id, handles[name], paneKey(name))] + return [name, createRootDispatch(db, task.id, handles[name], paneKey(name))] }) ) const legacyTask = db.createTask({ spec: 'legacy worker' }) - const legacyDispatch = db.createDispatchContext( + const legacyDispatch = createRootDispatch( + db, legacyTask.id, handles['legacy-worker'], paneKey('legacy-worker') @@ -43976,7 +43982,8 @@ describe('OrcaRuntimeService', () => { expect(creatorAuthority?.processIncarnation).toBeTruthy() expect(coordinatorAuthority?.processIncarnation).toBeTruthy() const creatorTask = db.createTask({ spec: 'create nested work', runId: runA.id }) - db.createDispatchContext( + createRootDispatch( + db, creatorTask.id, handles.creator, paneKey('creator'), @@ -43991,7 +43998,8 @@ describe('OrcaRuntimeService', () => { createdByProcessIncarnation: creatorAuthority?.processIncarnation ?? undefined, createdByRunGeneration: runA.consumer_generation }) - const workerDispatch = db.createDispatchContext( + const workerDispatch = createRootDispatch( + db, workerTask.id, handles.worker, paneKey('worker') @@ -44004,7 +44012,8 @@ describe('OrcaRuntimeService', () => { createdByProcessIncarnation: coordinatorAuthority?.processIncarnation ?? undefined, createdByRunGeneration: runA.consumer_generation }) - const coordinatorCreatedDispatch = db.createDispatchContext( + const coordinatorCreatedDispatch = createRootDispatch( + db, coordinatorCreatedTask.id, handles['coordinator-created-worker'], paneKey('coordinator-created-worker') @@ -44092,7 +44101,8 @@ describe('OrcaRuntimeService', () => { coordinatorPaneKey: makePaneKey(terminals[99].tabId, terminals[99].leafId) }) const task = db.createTask({ spec: 'one dispatched terminal', runId: run.id }) - const dispatch = db.createDispatchContext( + const dispatch = createRootDispatch( + db, task.id, handles[0], makePaneKey(terminals[0].tabId, terminals[0].leafId) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index b86135d76a3..a50cef258e6 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -18,6 +18,7 @@ import { assertAgentSkillSharingAllowed, isAgentSkillSharingEnabled } from '../../shared/agent-skill-sharing-gate' +import { resolveNestedWorkerMaxDepth } from '../../shared/nested-worker-depth' import { sortDirEntries } from '../../shared/file-name-sort' import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing' import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extraction' @@ -1407,6 +1408,7 @@ type RuntimeStore = { prBotAuthorOverrides?: GlobalSettings['prBotAuthorOverrides'] artifactSharingEnabled?: GlobalSettings['artifactSharingEnabled'] agentSkillSharingEnabled?: GlobalSettings['agentSkillSharingEnabled'] + nestedWorkerMaxDepth?: GlobalSettings['nestedWorkerMaxDepth'] terminalQuickCommands?: GlobalSettings['terminalQuickCommands'] gitlabProjects?: GlobalSettings['gitlabProjects'] mobileAutoRestoreFitMs?: number | null @@ -5283,6 +5285,11 @@ export class OrcaRuntimeService { assertAgentSkillSharingAllowed(() => isAgentSkillSharingEnabled(this.store?.getSettings())) } + /** Renderer-owned; read here because dispatch enforcement lives in main. */ + getNestedWorkerMaxDepth(): number { + return resolveNestedWorkerMaxDepth(this.store?.getSettings()) + } + async publishDiscoveredSkillsFromAgent( request: AgentSkillShareRequest, discoveredSkills: readonly DiscoveredSkill[], diff --git a/src/main/runtime/orchestration-mailbox-detached-routing.test.ts b/src/main/runtime/orchestration-mailbox-detached-routing.test.ts index 654f01acef1..e1d484d3b14 100644 --- a/src/main/runtime/orchestration-mailbox-detached-routing.test.ts +++ b/src/main/runtime/orchestration-mailbox-detached-routing.test.ts @@ -16,6 +16,7 @@ import { temporaryDirectories, TERMINAL_HANDLE } from './orchestration-mailbox-notification-test-harness' +import { createRootDispatch } from './orchestration/db/root-dispatch-test-fixture' vi.mock('electron', () => ({ app: { getPath: vi.fn(() => tmpdir()), isPackaged: false }, @@ -43,7 +44,7 @@ describe('orchestration detached mailbox routing', () => { '33333333-3333-4333-8333-333333333333:44444444-4444-4444-8444-444444444444' }) const task = db.createTask({ spec: 'Worker task', runId: run.id }) - const dispatch = db.createDispatchContext(task.id, TERMINAL_HANDLE, PANE_KEY) + const dispatch = createRootDispatch(db, task.id, TERMINAL_HANDLE, PANE_KEY) await driveToLiveIdle(harness.runtime) const message = db.insertMessage({ from: 'term_coordinator', @@ -118,7 +119,8 @@ describe('orchestration detached mailbox routing', () => { '55555555-5555-4555-8555-555555555555:66666666-6666-4666-8666-666666666666' }) const task = db.createTask({ spec: 'Worker task', runId: workerRun.id }) - const dispatch = db.createDispatchContext( + const dispatch = createRootDispatch( + db, task.id, 'term_mailbox_before_remint', `99999999-9999-4999-8999-999999999999:${LEAF_ID}` @@ -356,7 +358,7 @@ describe('orchestration detached mailbox routing', () => { '55555555-5555-4555-8555-555555555555:66666666-6666-4666-8666-666666666666' }) const task = db.createTask({ spec: 'Reminted worker', runId: run.id }) - const dispatch = db.createDispatchContext(task.id, 'term_before_remint', PANE_KEY) + const dispatch = createRootDispatch(db, task.id, 'term_before_remint', PANE_KEY) const waiting = harness.runtime.waitForMessage(`dispatch:${dispatch.id}`, { typeFilter: ['dispatch'], timeoutMs: 5_000 @@ -430,7 +432,7 @@ describe('orchestration detached mailbox routing', () => { coordinatorPaneKey: PANE_KEY }) const task = db.createTask({ spec: 'Same-handle worker', runId: run.id }) - db.createDispatchContext(task.id, TERMINAL_HANDLE, PANE_KEY) + createRootDispatch(db, task.id, TERMINAL_HANDLE, PANE_KEY) const message = db.insertMessage({ from: 'term_sender', to: TERMINAL_HANDLE, @@ -459,7 +461,7 @@ describe('orchestration detached mailbox routing', () => { coordinatorPaneKey: PANE_KEY }) const task = db.createTask({ spec: 'Same-handle worker', runId: run.id }) - const dispatch = db.createDispatchContext(task.id, TERMINAL_HANDLE, PANE_KEY) + const dispatch = createRootDispatch(db, task.id, TERMINAL_HANDLE, PANE_KEY) const message = db.insertMessage({ from: 'term_sender', to: TERMINAL_HANDLE, @@ -501,7 +503,7 @@ describe('orchestration detached mailbox routing', () => { .prepare('UPDATE messages SET to_handle = ? WHERE id = ?') .run(TERMINAL_HANDLE, directMessage.id) const task = db.createTask({ spec: 'Dispatch migration', runId: run.id }) - const dispatch = db.createDispatchContext(task.id, 'term_dispatch', PANE_KEY) + const dispatch = createRootDispatch(db, task.id, 'term_dispatch', PANE_KEY) db.insertMessage({ from: 'term_sender', to: `dispatch:${dispatch.id}`, diff --git a/src/main/runtime/orchestration-mailbox-notification-consistency.test.ts b/src/main/runtime/orchestration-mailbox-notification-consistency.test.ts index 38f574d9491..bbcbc42e564 100644 --- a/src/main/runtime/orchestration-mailbox-notification-consistency.test.ts +++ b/src/main/runtime/orchestration-mailbox-notification-consistency.test.ts @@ -28,6 +28,7 @@ import { } from './orchestration-mailbox-notification-test-harness' import { RpcDispatcher } from './rpc/dispatcher' import { ORCHESTRATION_METHODS } from './rpc/methods/orchestration' +import { createRootDispatch } from './orchestration/db/root-dispatch-test-fixture' vi.mock('electron', () => ({ app: { getPath: vi.fn(() => tmpdir()), isPackaged: false }, @@ -750,7 +751,7 @@ describe('orchestration notification mailbox consistency', () => { '55555555-5555-4555-8555-555555555555:66666666-6666-4666-8666-666666666666' }) const task = db.createTask({ spec: 'Worker task', runId: run.id }) - const dispatch = db.createDispatchContext(task.id, TERMINAL_HANDLE, PANE_KEY) + const dispatch = createRootDispatch(db, task.id, TERMINAL_HANDLE, PANE_KEY) for (let index = 0; index < 50; index += 1) { insertDirectRunMessage(db, run.id, `Worker status ${index}`) } diff --git a/src/main/runtime/orchestration-mailbox-routing-races.test.ts b/src/main/runtime/orchestration-mailbox-routing-races.test.ts index 935f05c2877..ff79fabc065 100644 --- a/src/main/runtime/orchestration-mailbox-routing-races.test.ts +++ b/src/main/runtime/orchestration-mailbox-routing-races.test.ts @@ -19,6 +19,7 @@ import { temporaryDirectories, TERMINAL_HANDLE } from './orchestration-mailbox-notification-test-harness' +import { createRootDispatch } from './orchestration/db/root-dispatch-test-fixture' vi.mock('electron', () => ({ app: { getPath: vi.fn(() => tmpdir()), isPackaged: false }, @@ -69,7 +70,7 @@ describe('orchestration mailbox routing races', () => { coordinatorPaneKey: SECOND_PANE_KEY }) const task = db.createTask({ spec: 'Worker task', runId: run.id }) - const dispatch = db.createDispatchContext(task.id, TERMINAL_HANDLE, PANE_KEY) + const dispatch = createRootDispatch(db, task.id, TERMINAL_HANDLE, PANE_KEY) for (let index = 0; index < 151; index += 1) { insertDirectRunMessage(db, run.id, `Before completion ${index}`) } @@ -152,7 +153,7 @@ describe('orchestration mailbox routing races', () => { coordinatorPaneKey: SECOND_PANE_KEY }) const task = db.createTask({ spec: 'Waiting worker', runId: run.id }) - const dispatch = db.createDispatchContext(task.id, TERMINAL_HANDLE, PANE_KEY) + const dispatch = createRootDispatch(db, task.id, TERMINAL_HANDLE, PANE_KEY) const status = insertDirectRunMessage(db, run.id, 'Filtered-out status') const controller = new AbortController() const waiting = dispatchMailboxCheck(harness.runtime, { @@ -201,7 +202,7 @@ describe('orchestration mailbox routing races', () => { coordinatorPaneKey: SECOND_PANE_KEY }) const task = db.createTask({ spec: 'Cancelled worker check', runId: run.id }) - const dispatch = db.createDispatchContext(task.id, TERMINAL_HANDLE, PANE_KEY) + const dispatch = createRootDispatch(db, task.id, TERMINAL_HANDLE, PANE_KEY) for (let index = 0; index < 151; index += 1) { insertDirectRunMessage(db, run.id, `Before cancelled migration ${index}`) } @@ -296,7 +297,7 @@ describe('orchestration mailbox routing races', () => { '55555555-5555-4555-8555-555555555555:66666666-6666-4666-8666-666666666666' }) const task = db.createTask({ spec: 'Worker task', runId: run.id }) - db.createDispatchContext(task.id, 'term_previous_worker', PANE_KEY) + createRootDispatch(db, task.id, 'term_previous_worker', PANE_KEY) const current = insertDirectRunMessage(db, run.id, 'Current worker handle') const previous = db.insertMessage({ from: 'term_coordinator', @@ -421,9 +422,9 @@ describe('orchestration mailbox routing races', () => { '55555555-5555-4555-8555-555555555555:66666666-6666-4666-8666-666666666666' }) const task = db.createTask({ spec: 'Valid worker', runId: run.id }) - const valid = db.createDispatchContext(task.id, 'term_old', PANE_KEY) + const valid = createRootDispatch(db, task.id, 'term_old', PANE_KEY) const collisionTask = db.createTask({ spec: 'Malformed collision', runId: run.id }) - db.createDispatchContext(collisionTask.id, 'term_collision', `:${LEAF_ID}`) + createRootDispatch(db, collisionTask.id, 'term_collision', `:${LEAF_ID}`) expect(db.getActiveDispatchForIdentity('term_reminted', PANE_KEY)?.id).toBe(valid.id) const plan = sqliteFor(db) diff --git a/src/main/runtime/orchestration/coordinator-decision-gates.test.ts b/src/main/runtime/orchestration/coordinator-decision-gates.test.ts index e5a76ab6a22..15e0cd2c7b0 100644 --- a/src/main/runtime/orchestration/coordinator-decision-gates.test.ts +++ b/src/main/runtime/orchestration/coordinator-decision-gates.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { openDecisionGateFromMessage } from './coordinator-decision-gates' import { OrchestrationDb } from './db' +import { createRootDispatch } from './db/root-dispatch-test-fixture' describe('coordinator decision-gate authority', () => { let db: OrchestrationDb @@ -12,7 +13,7 @@ describe('coordinator decision-gate authority', () => { it('opens a gate only for the sender-owned active Dispatch', () => { db = new OrchestrationDb(':memory:') const task = db.createTask({ spec: 'owned gate target' }) - const dispatch = db.createDispatchContext(task.id, 'term_owner', 'tab_owner:leaf_owner') + const dispatch = createRootDispatch(db, task.id, 'term_owner', 'tab_owner:leaf_owner') const logs: string[] = [] openDecisionGateFromMessage( @@ -41,13 +42,14 @@ describe('coordinator decision-gate authority', () => { it('rejects a gate targeting another active Dispatch without mutating either Task', () => { db = new OrchestrationDb(':memory:') const attackerTask = db.createTask({ spec: 'attacker assignment' }) - const attacker = db.createDispatchContext( + const attacker = createRootDispatch( + db, attackerTask.id, 'term_attacker', 'tab_attacker:leaf_attacker' ) const victimTask = db.createTask({ spec: 'victim assignment' }) - const victim = db.createDispatchContext(victimTask.id, 'term_victim', 'tab_victim:leaf_victim') + const victim = createRootDispatch(db, victimTask.id, 'term_victim', 'tab_victim:leaf_victim') const logs: string[] = [] openDecisionGateFromMessage( @@ -78,7 +80,7 @@ describe('coordinator decision-gate authority', () => { it('accepts the canonical sender of an imported federated Dispatch', () => { db = new OrchestrationDb(':memory:') const task = db.createTask({ spec: 'remote gate target' }) - const dispatch = db.createDispatchContext(task.id, 'remote-worker') + const dispatch = createRootDispatch(db, task.id, 'remote-worker') openDecisionGateFromMessage( db, diff --git a/src/main/runtime/orchestration/coordinator-escalation-triage.test.ts b/src/main/runtime/orchestration/coordinator-escalation-triage.test.ts index af973100336..c020e62dca0 100644 --- a/src/main/runtime/orchestration/coordinator-escalation-triage.test.ts +++ b/src/main/runtime/orchestration/coordinator-escalation-triage.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { applyEscalationToDispatch } from './coordinator-escalation-triage' import { OrchestrationDb } from './db' +import { createRootDispatch } from './db/root-dispatch-test-fixture' describe('coordinator escalation authority', () => { let db: OrchestrationDb @@ -12,13 +13,14 @@ describe('coordinator escalation authority', () => { it('rejects an escalation targeting another active Dispatch', () => { db = new OrchestrationDb(':memory:') const attackerTask = db.createTask({ spec: 'attacker assignment' }) - const attacker = db.createDispatchContext( + const attacker = createRootDispatch( + db, attackerTask.id, 'term_attacker', 'tab_attacker:leaf_attacker' ) const victimTask = db.createTask({ spec: 'victim assignment' }) - const victim = db.createDispatchContext(victimTask.id, 'term_victim') + const victim = createRootDispatch(db, victimTask.id, 'term_victim') const logs: string[] = [] applyEscalationToDispatch( @@ -42,7 +44,7 @@ describe('coordinator escalation authority', () => { it('accepts the canonical sender of an imported federated Dispatch', () => { db = new OrchestrationDb(':memory:') const task = db.createTask({ spec: 'remote escalation target' }) - const dispatch = db.createDispatchContext(task.id, 'remote-worker') + const dispatch = createRootDispatch(db, task.id, 'remote-worker') applyEscalationToDispatch( db, diff --git a/src/main/runtime/orchestration/coordinator-runtime-contract.ts b/src/main/runtime/orchestration/coordinator-runtime-contract.ts index f01f4188ea7..742434b1b80 100644 --- a/src/main/runtime/orchestration/coordinator-runtime-contract.ts +++ b/src/main/runtime/orchestration/coordinator-runtime-contract.ts @@ -26,6 +26,8 @@ export type CoordinatorRuntime = { probeWorktreeDrift(worktreeSelector: string): Promise // Why: pane-only fallback preserves reservation identity for lightweight runtime fakes. getTerminalPaneKey?(handle: string): string | null + // Why optional: lightweight fakes omit it and get the fail-closed default. + getNestedWorkerMaxDepth?(): number // Why: automatic dispatch persists the same authenticated pane/process tuple as manual dispatch. getOrchestrationDispatchAuthority?(handle: string): { paneKey: string | null diff --git a/src/main/runtime/orchestration/coordinator-task-dispatch.ts b/src/main/runtime/orchestration/coordinator-task-dispatch.ts index 51e2d834df5..1a882ca3b73 100644 --- a/src/main/runtime/orchestration/coordinator-task-dispatch.ts +++ b/src/main/runtime/orchestration/coordinator-task-dispatch.ts @@ -68,6 +68,7 @@ export async function dispatchTaskToWorker(params: { onLog: (msg: string) => void // Why: the coordinator owns the failed-task list, so a circuit break is reported back instead of mutated here. onCircuitBroken: (taskId: string) => void + nestedWorkerMaxDepth: number }): Promise { const { db, runtime, task, targetHandle, baseDrift, onLog } = params // Why (§3.1): drift check runs before createDispatchContext so a refusal doesn't bump failure_count (carried forward as MAX in db.ts:301-306) and burn the circuit-breaker budget; the task stays `ready` and retries next tick. @@ -95,13 +96,17 @@ export async function dispatchTaskToWorker(params: { dispatchAuthority?.paneKey && dispatchAuthority.processIncarnation ? dispatchAuthority.processIncarnation : undefined - const dispatch = db.createDispatchContext( - task.id, - targetHandle, + const dispatch = db.createDispatchContext({ + taskId: task.id, + assigneeHandle: targetHandle, assigneePaneKey, - dispatchAuthority?.launchTokenHash ?? undefined, - processIncarnation - ) + launchTokenHash: dispatchAuthority?.launchTokenHash ?? undefined, + processIncarnation, + // Why system: the automatic loop is host-local Orca code driven by + // coordinator_runs, not a CLI caller, so it is a root by construction. + creator: { kind: 'system' }, + maxDepth: params.nestedWorkerMaxDepth + }) // Why: dispatched agents use orca-dev in dev mode to reach the dev runtime's socket, not production (Section 6.4). const preamble = buildDispatchPreamble({ diff --git a/src/main/runtime/orchestration/coordinator.test.ts b/src/main/runtime/orchestration/coordinator.test.ts index 7fcc3483be1..38701a9d7dd 100644 --- a/src/main/runtime/orchestration/coordinator.test.ts +++ b/src/main/runtime/orchestration/coordinator.test.ts @@ -7,6 +7,7 @@ import { DISPATCH_STALE_THRESHOLD, parseAllowStaleBaseFromSpec } from './coordinator-stale-base-flag' +import { createRootDispatch } from './db/root-dispatch-test-fixture' type DriftResult = { base: string @@ -223,7 +224,7 @@ describe('Coordinator', () => { const runtime = createMockRuntime() const task = db.createTask({ spec: 'send-driven completion' }) - const dispatch = db.createDispatchContext(task.id, 'term_a') + const dispatch = createRootDispatch(db, task.id, 'term_a') const msg = db.insertMessage({ from: 'term_a', to: 'coord', @@ -250,7 +251,7 @@ describe('Coordinator', () => { const runtime = createMockRuntime() const task = db.createTask({ spec: 'duplicate completion' }) - const dispatch = db.createDispatchContext(task.id, 'term_a') + const dispatch = createRootDispatch(db, task.id, 'term_a') const payload = JSON.stringify({ taskId: task.id, dispatchId: dispatch.id, @@ -530,7 +531,7 @@ describe('Coordinator', () => { // No terminals available so dispatchReadyTasks creates one and we can // drive the stale-scan deterministically via SQL backdating. const task = db.createTask({ spec: 'work' }) - const ctx = db.createDispatchContext(task.id, 'term_stale') + const ctx = createRootDispatch(db, task.id, 'term_stale') // Backdate dispatched_at and last_heartbeat_at beyond the 10-min threshold // so getStaleDispatches returns this row on the first tick. @@ -569,7 +570,7 @@ describe('Coordinator', () => { runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] const task = db.createTask({ spec: 'work' }) - const ctx = db.createDispatchContext(task.id, 'term_a') + const ctx = createRootDispatch(db, task.id, 'term_a') const coordinator = new Coordinator(db, runtime, { spec: 'go', @@ -606,9 +607,9 @@ describe('Coordinator', () => { const logs: string[] = [] const task = db.createTask({ spec: 'retry-sensitive work' }) - const staleCtx = db.createDispatchContext(task.id, 'term_old') + const staleCtx = createRootDispatch(db, task.id, 'term_old') db.failDispatch(staleCtx.id, 'retry elsewhere') - const activeCtx = db.createDispatchContext(task.id, 'term_current') + const activeCtx = createRootDispatch(db, task.id, 'term_current') db.insertMessage({ from: 'term_old', @@ -664,7 +665,7 @@ describe('Coordinator', () => { const task = db.createTask({ spec: 'owned work' }) const leafId = '11111111-1111-4111-8111-111111111111' - const ctx = db.createDispatchContext(task.id, 'term_owner', `tab_before:${leafId}`) + const ctx = createRootDispatch(db, task.id, 'term_owner', `tab_before:${leafId}`) db.insertMessage({ from: 'term_reminted', diff --git a/src/main/runtime/orchestration/coordinator.ts b/src/main/runtime/orchestration/coordinator.ts index 85250770dee..252c9f89ea9 100644 --- a/src/main/runtime/orchestration/coordinator.ts +++ b/src/main/runtime/orchestration/coordinator.ts @@ -13,6 +13,7 @@ import { listAvailableWorkerTerminals, warnStaleDispatches } from './coordinator-task-dispatch' +import { NESTED_WORKER_MAX_DEPTH_DEFAULT } from '../../../shared/nested-worker-depth' export type CoordinatorOptions = { spec: string @@ -283,6 +284,8 @@ export class Coordinator { baseDrift, coordinatorHandle: this.opts.coordinatorHandle, worktree: this.opts.worktree, + nestedWorkerMaxDepth: + this.runtime.getNestedWorkerMaxDepth?.() ?? NESTED_WORKER_MAX_DEPTH_DEFAULT, onLog: this.opts.onLog, onCircuitBroken: (taskId) => this.state.failedTasks.push(taskId) }) diff --git a/src/main/runtime/orchestration/db-empty-dispatch-shortcircuit.benchmark.test.ts b/src/main/runtime/orchestration/db-empty-dispatch-shortcircuit.benchmark.test.ts index 991d9df4358..c729a8b1dd7 100644 --- a/src/main/runtime/orchestration/db-empty-dispatch-shortcircuit.benchmark.test.ts +++ b/src/main/runtime/orchestration/db-empty-dispatch-shortcircuit.benchmark.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { OrchestrationDb } from './db' +import { createRootDispatch } from './db/root-dispatch-test-fixture' // Why: buildAgentOrchestrationByPaneKey issues 2 dispatch lookups per terminal // on EVERY 16ms graph publish. For users who never orchestrate, every one of @@ -64,7 +65,7 @@ describe('orchestration empty-dispatch short-circuit (benchmark)', () => { it('still runs the fan-out once a dispatch exists (correctness preserved)', () => { const db = new OrchestrationDb(':memory:') const task = db.createTask({ spec: 'work' }) - db.createDispatchContext(task.id, 'term_5') + createRootDispatch(db, task.id, 'term_5') const handles = Array.from({ length: 10 }, (_, i) => `term_${i}`) const contexts = simulateGraphPublish(db, handles) @@ -75,7 +76,7 @@ describe('orchestration empty-dispatch short-circuit (benchmark)', () => { it('predicate lifecycle: false when empty, true after dispatch (even completed), false after reset', () => { const db = new OrchestrationDb(':memory:') expect(db.hasAnyDispatchContexts()).toBe(false) - const ctx = db.createDispatchContext(db.createTask({ spec: 'work' }).id, 'term_worker') + const ctx = createRootDispatch(db, db.createTask({ spec: 'work' }).id, 'term_worker') expect(db.hasAnyDispatchContexts()).toBe(true) // Completed rows still count — recent-completed lookups must stay valid. db.completeDispatch(ctx.id) diff --git a/src/main/runtime/orchestration/db-heartbeat-straggler-guard.test.ts b/src/main/runtime/orchestration/db-heartbeat-straggler-guard.test.ts index 7c2fd3e44db..793c218e162 100644 --- a/src/main/runtime/orchestration/db-heartbeat-straggler-guard.test.ts +++ b/src/main/runtime/orchestration/db-heartbeat-straggler-guard.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' import { OrchestrationDb } from './db' +import { createRootDispatch } from './db/root-dispatch-test-fixture' let db: OrchestrationDb | undefined @@ -13,7 +14,7 @@ function seedHeartbeatedDispatch(): { d: OrchestrationDb; dispatchId: string } { const d = new OrchestrationDb(':memory:') db = d const task = d.createTask({ spec: 'work' }) - const dispatch = d.createDispatchContext(task.id, 'term_worker') + const dispatch = createRootDispatch(d, task.id, 'term_worker') d.recordHeartbeat(dispatch.id, '2026-05-03T00:00:00.000Z') return { d, dispatchId: dispatch.id } } diff --git a/src/main/runtime/orchestration/db-task-dispatch-invariant.test.ts b/src/main/runtime/orchestration/db-task-dispatch-invariant.test.ts index a4ebe6b66a8..80b4f8cf53e 100644 --- a/src/main/runtime/orchestration/db-task-dispatch-invariant.test.ts +++ b/src/main/runtime/orchestration/db-task-dispatch-invariant.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type Database from '../../sqlite/sync-database' import { OrchestrationDb } from './db' +import { createRootDispatch } from './db/root-dispatch-test-fixture' type DatabaseHarness = { db: OrchestrationDb @@ -31,7 +32,7 @@ describe('Task/Dispatch invariant transactions', () => { const { db } = createDatabase() const task = db.createTask({ spec: 'atomic work' }) const dependent = db.createTask({ spec: 'dependent work', deps: [task.id] }) - const dispatch = db.createDispatchContext(task.id, 'term_worker') + const dispatch = createRootDispatch(db, task.id, 'term_worker') const capability = db.mintDispatchCapability({ dispatchId: dispatch.id, paneKey: 'tab_worker:leaf_worker', @@ -74,7 +75,7 @@ describe('Task/Dispatch invariant transactions', () => { it('does not commit a caller-owned transaction', () => { const { db } = createDatabase() const task = db.createTask({ spec: 'outer transaction work' }) - const dispatch = db.createDispatchContext(task.id, 'term_worker') + const dispatch = createRootDispatch(db, task.id, 'term_worker') const sqlite = sqliteFor(db) sqlite.exec('BEGIN IMMEDIATE') @@ -101,7 +102,7 @@ describe('Task/Dispatch invariant transactions', () => { const sqlite = sqliteFor(db) sqlite.exec('BEGIN IMMEDIATE') - const dispatch = db.createDispatchContext(task.id, 'term_worker') + const dispatch = createRootDispatch(db, task.id, 'term_worker') expect(db.getTask(task.id)?.status).toBe('dispatched') expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched') sqlite.exec('ROLLBACK') @@ -115,9 +116,9 @@ describe('Task/Dispatch invariant transactions', () => { (status) => { const { db } = createDatabase() const task = db.createTask({ spec: 'legacy split work' }) - const first = db.createDispatchContext(task.id, 'term_first') + const first = createRootDispatch(db, task.id, 'term_first') sqliteFor(db).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id) - const second = db.createDispatchContext(task.id, 'term_second') + const second = createRootDispatch(db, task.id, 'term_second') db.updateTaskStatus(task.id, status, 'terminal result') @@ -131,10 +132,10 @@ describe('Task/Dispatch invariant transactions', () => { expect(db.getActiveDispatchForTerminal('term_first')).toBeUndefined() expect(db.getActiveDispatchForTerminal('term_second')).toBeUndefined() expect(() => - db.createDispatchContext(db.createTask({ spec: 'first later work' }).id, 'term_first') + createRootDispatch(db, db.createTask({ spec: 'first later work' }).id, 'term_first') ).not.toThrow() expect(() => - db.createDispatchContext(db.createTask({ spec: 'second later work' }).id, 'term_second') + createRootDispatch(db, db.createTask({ spec: 'second later work' }).id, 'term_second') ).not.toThrow() } ) @@ -142,9 +143,9 @@ describe('Task/Dispatch invariant transactions', () => { it('does not requeue a legacy split Task while another Dispatch remains active', () => { const { db } = createDatabase() const task = db.createTask({ spec: 'legacy split retry' }) - const first = db.createDispatchContext(task.id, 'term_first') + const first = createRootDispatch(db, task.id, 'term_first') sqliteFor(db).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id) - const second = db.createDispatchContext(task.id, 'term_second') + const second = createRootDispatch(db, task.id, 'term_second') db.failDispatch(second.id, 'targeted failure') @@ -156,9 +157,9 @@ describe('Task/Dispatch invariant transactions', () => { it('does not block a legacy split Task while another Dispatch remains active', () => { const { db } = createDatabase() const task = db.createTask({ spec: 'legacy split release' }) - const first = db.createDispatchContext(task.id, 'term_first') + const first = createRootDispatch(db, task.id, 'term_first') sqliteFor(db).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id) - const second = db.createDispatchContext(task.id, 'term_second') + const second = createRootDispatch(db, task.id, 'term_second') expect(db.beginWorkerStop(second.id, 'runtime_test')).toMatchObject({ disposition: 'context_only', @@ -174,7 +175,7 @@ describe('Task/Dispatch invariant transactions', () => { (status) => { const { db } = createDatabase() const task = db.createTask({ spec: 'guarded work' }) - const dispatch = db.createDispatchContext(task.id, 'term_worker') + const dispatch = createRootDispatch(db, task.id, 'term_worker') expect(() => db.updateTaskStatus(task.id, status, 'must not persist')).toThrowError( expect.objectContaining({ @@ -215,7 +216,7 @@ describe('Task/Dispatch invariant transactions', () => { return prepare(sql) }) - expect(() => first.db.createDispatchContext(task.id, 'term_worker')).toThrow( + expect(() => createRootDispatch(first.db, task.id, 'term_worker')).toThrow( `Task ${task.id} is failed; only ready tasks can be dispatched` ) expect(injected).toBe(true) @@ -233,7 +234,8 @@ describe('Task/Dispatch invariant transactions', () => { let winnerId: string | undefined vi.spyOn(sqlite, 'prepare').mockImplementation((sql) => { if (!winnerId && sql.includes('INSERT INTO dispatch_contexts')) { - winnerId = concurrent.db.createDispatchContext( + winnerId = createRootDispatch( + concurrent.db, secondTask.id, 'term_reminted', 'tab_new:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' @@ -243,7 +245,8 @@ describe('Task/Dispatch invariant transactions', () => { }) expect(() => - first.db.createDispatchContext( + createRootDispatch( + first.db, firstTask.id, 'term_worker', 'tab_old:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' @@ -264,13 +267,16 @@ describe('Task/Dispatch invariant transactions', () => { it('rejects worker authority when another Dispatch owns the pane', () => { const { db } = createDatabase() const ownerTask = db.createTask({ spec: 'current pane owner' }) - const owner = db.createDispatchContext( + const owner = createRootDispatch( + db, ownerTask.id, 'term_owner', 'tab_old:cccccccc-cccc-4ccc-8ccc-cccccccccccc' ) const workerTask = db.createTask({ spec: 'competing supervised worker' }) const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, taskId: workerTask.id, startOptions: {} }) @@ -304,7 +310,12 @@ describe('Task/Dispatch invariant transactions', () => { (status) => { const { db } = createDatabase() const task = db.createTask({ spec: 'supervised lifecycle' }) - const started = db.createStartingWorkerDispatch({ taskId: task.id, startOptions: {} }) + const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) const capability = db.prepareStartingWorkerAuthority({ dispatchId: started.dispatch.id, handle: 'term_worker', @@ -348,6 +359,8 @@ describe('Task/Dispatch invariant transactions', () => { const { db } = createDatabase() const task = db.createTask({ spec: 'federated lifecycle' }) const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, taskId: task.id, startOptions: {}, federation: { diff --git a/src/main/runtime/orchestration/db-task-dispatch-lifecycle-guards.test.ts b/src/main/runtime/orchestration/db-task-dispatch-lifecycle-guards.test.ts index 2a2e0b8d692..e1e9da0ee41 100644 --- a/src/main/runtime/orchestration/db-task-dispatch-lifecycle-guards.test.ts +++ b/src/main/runtime/orchestration/db-task-dispatch-lifecycle-guards.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type Database from '../../sqlite/sync-database' import { OrchestrationDb } from './db' +import { createRootDispatch } from './db/root-dispatch-test-fixture' type WorkerFixture = { dispatchId: string @@ -55,7 +56,7 @@ describe('Task/Dispatch lifecycle guards', () => { (outcome) => { const database = createDatabase() const task = database.createTask({ spec: 'legacy mixed split' }) - const contextOnly = database.createDispatchContext(task.id, 'term_context') + const contextOnly = createRootDispatch(database, task.id, 'term_context') sqliteFor(database).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id) const worker = startWorker(database, task.id, 'reporter') @@ -75,7 +76,8 @@ describe('Task/Dispatch lifecycle guards', () => { }) expect(database.getActiveDispatchForTerminal('term_context')).toBeUndefined() expect(() => - database.createDispatchContext( + createRootDispatch( + database, database.createTask({ spec: 'later context work' }).id, 'term_context' ) @@ -88,7 +90,7 @@ describe('Task/Dispatch lifecycle guards', () => { const task = database.createTask({ spec: 'reversed legacy mixed split' }) const worker = startWorker(database, task.id, 'reversed_reporter') sqliteFor(database).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id) - const contextOnly = database.createDispatchContext(task.id, 'term_reversed_context') + const contextOnly = createRootDispatch(database, task.id, 'term_reversed_context') expect( database.settleWorkerReport({ @@ -175,6 +177,8 @@ describe('Task/Dispatch lifecycle guards', () => { const database = createDatabase() const task = database.createTask({ spec: `${kind} split start failure` }) const failed = database.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, taskId: task.id, startOptions: {}, ...(kind === 'federated' @@ -219,9 +223,14 @@ describe('Task/Dispatch lifecycle guards', () => { (operation) => { const database = createDatabase() const task = database.createTask({ spec: `${operation} historical sibling` }) - const contextOnly = database.createDispatchContext(task.id, `term_${operation}`) + const contextOnly = createRootDispatch(database, task.id, `term_${operation}`) sqliteFor(database).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id) - const failed = database.createStartingWorkerDispatch({ taskId: task.id, startOptions: {} }) + const failed = database.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) database.failWorkerStart(failed.dispatch.id, 'start_failed', 'worker failed to start') expect(database.getTask(task.id)?.status).toBe('dispatched') @@ -239,7 +248,8 @@ describe('Task/Dispatch lifecycle guards', () => { expect(database.getDispatchContextById(contextOnly.id)?.status).toBe('failed') expect(database.getActiveDispatchForTerminal(`term_${operation}`)).toBeUndefined() expect(() => - database.createDispatchContext( + createRootDispatch( + database, database.createTask({ spec: `${operation} later work` }).id, `term_${operation}` ) @@ -305,7 +315,12 @@ describe('Task/Dispatch lifecycle guards', () => { const task = database.createTask({ spec: 'uncertain legacy worker split' }) const live = startWorker(database, task.id, 'uncertain_live') sqliteFor(database).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id) - const uncertain = database.createStartingWorkerDispatch({ taskId: task.id, startOptions: {} }) + const uncertain = database.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) database.markWorkerStartUnknown(uncertain.dispatch.id, 'agent_readiness', 'outcome unknown') expect(database.getTask(task.id)?.status).toBe('blocked') @@ -331,7 +346,12 @@ describe('Task/Dispatch lifecycle guards', () => { const task = database.createTask({ spec: `${recovery} uncertain sibling` }) const live = startWorker(database, task.id, `${recovery}_live`) sqliteFor(database).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id) - const uncertain = database.createStartingWorkerDispatch({ taskId: task.id, startOptions: {} }) + const uncertain = database.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) database.markWorkerStartUnknown(uncertain.dispatch.id, 'agent_readiness', 'outcome unknown') if (recovery === 'federated-reconcile') { @@ -381,7 +401,7 @@ describe('Task/Dispatch lifecycle guards', () => { const task = database.createTask({ spec: 'corrupt gated task' }) const gate = database.createGate({ taskId: task.id, question: 'Proceed?' }) sqliteFor(database).prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id) - const dispatch = database.createDispatchContext(task.id, 'term_worker') + const dispatch = createRootDispatch(database, task.id, 'term_worker') sqliteFor(database).prepare("UPDATE tasks SET status = 'blocked' WHERE id = ?").run(task.id) expect(() => database.resolveGate(gate.id, 'yes')).toThrowError( @@ -404,7 +424,12 @@ function createDatabase(): OrchestrationDb { } function startWorker(database: OrchestrationDb, taskId: string, name: string): WorkerFixture { - const started = database.createStartingWorkerDispatch({ taskId, startOptions: {} }) + const started = database.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId, + startOptions: {} + }) const paneSuffix = name.length.toString(16).padStart(12, '0') const paneKey = `tab_${name}:aaaaaaaa-aaaa-4aaa-8aaa-${paneSuffix}` const processIncarnation = `${name}:1` diff --git a/src/main/runtime/orchestration/db-task-dispatch-races.test.ts b/src/main/runtime/orchestration/db-task-dispatch-races.test.ts index c9c765f9757..fabb859acb6 100644 --- a/src/main/runtime/orchestration/db-task-dispatch-races.test.ts +++ b/src/main/runtime/orchestration/db-task-dispatch-races.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type Database from '../../sqlite/sync-database' import { OrchestrationDb } from './db' +import { createRootDispatch } from './db/root-dispatch-test-fixture' type DatabaseHarness = { db: OrchestrationDb @@ -28,7 +29,7 @@ describe('Task/Dispatch concurrency', () => { it('rolls back Dispatch failure when Task requeue fails', () => { const { db } = createDatabase() const task = db.createTask({ spec: 'atomic retry failure' }) - const dispatch = db.createDispatchContext(task.id, 'term_worker') + const dispatch = createRootDispatch(db, task.id, 'term_worker') sqliteFor(db).exec(` CREATE TRIGGER reject_task_requeue BEFORE UPDATE OF status ON tasks @@ -55,7 +56,12 @@ describe('Task/Dispatch concurrency', () => { const first = createDatabase() const concurrent = createDatabase(first.path) const task = first.db.createTask({ spec: 'worker completion wins' }) - const started = first.db.createStartingWorkerDispatch({ taskId: task.id, startOptions: {} }) + const started = first.db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) const capability = first.db.prepareStartingWorkerAuthority({ dispatchId: started.dispatch.id, handle: 'term_worker', @@ -117,10 +123,14 @@ describe('Task/Dispatch concurrency', () => { const losingTask = first.db.createTask({ spec: 'losing worker' }) const winningTask = first.db.createTask({ spec: 'winning worker' }) const loser = first.db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, taskId: losingTask.id, startOptions: {} }) const winner = concurrent.db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, taskId: winningTask.id, startOptions: {} }) diff --git a/src/main/runtime/orchestration/db.test.ts b/src/main/runtime/orchestration/db.test.ts index 0894ca7c6a0..4825246586a 100644 --- a/src/main/runtime/orchestration/db.test.ts +++ b/src/main/runtime/orchestration/db.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest' import Database from '../../sqlite/sync-database' import { LEGACY_RUN_ID, OrchestrationDb } from './db' import type { MessageType } from './db' +import { createRootDispatch } from './db/root-dispatch-test-fixture' // Overwrites the datetime('now')-seeded timestamps with explicit fixture values // so stale-detection assertions stay deterministic (no wall clock). @@ -255,7 +256,7 @@ describe('OrchestrationDb', () => { it('completing a task frees its active dispatch context', () => { const d = createDb() const task = d.createTask({ spec: 'do it' }) - d.createDispatchContext(task.id, 'term_a') + createRootDispatch(d, task.id, 'term_a') d.updateTaskStatus(task.id, 'completed') @@ -285,7 +286,7 @@ describe('OrchestrationDb', () => { const d = createDb() const ready = d.createTask({ spec: 'ready task' }) const dispatched = d.createTask({ spec: 'active task' }) - const ctx = d.createDispatchContext(dispatched.id, 'term_worker') + const ctx = createRootDispatch(d, dispatched.id, 'term_worker') const rows = d.listTasksWithDispatch() const readyRow = rows.find((r) => r.id === ready.id) @@ -300,7 +301,7 @@ describe('OrchestrationDb', () => { it('listTasksWithDispatch does not surface completed dispatches', () => { const d = createDb() const task = d.createTask({ spec: 'work' }) - d.createDispatchContext(task.id, 'term_worker') + createRootDispatch(d, task.id, 'term_worker') d.updateTaskStatus(task.id, 'completed') const rows = d.listTasksWithDispatch() @@ -323,7 +324,7 @@ describe('OrchestrationDb', () => { it('creates a dispatch context and marks task as dispatched', () => { const d = createDb() const task = d.createTask({ spec: 'work' }) - const ctx = d.createDispatchContext(task.id, 'term_worker') + const ctx = createRootDispatch(d, task.id, 'term_worker') expect(ctx.id).toMatch(/^ctx_/) expect(ctx.task_id).toBe(task.id) @@ -337,7 +338,7 @@ describe('OrchestrationDb', () => { const parent = d.createTask({ spec: 'parent' }) const child = d.createTask({ spec: 'child', deps: [parent.id] }) - expect(() => d.createDispatchContext(child.id, 'term_worker')).toThrow( + expect(() => createRootDispatch(d, child.id, 'term_worker')).toThrow( /only ready tasks can be dispatched/ ) }) @@ -346,9 +347,9 @@ describe('OrchestrationDb', () => { const d = createDb() const t1 = d.createTask({ spec: 'first' }) const t2 = d.createTask({ spec: 'second' }) - d.createDispatchContext(t1.id, 'term_worker') + createRootDispatch(d, t1.id, 'term_worker') - expect(() => d.createDispatchContext(t2.id, 'term_worker')).toThrow( + expect(() => createRootDispatch(d, t2.id, 'term_worker')).toThrow( /already has an active dispatch/ ) }) @@ -362,9 +363,9 @@ describe('OrchestrationDb', () => { const d = createDb() const t1 = d.createTask({ spec: 'first' }) const t2 = d.createTask({ spec: 'second' }) - d.createDispatchContext(t1.id, 'term_old', `tab_1:${LEAF_A}`) + createRootDispatch(d, t1.id, 'term_old', `tab_1:${LEAF_A}`) - expect(() => d.createDispatchContext(t2.id, 'term_new', `tab_1:${LEAF_A}`)).toThrow( + expect(() => createRootDispatch(d, t2.id, 'term_new', `tab_1:${LEAF_A}`)).toThrow( /already has an active dispatch/ ) }) @@ -373,9 +374,9 @@ describe('OrchestrationDb', () => { const d = createDb() const t1 = d.createTask({ spec: 'first' }) const t2 = d.createTask({ spec: 'second' }) - d.createDispatchContext(t1.id, 'term_old', `tab_1:${LEAF_A}`) + createRootDispatch(d, t1.id, 'term_old', `tab_1:${LEAF_A}`) - expect(() => d.createDispatchContext(t2.id, 'term_new', `tab_2:${LEAF_A}`)).toThrow( + expect(() => createRootDispatch(d, t2.id, 'term_new', `tab_2:${LEAF_A}`)).toThrow( /already has an active dispatch/ ) }) @@ -384,37 +385,37 @@ describe('OrchestrationDb', () => { const d = createDb() const t1 = d.createTask({ spec: 'first' }) const t2 = d.createTask({ spec: 'second' }) - d.createDispatchContext(t1.id, 'term_a', `tab_1:${LEAF_A}`) + createRootDispatch(d, t1.id, 'term_a', `tab_1:${LEAF_A}`) - expect(() => d.createDispatchContext(t2.id, 'term_b', `tab_1:${LEAF_B}`)).not.toThrow() + expect(() => createRootDispatch(d, t2.id, 'term_b', `tab_1:${LEAF_B}`)).not.toThrow() }) it('falls back to handle lock when pane keys are missing', () => { const d = createDb() const t1 = d.createTask({ spec: 'first' }) const t2 = d.createTask({ spec: 'second' }) - d.createDispatchContext(t1.id, 'term_worker') + createRootDispatch(d, t1.id, 'term_worker') // New dispatch has a pane key but the active row is legacy (no pane key): // only handle identity can lock; a different handle is free. - expect(() => d.createDispatchContext(t2.id, 'term_other', `tab_1:${LEAF_A}`)).not.toThrow() + expect(() => createRootDispatch(d, t2.id, 'term_other', `tab_1:${LEAF_A}`)).not.toThrow() }) it('allows dispatch to a terminal after previous dispatch completes', () => { const d = createDb() const t1 = d.createTask({ spec: 'first' }) const t2 = d.createTask({ spec: 'second' }) - const ctx1 = d.createDispatchContext(t1.id, 'term_worker') + const ctx1 = createRootDispatch(d, t1.id, 'term_worker') d.completeDispatch(ctx1.id) - expect(() => d.createDispatchContext(t2.id, 'term_worker')).not.toThrow() + expect(() => createRootDispatch(d, t2.id, 'term_worker')).not.toThrow() }) it('getDispatchContext returns latest for a task', () => { const d = createDb() const task = d.createTask({ spec: 'work' }) - const ctx = d.createDispatchContext(task.id, 'term_a') + const ctx = createRootDispatch(d, task.id, 'term_a') const found = d.getDispatchContext(task.id) expect(found?.id).toBe(ctx.id) }) @@ -422,9 +423,9 @@ describe('OrchestrationDb', () => { it('getDispatchContext uses insertion order when timestamps tie', () => { const d = createDb() const task = d.createTask({ spec: 'work' }) - const ctx1 = d.createDispatchContext(task.id, 'term_a') + const ctx1 = createRootDispatch(d, task.id, 'term_a') d.failDispatch(ctx1.id, 'retry') - const ctx2 = d.createDispatchContext(task.id, 'term_a') + const ctx2 = createRootDispatch(d, task.id, 'term_a') expect(d.getDispatchContext(task.id)?.id).toBe(ctx2.id) }) @@ -432,7 +433,7 @@ describe('OrchestrationDb', () => { it('getActiveDispatchForTerminal returns active dispatch', () => { const d = createDb() const task = d.createTask({ spec: 'work' }) - d.createDispatchContext(task.id, 'term_a') + createRootDispatch(d, task.id, 'term_a') const active = d.getActiveDispatchForTerminal('term_a') expect(active?.task_id).toBe(task.id) @@ -442,10 +443,10 @@ describe('OrchestrationDb', () => { it('getLatestDispatchForTerminal returns the most recent completed dispatch', () => { const d = createDb() const firstTask = d.createTask({ spec: 'first' }) - const first = d.createDispatchContext(firstTask.id, 'term_a') + const first = createRootDispatch(d, firstTask.id, 'term_a') d.completeDispatch(first.id) const secondTask = d.createTask({ spec: 'second' }) - const second = d.createDispatchContext(secondTask.id, 'term_a') + const second = createRootDispatch(d, secondTask.id, 'term_a') d.completeDispatch(second.id) const latest = d.getLatestDispatchForTerminal('term_a') @@ -457,19 +458,19 @@ describe('OrchestrationDb', () => { it('circuit breaker trips after 3 failures', () => { const d = createDb() const task = d.createTask({ spec: 'flaky' }) - const ctx = d.createDispatchContext(task.id, 'term_a') + const ctx = createRootDispatch(d, task.id, 'term_a') const after1 = d.failDispatch(ctx.id, 'timeout') expect(after1?.failure_count).toBe(1) expect(after1?.status).toBe('failed') expect(d.getTask(task.id)?.status).toBe('ready') - const ctx2 = d.createDispatchContext(task.id, 'term_a') + const ctx2 = createRootDispatch(d, task.id, 'term_a') const after2 = d.failDispatch(ctx2.id, 'timeout') expect(after2?.failure_count).toBe(2) expect(after2?.status).toBe('failed') - const ctx3 = d.createDispatchContext(task.id, 'term_a') + const ctx3 = createRootDispatch(d, task.id, 'term_a') const after3 = d.failDispatch(ctx3.id, 'timeout') expect(after3?.failure_count).toBe(3) expect(after3?.status).toBe('circuit_broken') @@ -480,7 +481,7 @@ describe('OrchestrationDb', () => { it('completeDispatch sets completed_at', () => { const d = createDb() const task = d.createTask({ spec: 'work' }) - const ctx = d.createDispatchContext(task.id, 'term_a') + const ctx = createRootDispatch(d, task.id, 'term_a') d.completeDispatch(ctx.id) const updated = d.getDispatchContext(task.id) @@ -493,7 +494,7 @@ describe('OrchestrationDb', () => { it('creates a gate and blocks the task', () => { const d = createDb() const task = d.createTask({ spec: 'needs approval' }) - d.createDispatchContext(task.id, 'term_a') + createRootDispatch(d, task.id, 'term_a') const gate = d.createGate({ taskId: task.id, question: 'Proceed?', @@ -622,7 +623,7 @@ describe('OrchestrationDb', () => { const d = createDb() d.insertMessage({ from: 'a', to: 'b', subject: 'test' }) const task = d.createTask({ spec: 'work' }) - d.createDispatchContext(task.id, 'term_a') + createRootDispatch(d, task.id, 'term_a') d.resetTasks() @@ -647,7 +648,7 @@ describe('OrchestrationDb', () => { it('recordHeartbeat updates last_heartbeat_at on dispatched rows', () => { const d = createDb() const task = d.createTask({ spec: 'work' }) - const ctx = d.createDispatchContext(task.id, 'term_a') + const ctx = createRootDispatch(d, task.id, 'term_a') d.recordHeartbeat(ctx.id, '2026-05-04T00:00:00.000Z') const after = d.getDispatchContext(task.id) @@ -665,10 +666,10 @@ describe('OrchestrationDb', () => { const taskB = d.createTask({ spec: 'b' }) const taskC = d.createTask({ spec: 'c' }) const taskD = d.createTask({ spec: 'd' }) - const ctxA = d.createDispatchContext(taskA.id, 'term_a') - const ctxB = d.createDispatchContext(taskB.id, 'term_b') - const ctxC = d.createDispatchContext(taskC.id, 'term_c') - const ctxD = d.createDispatchContext(taskD.id, 'term_d') + const ctxA = createRootDispatch(d, taskA.id, 'term_a') + const ctxB = createRootDispatch(d, taskB.id, 'term_b') + const ctxC = createRootDispatch(d, taskC.id, 'term_c') + const ctxD = createRootDispatch(d, taskD.id, 'term_d') d.completeDispatch(ctxD.id) const now = Date.now() @@ -709,15 +710,15 @@ describe('OrchestrationDb', () => { // Fresh worker: dispatched 12:00, heartbeat 12:05 (space-format), both // after the 11:55 threshold → NOT stale. - const fresh = d.createDispatchContext(d.createTask({ spec: 'fresh' }).id, 'term_fresh') + const fresh = createRootDispatch(d, d.createTask({ spec: 'fresh' }).id, 'term_fresh') setDispatchTimes(d, fresh.id, '2026-07-12 12:00:00', '2026-07-12 12:05:00') // Legacy ISO-format fresh row (mixed-format table) stays fresh too. - const legacy = d.createDispatchContext(d.createTask({ spec: 'legacy' }).id, 'term_legacy') + const legacy = createRootDispatch(d, d.createTask({ spec: 'legacy' }).id, 'term_legacy') setDispatchTimes(d, legacy.id, '2026-07-12T12:00:00.000Z', '2026-07-12T12:05:00.000Z') // Genuinely hung: dispatched + heartbeated at 10:00, ~2h before threshold. - const hung = d.createDispatchContext(d.createTask({ spec: 'hung' }).id, 'term_hung') + const hung = createRootDispatch(d, d.createTask({ spec: 'hung' }).id, 'term_hung') setDispatchTimes(d, hung.id, '2026-07-12 10:00:00', '2026-07-12 10:00:00') const stale = d.getStaleDispatches('2026-07-12T11:55:00.000Z') @@ -729,7 +730,7 @@ describe('OrchestrationDb', () => { // Space-format dispatched_at one minute after the threshold, no heartbeat // yet → still inside the grace window, must not be flagged. - const ctx = d.createDispatchContext(d.createTask({ spec: 'x' }).id, 'term_x') + const ctx = createRootDispatch(d, d.createTask({ spec: 'x' }).id, 'term_x') setDispatchTimes(d, ctx.id, '2026-07-12 12:00:00') const stale = d.getStaleDispatches('2026-07-12T11:59:00.000Z') @@ -741,7 +742,7 @@ describe('OrchestrationDb', () => { it('getStaleDispatches keeps a fresh row just after a UTC-midnight threshold (#8452)', () => { const d = createDb() - const ctx = d.createDispatchContext(d.createTask({ spec: 'midnight' }).id, 'term_midnight') + const ctx = createRootDispatch(d, d.createTask({ spec: 'midnight' }).id, 'term_midnight') setDispatchTimes(d, ctx.id, '2026-05-04 00:04:00') const stale = d.getStaleDispatches('2026-05-04T00:00:00.000Z') @@ -754,7 +755,7 @@ describe('OrchestrationDb', () => { it('getStaleDispatches keeps a live worker with a fresh space-format heartbeat (#8452)', () => { const d = createDb() - const ctx = d.createDispatchContext(d.createTask({ spec: 'live' }).id, 'term_live') + const ctx = createRootDispatch(d, d.createTask({ spec: 'live' }).id, 'term_live') setDispatchTimes(d, ctx.id, '2026-07-12 10:00:00', '2026-07-12 11:59:00') const stale = d.getStaleDispatches('2026-07-12T11:55:00.000Z') @@ -911,7 +912,7 @@ describe('OrchestrationDb', () => { // (b) last_heartbeat_at column exists on dispatch_contexts const task = d.createTask({ spec: 'work' }) - const ctx = d.createDispatchContext(task.id, 'term_a') + const ctx = createRootDispatch(d, task.id, 'term_a') d.recordHeartbeat(ctx.id, '2026-05-04T00:00:00.000Z') expect(d.getDispatchContext(task.id)?.last_heartbeat_at).toBe('2026-05-04T00:00:00.000Z') expect(d.getTask(task.id)?.task_title).toBe('work') @@ -942,7 +943,7 @@ describe('OrchestrationDb', () => { db = d const task = d.createTask({ spec: 'work' }) - const ctx = d.createDispatchContext(task.id, 'term_a', 'tab_1:leaf_1') + const ctx = createRootDispatch(d, task.id, 'term_a', 'tab_1:leaf_1') expect(d.getDispatchContextById(ctx.id)?.assignee_pane_key).toBe('tab_1:leaf_1') const msg = d.insertMessage({ diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index c6cab061700..7b650f970ba 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -6,7 +6,7 @@ export { } from './db/contract-constants' export type { RunListPage, TaskRuntimeLineageRow } from './db/run-list-page' export { ORCHESTRATION_DELIVERY_BATCH_LIMIT } from './db/messages/mailbox-routing-page' -export { DISPATCH_CONTEXT_CLAIM_SQL } from './db/dispatch-context/dispatch-context-store' +export { DISPATCH_CONTEXT_CLAIM_SQL } from './db/dispatch-row-writer' export type { ForeignDirectMailboxRoutingPage, MailboxRoutingPage diff --git a/src/main/runtime/orchestration/db/attach-orchestration-db-methods.ts b/src/main/runtime/orchestration/db/attach-orchestration-db-methods.ts index 087ec7e1e53..69f12d9bbd6 100644 --- a/src/main/runtime/orchestration/db/attach-orchestration-db-methods.ts +++ b/src/main/runtime/orchestration/db/attach-orchestration-db-methods.ts @@ -4,6 +4,7 @@ import { attachDispatchCapability } from './dispatch-context/dispatch-capability import { attachDispatchCompletion } from './dispatch-context/dispatch-completion' import { attachDispatchContextStore } from './dispatch-context/dispatch-context-store' import { attachDispatchLookup } from './dispatch-context/dispatch-lookup' +import { attachDispatchDepth } from './dispatch-depth' import { attachWorkerReportSettlement } from './dispatch-context/worker-report-settlement' import { attachFederatedDispatchStore } from './federation/federated-dispatch-store' import { attachFederationRelayAck } from './federation/federation-relay-ack' @@ -115,6 +116,7 @@ export function attachOrchestrationDbMethods(ctor: { prototype: object }): void attachDispatchContextStore(ctor) attachDispatchCapability(ctor) attachDispatchLookup(ctor) + attachDispatchDepth(ctor) attachDispatchCompletion(ctor) attachWorkerReportSettlement(ctor) attachDecisionGateStore(ctor) diff --git a/src/main/runtime/orchestration/db/contract-constants.ts b/src/main/runtime/orchestration/db/contract-constants.ts index 6d175498bc4..56390138f0a 100644 --- a/src/main/runtime/orchestration/db/contract-constants.ts +++ b/src/main/runtime/orchestration/db/contract-constants.ts @@ -7,4 +7,4 @@ export const LEGACY_CONTRACT_VERSION = 0 export const CURRENT_CONTRACT_VERSION = ORCHESTRATION_CONTRACT_VERSION // Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup, v23 worker terminal resource ownership, v24 creator-incarnation authority, v25 active Dispatch handle lookup, v26 indexed mutation receipt capacity, v27 durable federation acknowledgments, v28 durable local mutation caller identity. -export const SCHEMA_VERSION = 29 +export const SCHEMA_VERSION = 30 diff --git a/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts b/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts index c8415a0214c..cd4083acc0a 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts @@ -3,48 +3,27 @@ import { OrchestrationError } from '../../orchestration-error' import { parsePaneKey } from '../../../../../shared/stable-pane-id' import { CURRENT_CONTRACT_VERSION } from '../contract-constants' import { generateId } from '../generated-id' -import { DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL, paneKeyMatchSuffix } from '../pane-key-match' +import { paneKeyMatchSuffix } from '../pane-key-match' +import { claimDispatchContextRow } from '../dispatch-row-writer' +import type { DispatchCreator } from '../dispatch-depth' import type { OrchestrationDb } from '../orchestration-db' -export const DISPATCH_CONTEXT_CLAIM_SQL = `INSERT INTO dispatch_contexts ( - id, run_id, task_id, contract_version, launch_token_hash, - assignee_handle, assignee_pane_key, process_incarnation, - status, failure_count, dispatched_at -) -SELECT ?, run_id, id, ?, ?, ?, ?, ?, 'dispatched', ?, datetime('now') -FROM tasks -WHERE id = ? AND status = 'ready' - AND NOT EXISTS ( - SELECT 1 FROM dispatch_contexts active - WHERE active.assignee_handle = ? - AND active.status IN ('pending', 'dispatched') - ) - AND ( - ? IS NULL OR NOT EXISTS ( - SELECT 1 FROM dispatch_contexts active - WHERE active.assignee_pane_key = ? - AND active.status IN ('pending', 'dispatched') - ) - ) - AND ( - ? IS NULL OR NOT EXISTS ( - SELECT 1 FROM dispatch_contexts active - WHERE active.assignee_pane_key IS NOT NULL - AND active.status IN ('pending', 'dispatched') - AND instr(active.assignee_pane_key, ':') > 1 - AND ${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL} = ? - ) - )` - export function createDispatchContext( this: OrchestrationDb, - taskId: string, - assigneeHandle: string, - // Why: pane key is the remint-stable identity behind the handle — lets worker_done ownership survive handle reissue. - assigneePaneKey?: string, - launchTokenHash?: string, - processIncarnation?: string + params: { + taskId: string + assigneeHandle: string + // Why: pane key is the remint-stable identity behind the handle — lets worker_done ownership survive handle reissue. + assigneePaneKey?: string + launchTokenHash?: string + processIncarnation?: string + /** Who is dispatching, for nesting depth. Required so a new caller must decide. */ + creator: DispatchCreator + maxDepth: number + } ): DispatchContextRow { + const { taskId, assigneeHandle, assigneePaneKey, launchTokenHash, processIncarnation } = params + const depth = this.resolveChildDispatchDepth(params.creator, params.maxDepth) const task = this.getTask(taskId) if (!task) { throw new Error(`Task not found: ${taskId}`) @@ -73,23 +52,18 @@ export function createDispatchContext( const id = generateId('ctx') this.db.exec('SAVEPOINT create_dispatch_context') try { - const inserted = this.db - .prepare(DISPATCH_CONTEXT_CLAIM_SQL) - .run( - id, - CURRENT_CONTRACT_VERSION, - launchTokenHash ?? null, - assigneeHandle, - assigneePaneKey ?? null, - processIncarnation ?? null, - priorFailures, - taskId, - assigneeHandle, - assigneePaneKey ?? null, - assigneePaneKey ?? null, - paneSuffix, - paneSuffix - ) + const inserted = claimDispatchContextRow(this.db, { + id, + contractVersion: CURRENT_CONTRACT_VERSION, + launchTokenHash: launchTokenHash ?? null, + assigneeHandle, + assigneePaneKey: assigneePaneKey ?? null, + processIncarnation: processIncarnation ?? null, + priorFailures, + depth, + taskId, + paneSuffix + }) if (inserted.changes !== 1) { const current = this.getTask(taskId) const occupied = this.findActiveDispatchForAssignee(assigneeHandle, assigneePaneKey) diff --git a/src/main/runtime/orchestration/db/dispatch-depth.test.ts b/src/main/runtime/orchestration/db/dispatch-depth.test.ts new file mode 100644 index 00000000000..97df3f01c51 --- /dev/null +++ b/src/main/runtime/orchestration/db/dispatch-depth.test.ts @@ -0,0 +1,271 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { OrchestrationDb } from '../db' +import { AmbiguousDispatchParentError } from './dispatch-depth' + +/** + * These pin the fence Orca documented but never enforced: before this feature a + * dispatched worker could create its own Run and dispatch sub-workers freely. + * Every rejection case here passes on the pre-change tree. + */ +describe('nested worker depth', () => { + let db: OrchestrationDb + const SYSTEM = { kind: 'system' } as const + const UNCAPPED = Number.MAX_SAFE_INTEGER + + afterEach(() => db?.close()) + + function coordinatorDispatchesWorker(maxDepth = UNCAPPED) { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'root task' }) + const worker = db.createDispatchContext({ + taskId: task.id, + assigneeHandle: 'term_worker', + assigneePaneKey: 'tab_worker:leaf_worker', + creator: SYSTEM, + maxDepth + }) + return worker + } + + it('stamps a root coordinator dispatch at depth 1', () => { + expect(coordinatorDispatchesWorker().depth).toBe(1) + }) + + it('refuses a worker dispatching a sub-worker at the default cap', () => { + coordinatorDispatchesWorker() + const nested = db.createTask({ spec: 'nested task' }) + expect(() => + db.createDispatchContext({ + taskId: nested.id, + assigneeHandle: 'term_sub', + assigneePaneKey: 'tab_sub:leaf_sub', + creator: { + kind: 'terminal', + handle: 'term_worker', + paneKey: 'tab_worker:leaf_worker' + }, + maxDepth: 1 + }) + ).toThrow(/depth 2 \(max 1\)/) + }) + + it('tells the refused worker to complete the task itself', () => { + coordinatorDispatchesWorker() + const nested = db.createTask({ spec: 'nested task' }) + expect(() => + db.createDispatchContext({ + taskId: nested.id, + assigneeHandle: 'term_sub', + creator: { kind: 'terminal', handle: 'term_worker', paneKey: 'tab_worker:leaf_worker' }, + maxDepth: 1 + }) + ).toThrow(/Complete this task yourself/) + }) + + it('permits one more generation when the cap is raised, and records depth 2', () => { + coordinatorDispatchesWorker() + const nested = db.createTask({ spec: 'nested task' }) + const sub = db.createDispatchContext({ + taskId: nested.id, + assigneeHandle: 'term_sub', + assigneePaneKey: 'tab_sub:leaf_sub', + creator: { kind: 'terminal', handle: 'term_worker', paneKey: 'tab_worker:leaf_worker' }, + maxDepth: 2 + }) + expect(sub.depth).toBe(2) + }) + + it('closes the run-create bypass: a fresh Run does not reset the creator depth', () => { + // The old fence keyed off Run binding, so a worker that created its own Run + // walked straight through. Depth comes from the creator's dispatch instead. + coordinatorDispatchesWorker() + const ownRun = db.createRun({ + objective: 'worker-owned run', + coordinatorHandle: 'term_worker', + coordinatorPaneKey: 'tab_worker:leaf_worker' + }) + const nested = db.createTask({ spec: 'nested task', runId: ownRun.id }) + expect(() => + db.createDispatchContext({ + taskId: nested.id, + assigneeHandle: 'term_sub', + creator: { kind: 'terminal', handle: 'term_worker', paneKey: 'tab_worker:leaf_worker' }, + maxDepth: 1 + }) + ).toThrow(/depth 2 \(max 1\)/) + }) + + it('treats the in-process coordinator loop as a root even from a worker pane', () => { + coordinatorDispatchesWorker() + expect(db.resolveCreatorDepth({ kind: 'system' })).toBe(0) + }) + + it('resolves an unknown terminal to root depth', () => { + db = new OrchestrationDb(':memory:') + expect(db.resolveCreatorDepth({ kind: 'terminal', handle: 'term_nobody' })).toBe(0) + }) + + describe('remote attachments as parents', () => { + const PANE = 'tab_remote:leaf_remote' + const INCARNATION = 'inc-1' + + function attachRemoteWorker(state: string, depth: number, paneKey = PANE, inc = INCARNATION) { + db.db + .prepare( + `INSERT INTO remote_dispatch_attachments + (dispatch_id, task_id, home_peer_fingerprint, protocol_version, runtime_epoch, + pane_key, process_incarnation, state, depth) + VALUES (?, ?, 'peer', 1, 'epoch', ?, ?, ?, ?)` + ) + .run(`ctx_${state}_${depth}_${paneKey}_${inc}`, 'task_remote', paneKey, inc, state, depth) + } + + // Loss of contact is never evidence of process death: an unverifiable remote + // worker must still block nesting. See docs/reference/ssh-execution-boundary.md. + for (const state of ['starting', 'ready', 'start_unknown', 'stopping', 'stop_unknown']) { + it(`counts a '${state}' attachment as a live parent`, () => { + db = new OrchestrationDb(':memory:') + attachRemoteWorker(state, 1) + expect( + db.resolveCreatorDepth({ + kind: 'terminal', + handle: 'term_remote', + paneKey: PANE, + processIncarnation: INCARNATION + }) + ).toBe(1) + }) + } + + for (const state of ['succeeded', 'failed', 'stopped', 'abandoned']) { + it(`does not count a settled '${state}' attachment`, () => { + db = new OrchestrationDb(':memory:') + attachRemoteWorker(state, 1) + expect( + db.resolveCreatorDepth({ + kind: 'terminal', + handle: 'term_remote', + paneKey: PANE, + processIncarnation: INCARNATION + }) + ).toBe(0) + }) + } + + it('ignores an attachment whose pane was reused by a new process', () => { + db = new OrchestrationDb(':memory:') + attachRemoteWorker('ready', 2) + expect( + db.resolveCreatorDepth({ + kind: 'terminal', + handle: 'term_remote', + paneKey: PANE, + processIncarnation: 'inc-2' + }) + ).toBe(0) + }) + + it('fails closed when one identity matches two live attachments', () => { + db = new OrchestrationDb(':memory:') + attachRemoteWorker('ready', 1) + attachRemoteWorker('starting', 2) + expect(() => + db.resolveCreatorDepth({ + kind: 'terminal', + handle: 'term_remote', + paneKey: PANE, + processIncarnation: INCARNATION + }) + ).toThrow(AmbiguousDispatchParentError) + }) + + it('takes the maximum when a process holds both a local and a remote role', () => { + // Query order must not decide the answer: the deeper role governs. + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'local role' }) + db.createDispatchContext({ + taskId: task.id, + assigneeHandle: 'term_both', + assigneePaneKey: PANE, + creator: { kind: 'system' }, + maxDepth: UNCAPPED + }) + attachRemoteWorker('ready', 3) + expect( + db.resolveCreatorDepth({ + kind: 'terminal', + handle: 'term_both', + paneKey: PANE, + processIncarnation: INCARNATION + }) + ).toBe(3) + }) + }) + + describe('the supervised worker-start path', () => { + // r2 put enforcement in createDispatchContext and missed this entirely: + // worker-start has its own insert, and so does every retry through it. + function startWorker( + taskId: string, + creator: Parameters[0], + maxDepth: number + ) { + return db.createStartingWorkerDispatch({ + taskId, + startOptions: {}, + creator, + maxDepth + }) + } + + it('stamps depth 1 for a root coordinator', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'root work' }) + expect(startWorker(task.id, SYSTEM, UNCAPPED).dispatch.depth).toBe(1) + }) + + it('refuses a worker starting a sub-worker at the default cap', () => { + coordinatorDispatchesWorker() + const nested = db.createTask({ spec: 'nested work' }) + expect(() => + startWorker( + nested.id, + { kind: 'terminal', handle: 'term_worker', paneKey: 'tab_worker:leaf_worker' }, + 1 + ) + ).toThrow(/depth 2 \(max 1\)/) + }) + + it('refuses a worker retrying into a sub-worker at the default cap', () => { + coordinatorDispatchesWorker() + const nested = db.createTask({ spec: 'nested retry work' }) + const first = startWorker(nested.id, SYSTEM, UNCAPPED) + db.failWorkerStart(first.dispatch.id, 'accepted', 'first attempt failed') + expect(() => + db.createStartingWorkerDispatch({ + taskId: nested.id, + startOptions: {}, + retryOf: first.dispatch.id, + creator: { kind: 'terminal', handle: 'term_worker', paneKey: 'tab_worker:leaf_worker' }, + maxDepth: 1 + }) + ).toThrow(/depth 2 \(max 1\)/) + }) + }) + + it('keeps a local row with a null process incarnation eligible as a parent', () => { + // Context-only dispatch stores null on purpose; requiring an incarnation + // locally would silently drop real parents and fail open. + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'context only' }) + const row = db.createDispatchContext({ + taskId: task.id, + assigneeHandle: 'term_ctx', + assigneePaneKey: 'tab_ctx:leaf_ctx', + creator: { kind: 'system' }, + maxDepth: UNCAPPED + }) + expect(row.process_incarnation).toBeNull() + expect(db.resolveCreatorDepth({ kind: 'terminal', handle: 'term_ctx' })).toBe(1) + }) +}) diff --git a/src/main/runtime/orchestration/db/dispatch-depth.ts b/src/main/runtime/orchestration/db/dispatch-depth.ts new file mode 100644 index 00000000000..c52ab27ba67 --- /dev/null +++ b/src/main/runtime/orchestration/db/dispatch-depth.ts @@ -0,0 +1,156 @@ +import { + NESTED_WORKER_DEPTH_EXCEEDED_CODE, + NESTED_WORKER_DEPTH_EXCEEDED_NEXT_STEPS, + ROOT_DISPATCH_DEPTH, + nestedWorkerDepthExceededMessage +} from '../../../../shared/nested-worker-depth' +import { OrchestrationError } from '../orchestration-error' +import { isEquivalentPaneKey } from './pane-key-match' +import type { OrchestrationDb } from './orchestration-db' +import type { DispatchContextRow, RemoteDispatchAttachmentRow } from '../types' + +/** + * Who is creating a dispatch row, for nesting-depth purposes. + * + * `system` is Orca's own in-process coordinator loop, which is host-local code + * rather than a CLI caller and is a root by construction. It is an internal + * discriminated branch on purpose — never a caller-supplied value, or a worker + * could claim to be the loop. + */ +export type DispatchCreator = + | { kind: 'system' } + | { + kind: 'terminal' + handle: string + paneKey?: string + /** Remote attachment matching requires the exact incarnation; local rows do not. */ + processIncarnation?: string + } + +/** + * Attachment states in which the worker may still be running. + * + * `start_unknown` means prompt delivery may have succeeded; `stopping` and + * `stop_unknown` do not establish that the process exited. Loss of contact is + * never evidence of process death — see docs/reference/ssh-execution-boundary.md. + * An `unverifiable` worker must still count as a nesting parent. + */ +const POTENTIALLY_LIVE_ATTACHMENT_STATES = [ + 'starting', + 'ready', + 'start_unknown', + 'stopping', + 'stop_unknown' +] as const + +export class AmbiguousDispatchParentError extends Error { + constructor(message: string) { + super(message) + this.name = 'AmbiguousDispatchParentError' + } +} + +/** + * Depth of the deepest live role this caller currently holds. + * + * Why the maximum rather than the first match: one terminal process can hold a + * local dispatch and a remote attachment at the same time, and the command + * cannot say which role motivated it. Taking the maximum cannot undercount, so + * it cannot let a deep worker pass as a shallow one. + */ +export function resolveCreatorDepth(this: OrchestrationDb, creator: DispatchCreator): number { + if (creator.kind === 'system') { + return ROOT_DISPATCH_DEPTH + } + + const depths: number[] = [] + + // Local rows match on handle/pane as they always have. process_incarnation is + // nullable here and context-only dispatch stores null deliberately, so + // requiring it would drop real parents. + const local = this.findActiveDispatchForAssignee(creator.handle, creator.paneKey) as + | DispatchContextRow + | undefined + if (local) { + depths.push(local.depth) + } + + for (const attachment of findPotentiallyLiveAttachmentsForCreator.call(this, creator)) { + depths.push(attachment.depth) + } + + return depths.length > 0 ? Math.max(...depths) : ROOT_DISPATCH_DEPTH +} + +/** + * Remote attachments matching this caller's pane AND exact process incarnation. + * + * Handle is deliberately not compared: identity survives handle remint and + * nothing updates `remote_dispatch_attachments.terminal_handle` when it happens, + * so a stored-handle predicate would reject a live federated parent. Pane + * equivalence plus exact incarnation is what remote authority already uses. + */ +function findPotentiallyLiveAttachmentsForCreator( + this: OrchestrationDb, + creator: Extract +): RemoteDispatchAttachmentRow[] { + if (!creator.paneKey || !creator.processIncarnation) { + return [] + } + const placeholders = POTENTIALLY_LIVE_ATTACHMENT_STATES.map(() => '?').join(', ') + const rows = this.db + .prepare( + `SELECT * FROM remote_dispatch_attachments + WHERE process_incarnation = ? + AND pane_key IS NOT NULL + AND state IN (${placeholders})` + ) + .all( + creator.processIncarnation, + ...POTENTIALLY_LIVE_ATTACHMENT_STATES + ) as RemoteDispatchAttachmentRow[] + + const matches = rows.filter( + (row) => row.pane_key !== null && isEquivalentPaneKey(row.pane_key, creator.paneKey as string) + ) + + // Two live attachments for one identity is an anomaly, not a depth question. + // Surface it rather than silently picking one. + if (matches.length > 1) { + throw new AmbiguousDispatchParentError( + `Terminal ${creator.handle} matches ${matches.length} live remote attachments; cannot establish nesting depth.` + ) + } + return matches +} + +/** + * Depth to stamp on a row this creator is about to make, rejecting over-cap. + * + * Every path that mints a live worker goes through here, so the cap cannot be + * skipped by adding a new spawn verb. + */ +export function resolveChildDispatchDepth( + this: OrchestrationDb, + creator: DispatchCreator, + maxDepth: number +): number { + const childDepth = this.resolveCreatorDepth(creator) + 1 + if (childDepth > maxDepth) { + throw new OrchestrationError( + NESTED_WORKER_DEPTH_EXCEEDED_CODE, + nestedWorkerDepthExceededMessage(childDepth, maxDepth), + { effectsApplied: false, nextSteps: [...NESTED_WORKER_DEPTH_EXCEEDED_NEXT_STEPS] } + ) + } + return childDepth +} + +export type DispatchDepthMethods = { + resolveCreatorDepth: typeof resolveCreatorDepth + resolveChildDispatchDepth: typeof resolveChildDispatchDepth +} + +export function attachDispatchDepth(ctor: { prototype: object }): void { + Object.assign(ctor.prototype, { resolveCreatorDepth, resolveChildDispatchDepth }) +} diff --git a/src/main/runtime/orchestration/db/dispatch-row-writer-boundary.test.ts b/src/main/runtime/orchestration/db/dispatch-row-writer-boundary.test.ts new file mode 100644 index 00000000000..9b7581d6acd --- /dev/null +++ b/src/main/runtime/orchestration/db/dispatch-row-writer-boundary.test.ts @@ -0,0 +1,139 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { join, relative, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * Guard the live-worker row chokepoint at the tree level rather than per call site. + * + * Nesting depth has to be stamped on every row that represents a live supervised + * worker. Three separate modules used to own their own INSERT, and three review + * rounds each found one more spawn path than the previous round believed existed. + * `dispatch-row-writer.ts` owns the statements once; this test is what stops the + * fourth path from owning one again. + * + * Known limit, recorded rather than assumed away: this scans SQL string literals. + * SQL assembled from a shared table-name constant, split template fragments, or a + * query builder would evade it — see the detector cases below. + */ +const WRITER_MODULE = 'src/main/runtime/orchestration/db/dispatch-row-writer.ts' + +const GUARDED_TABLES = ['dispatch_contexts', 'remote_dispatch_attachments'] as const + +/** `INSERT ... INTO `, tolerating OR-clauses and newlines between the words. */ +const insertPattern = (table: string): RegExp => + new RegExp(String.raw`INSERT\b[\s\S]{0,40}?\bINTO\s+${table}\b`, 'i') + +/** + * Schema DDL, migrations, and reset all legitimately name these tables. They + * create, alter, and delete rows — they never mint a live worker. + */ +const EXEMPT_PATH_FRAGMENTS = ['/db/schema/', '/db/reset/', '/orchestration-schema-version-skew'] + +const SCANNED_EXTENSIONS = ['.ts', '.tsx'] +const IGNORED_DIRECTORIES = new Set(['node_modules', 'dist', 'out', 'build', '.git']) + +function isTestFile(path: string): boolean { + return ( + /\.(?:test|spec)\.tsx?$/.test(path) || + /(?:test-harness|test-utils|test-setup|test-fixture)/.test(path) || + path.includes('/__tests__/') || + path.includes('/__fixtures__/') + ) +} + +function collectSourceFiles(root: string): string[] { + const found: string[] = [] + let entries: string[] + try { + entries = readdirSync(root) + } catch { + return found + } + for (const entry of entries) { + if (IGNORED_DIRECTORIES.has(entry)) { + continue + } + const full = join(root, entry) + if (statSync(full).isDirectory()) { + found.push(...collectSourceFiles(full)) + } else if (SCANNED_EXTENSIONS.some((ext) => entry.endsWith(ext))) { + found.push(full) + } + } + return found +} + +describe('live-worker row insert boundary', () => { + const repoRoot = resolve(__dirname, '../../../../..') + const srcRoot = join(repoRoot, 'src') + + it('inserts guarded tables only from dispatch-row-writer.ts', () => { + const offenders: string[] = [] + for (const file of collectSourceFiles(srcRoot)) { + const rel = relative(repoRoot, file).split('\\').join('/') + if (rel === WRITER_MODULE || isTestFile(rel)) { + continue + } + if (EXEMPT_PATH_FRAGMENTS.some((fragment) => rel.includes(fragment))) { + continue + } + const contents = readFileSync(file, 'utf8') + for (const table of GUARDED_TABLES) { + if (insertPattern(table).test(contents)) { + offenders.push(`${rel} inserts ${table}`) + } + } + } + expect(offenders).toEqual([]) + }) + + it('the writer module actually owns an insert for every guarded table', () => { + const contents = readFileSync(join(repoRoot, WRITER_MODULE), 'utf8') + for (const table of GUARDED_TABLES) { + expect(insertPattern(table).test(contents)).toBe(true) + } + }) + + it('does not fire on schema DDL, migration DDL, or reset SQL', () => { + // Why explicit: a naive identifier scan flags all three, which is how the + // first two drafts of this ratchet failed against their own tree. + const exempt = [ + 'src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts', + 'src/main/runtime/orchestration/db/schema/migrate-v13-v30.ts', + 'src/main/runtime/orchestration/db/reset/orchestration-reset.ts' + ] + for (const rel of exempt) { + expect( + EXEMPT_PATH_FRAGMENTS.some((fragment) => rel.includes(fragment)), + `${rel} must be exempt` + ).toBe(true) + } + }) + + it('detects the insert forms it claims to detect', () => { + expect(insertPattern('dispatch_contexts').test('INSERT INTO dispatch_contexts (id)')).toBe(true) + expect( + insertPattern('dispatch_contexts').test('INSERT OR REPLACE INTO dispatch_contexts (id)') + ).toBe(true) + expect(insertPattern('dispatch_contexts').test('INSERT\n INTO dispatch_contexts')).toBe(true) + expect(insertPattern('dispatch_contexts').test('SELECT * FROM dispatch_contexts')).toBe(false) + expect(insertPattern('dispatch_contexts').test('DELETE FROM dispatch_contexts')).toBe(false) + // Guards against matching the longer sibling table name by prefix. + expect(insertPattern('dispatch_contexts').test('INSERT INTO dispatch_contexts_archive')).toBe( + false + ) + }) + + it('records the evasions this scanner cannot catch', () => { + // Why asserted rather than commented: these are the scanner's known blind + // spots. Centralization is the convention; this test only guards the common + // form. If any of these ever becomes reachable in production SQL, the + // boundary needs an AST-level check instead. + const dynamicTable = 'const t = "dispatch_contexts"; db.prepare(`INSERT INTO ${t} (id)`)' + const splitLiteral = 'db.prepare("INSERT INTO " + "dispatch_contexts (id)")' + const queryBuilder = 'db.insertInto("dispatch_contexts").values({ id })' + expect(insertPattern('dispatch_contexts').test(dynamicTable)).toBe(false) + expect(insertPattern('dispatch_contexts').test(splitLiteral)).toBe(false) + expect(insertPattern('dispatch_contexts').test(queryBuilder)).toBe(false) + }) +}) diff --git a/src/main/runtime/orchestration/db/dispatch-row-writer.ts b/src/main/runtime/orchestration/db/dispatch-row-writer.ts new file mode 100644 index 00000000000..606081e58a3 --- /dev/null +++ b/src/main/runtime/orchestration/db/dispatch-row-writer.ts @@ -0,0 +1,144 @@ +import type Database from '../../../sqlite/sync-database' +import { DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL } from './pane-key-match' + +/** + * The only place that inserts rows representing a live supervised worker. + * + * Why centralized: nesting depth must be stamped on every such row, and three + * separate modules used to own their own INSERT. A boundary test forbids these + * statements anywhere else, so a new spawn path cannot skip the stamp. + * + * Transaction-neutral on purpose — each caller keeps its own BEGIN IMMEDIATE or + * SAVEPOINT, mutation-receipt write, and companion inserts. + */ + +export const DISPATCH_CONTEXT_CLAIM_SQL = `INSERT INTO dispatch_contexts ( + id, run_id, task_id, contract_version, launch_token_hash, + assignee_handle, assignee_pane_key, process_incarnation, + status, failure_count, depth, dispatched_at +) +SELECT ?, run_id, id, ?, ?, ?, ?, ?, 'dispatched', ?, ?, datetime('now') +FROM tasks +WHERE id = ? AND status = 'ready' + AND NOT EXISTS ( + SELECT 1 FROM dispatch_contexts active + WHERE active.assignee_handle = ? + AND active.status IN ('pending', 'dispatched') + ) + AND ( + ? IS NULL OR NOT EXISTS ( + SELECT 1 FROM dispatch_contexts active + WHERE active.assignee_pane_key = ? + AND active.status IN ('pending', 'dispatched') + ) + ) + AND ( + ? IS NULL OR NOT EXISTS ( + SELECT 1 FROM dispatch_contexts active + WHERE active.assignee_pane_key IS NOT NULL + AND active.status IN ('pending', 'dispatched') + AND instr(active.assignee_pane_key, ':') > 1 + AND ${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL} = ? + ) + )` + +const STARTING_DISPATCH_CONTEXT_SQL = `INSERT INTO dispatch_contexts ( + id, run_id, task_id, contract_version, launch_token_hash, depth, status, dispatched_at + ) VALUES (?, ?, ?, ?, ?, ?, 'pending', datetime('now'))` + +const REMOTE_DISPATCH_ATTACHMENT_SQL = `INSERT INTO remote_dispatch_attachments ( + dispatch_id, task_id, home_peer_fingerprint, protocol_version, runtime_epoch, depth + ) VALUES (?, ?, ?, ?, ?, ?)` + +/** Last line of defence: a row that reached here unstamped would read as a root. */ +function assertStampedDepth(depth: number): void { + if (!Number.isInteger(depth) || depth < 1) { + throw new Error( + `Refusing to write a live-worker row with depth ${depth}; expected an integer >= 1.` + ) + } +} + +/** Adopts an existing agent terminal, claiming a ready task atomically. */ +export function claimDispatchContextRow( + db: Database.Database, + params: { + id: string + contractVersion: number + launchTokenHash: string | null + assigneeHandle: string + assigneePaneKey: string | null + processIncarnation: string | null + priorFailures: number + depth: number + taskId: string + paneSuffix: string | null + } +): { changes: number | bigint } { + assertStampedDepth(params.depth) + return db + .prepare(DISPATCH_CONTEXT_CLAIM_SQL) + .run( + params.id, + params.contractVersion, + params.launchTokenHash, + params.assigneeHandle, + params.assigneePaneKey, + params.processIncarnation, + params.priorFailures, + params.depth, + params.taskId, + params.assigneeHandle, + params.assigneePaneKey, + params.assigneePaneKey, + params.paneSuffix, + params.paneSuffix + ) +} + +/** Supervised `worker-start`, including every retry and the federated home side. */ +export function insertStartingDispatchContextRow( + db: Database.Database, + params: { + id: string + runId: string + taskId: string + contractVersion: number + launchTokenHash: string | null + depth: number + } +): void { + assertStampedDepth(params.depth) + db.prepare(STARTING_DISPATCH_CONTEXT_SQL).run( + params.id, + params.runId, + params.taskId, + params.contractVersion, + params.launchTokenHash, + params.depth + ) +} + +/** The worker host's record of a live worker driven by a remote Run home. */ +export function insertRemoteDispatchAttachmentRow( + db: Database.Database, + params: { + dispatchId: string + taskId: string + homePeerFingerprint: string + protocolVersion: number + runtimeEpoch: string + /** Propagated from the Run home, not computed here; absent (old client) = 1. */ + depth: number + } +): void { + assertStampedDepth(params.depth) + db.prepare(REMOTE_DISPATCH_ATTACHMENT_SQL).run( + params.dispatchId, + params.taskId, + params.homePeerFingerprint, + params.protocolVersion, + params.runtimeEpoch, + params.depth + ) +} diff --git a/src/main/runtime/orchestration/db/federation/remote-dispatch-attachment-create.ts b/src/main/runtime/orchestration/db/federation/remote-dispatch-attachment-create.ts index bb724f74cb8..56d26ccfe3b 100644 --- a/src/main/runtime/orchestration/db/federation/remote-dispatch-attachment-create.ts +++ b/src/main/runtime/orchestration/db/federation/remote-dispatch-attachment-create.ts @@ -2,6 +2,7 @@ import type { WorkerDispatchState, RemoteDispatchAttachmentRow } from '../../typ import { OrchestrationError } from '../../orchestration-error' import { ensureMutationReceiptCapacity } from '../../mutation-receipt-capacity' import type { OrchestrationDb } from '../orchestration-db' +import { insertRemoteDispatchAttachmentRow } from '../dispatch-row-writer' export function createRemoteDispatchAttachment( this: OrchestrationDb, @@ -11,6 +12,8 @@ export function createRemoteDispatchAttachment( homePeerFingerprint: string protocolVersion: number runtimeEpoch: string + /** Child depth computed by the Run home; absent from an old client = 1 (fails closed). */ + depth?: number mutationReceipt: { callerFingerprint: string requestId: string @@ -54,19 +57,14 @@ export function createRemoteDispatchAttachment( params.mutationReceipt.payloadHash, JSON.stringify({ accepted: { dispatchId: params.dispatchId } }) ) - this.db - .prepare( - `INSERT INTO remote_dispatch_attachments ( - dispatch_id, task_id, home_peer_fingerprint, protocol_version, runtime_epoch - ) VALUES (?, ?, ?, ?, ?)` - ) - .run( - params.dispatchId, - params.taskId, - params.homePeerFingerprint, - params.protocolVersion, - params.runtimeEpoch - ) + insertRemoteDispatchAttachmentRow(this.db, { + dispatchId: params.dispatchId, + taskId: params.taskId, + homePeerFingerprint: params.homePeerFingerprint, + protocolVersion: params.protocolVersion, + runtimeEpoch: params.runtimeEpoch, + depth: params.depth ?? 1 + }) this.db.exec('COMMIT') return this.getRemoteDispatchAttachment(params.dispatchId) as RemoteDispatchAttachmentRow } catch (error) { diff --git a/src/main/runtime/orchestration/db/orchestration-db-methods.ts b/src/main/runtime/orchestration/db/orchestration-db-methods.ts index 8fa48a0edd3..7f25b209c54 100644 --- a/src/main/runtime/orchestration/db/orchestration-db-methods.ts +++ b/src/main/runtime/orchestration/db/orchestration-db-methods.ts @@ -4,6 +4,7 @@ import type { DispatchCapabilityMethods } from './dispatch-context/dispatch-capa import type { DispatchCompletionMethods } from './dispatch-context/dispatch-completion' import type { DispatchContextStoreMethods } from './dispatch-context/dispatch-context-store' import type { DispatchLookupMethods } from './dispatch-context/dispatch-lookup' +import type { DispatchDepthMethods } from './dispatch-depth' import type { WorkerReportSettlementMethods } from './dispatch-context/worker-report-settlement' import type { FederatedDispatchStoreMethods } from './federation/federated-dispatch-store' import type { FederationRelayAckMethods } from './federation/federation-relay-ack' @@ -114,6 +115,7 @@ export type OrchestrationDbMethods = CreateTablesMethods & DispatchContextStoreMethods & DispatchCapabilityMethods & DispatchLookupMethods & + DispatchDepthMethods & DispatchCompletionMethods & WorkerReportSettlementMethods & DecisionGateStoreMethods & diff --git a/src/main/runtime/orchestration/db/root-dispatch-test-fixture.ts b/src/main/runtime/orchestration/db/root-dispatch-test-fixture.ts new file mode 100644 index 00000000000..4b16c95e0a6 --- /dev/null +++ b/src/main/runtime/orchestration/db/root-dispatch-test-fixture.ts @@ -0,0 +1,28 @@ +import type { DispatchContextRow } from '../types' +import type { OrchestrationDb } from './orchestration-db' + +/** + * Dispatch as a root coordinator with no nesting cap. + * + * Tests that predate nesting depth care about dispatch behaviour, not the cap; + * this keeps them at their original call shape instead of repeating the same + * creator/maxDepth pair at every site. + */ +export function createRootDispatch( + db: OrchestrationDb, + taskId: string, + assigneeHandle: string, + assigneePaneKey?: string, + launchTokenHash?: string, + processIncarnation?: string +): DispatchContextRow { + return db.createDispatchContext({ + taskId, + assigneeHandle, + assigneePaneKey, + launchTokenHash, + processIncarnation, + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) +} diff --git a/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts b/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts index 720b7153cea..07a47c3c80c 100644 --- a/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts +++ b/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts @@ -42,17 +42,24 @@ CREATE TABLE IF NOT EXISTS remote_dispatch_attachments ( effects TEXT NOT NULL DEFAULT '[]', residual_resources TEXT NOT NULL DEFAULT '[]', to_worker_imported_sequence INTEGER NOT NULL DEFAULT 0, + -- Nesting depth of the worker this attachment represents. Propagated from the + -- Run home; absent from an old client means 1, which fails closed. + depth INTEGER NOT NULL DEFAULT 1, last_error TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); +-- Why five states: 'start_unknown', 'stopping', and 'stop_unknown' do not +-- establish process exit, and a potentially-live worker must still count as a +-- nesting parent. See docs/reference/ssh-execution-boundary.md. CREATE INDEX IF NOT EXISTS idx_remote_dispatch_attachments_active_pane ON remote_dispatch_attachments(pane_key) - WHERE state IN ('starting', 'ready'); + WHERE state IN ('starting', 'ready', 'start_unknown', 'stopping', 'stop_unknown'); CREATE INDEX IF NOT EXISTS idx_remote_dispatch_attachments_active_pane_suffix ON remote_dispatch_attachments(${REMOTE_ATTACHMENT_PANE_KEY_MATCH_SUFFIX_SQL}) - WHERE state IN ('starting', 'ready') AND pane_key IS NOT NULL; + WHERE state IN ('starting', 'ready', 'start_unknown', 'stopping', 'stop_unknown') + AND pane_key IS NOT NULL; CREATE TABLE IF NOT EXISTS federation_relay_items ( dispatch_id TEXT NOT NULL, @@ -127,6 +134,9 @@ CREATE TABLE IF NOT EXISTS dispatch_contexts ( last_failure TEXT, -- Why the process is gone, when Orca could establish it. See TerminalExitCause. termination_reason TEXT, + -- Nesting depth: a root coordinator's worker is 1, its worker's worker is 2. + -- Defaults to 1 so an unstamped row fails closed rather than reading as a root. + depth INTEGER NOT NULL DEFAULT 1, dispatched_at TEXT, completed_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), diff --git a/src/main/runtime/orchestration/db/schema/migrate-v13-v29.ts b/src/main/runtime/orchestration/db/schema/migrate-v13-v30.ts similarity index 84% rename from src/main/runtime/orchestration/db/schema/migrate-v13-v29.ts rename to src/main/runtime/orchestration/db/schema/migrate-v13-v30.ts index b3129dc7ed6..654395bcd2c 100644 --- a/src/main/runtime/orchestration/db/schema/migrate-v13-v29.ts +++ b/src/main/runtime/orchestration/db/schema/migrate-v13-v30.ts @@ -1,8 +1,11 @@ import { migrateMutationReceiptCapacity } from '../../mutation-receipt-capacity' -import { DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL } from '../pane-key-match' +import { + DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL, + REMOTE_ATTACHMENT_PANE_KEY_MATCH_SUFFIX_SQL +} from '../pane-key-match' import type { OrchestrationDb } from '../orchestration-db' -export function applySchemaMigrationsV13ToV29(this: OrchestrationDb, current: number): void { +export function applySchemaMigrationsV13ToV30(this: OrchestrationDb, current: number): void { if (current < 13 && !this.hasColumn('worker_dispatches', 'runtime_epoch')) { this.db.exec('ALTER TABLE worker_dispatches ADD COLUMN runtime_epoch TEXT') } @@ -157,6 +160,29 @@ export function applySchemaMigrationsV13ToV29(this: OrchestrationDb, current: nu if (current < 29 && !this.hasColumn('dispatch_contexts', 'termination_reason')) { this.db.exec('ALTER TABLE dispatch_contexts ADD COLUMN termination_reason TEXT') } + if (current < 30) { + if (!this.hasColumn('dispatch_contexts', 'depth')) { + this.db.exec('ALTER TABLE dispatch_contexts ADD COLUMN depth INTEGER NOT NULL DEFAULT 1') + } + if (!this.hasColumn('remote_dispatch_attachments', 'depth')) { + this.db.exec( + 'ALTER TABLE remote_dispatch_attachments ADD COLUMN depth INTEGER NOT NULL DEFAULT 1' + ) + } + // Why drop first: CREATE INDEX IF NOT EXISTS cannot widen an existing + // partial index predicate, and these two covered only starting/ready. + this.db.exec(` + DROP INDEX IF EXISTS idx_remote_dispatch_attachments_active_pane; + DROP INDEX IF EXISTS idx_remote_dispatch_attachments_active_pane_suffix; + CREATE INDEX IF NOT EXISTS idx_remote_dispatch_attachments_active_pane + ON remote_dispatch_attachments(pane_key) + WHERE state IN ('starting', 'ready', 'start_unknown', 'stopping', 'stop_unknown'); + CREATE INDEX IF NOT EXISTS idx_remote_dispatch_attachments_active_pane_suffix + ON remote_dispatch_attachments(${REMOTE_ATTACHMENT_PANE_KEY_MATCH_SUFFIX_SQL}) + WHERE state IN ('starting', 'ready', 'start_unknown', 'stopping', 'stop_unknown') + AND pane_key IS NOT NULL; + `) + } this.db.exec(` CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_pane_leaf ON dispatch_contexts(${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL}) diff --git a/src/main/runtime/orchestration/db/schema/migrate.ts b/src/main/runtime/orchestration/db/schema/migrate.ts index 3c33b5c8ce5..b29debf6aa1 100644 --- a/src/main/runtime/orchestration/db/schema/migrate.ts +++ b/src/main/runtime/orchestration/db/schema/migrate.ts @@ -1,7 +1,7 @@ import { resolveOrchestrationMigrationStartVersion } from '../../orchestration-schema-version-skew' import { SCHEMA_VERSION } from '../contract-constants' import type { OrchestrationDb } from '../orchestration-db' -import { applySchemaMigrationsV13ToV29 } from './migrate-v13-v29' +import { applySchemaMigrationsV13ToV30 } from './migrate-v13-v30' import { applySchemaMigrationsV2ToV12 } from './migrate-v2-v12' // Why: CREATE TABLE IF NOT EXISTS won't alter existing DBs; migrate in a txn that bumps user_version only on success (atomic all-or-nothing). @@ -15,7 +15,7 @@ export function migrate(this: OrchestrationDb): void { this.db.exec('BEGIN IMMEDIATE') try { applySchemaMigrationsV2ToV12.call(this, current) - applySchemaMigrationsV13ToV29.call(this, current) + applySchemaMigrationsV13ToV30.call(this, current) this.db.pragma(`user_version = ${SCHEMA_VERSION}`) this.db.exec('COMMIT') } catch (err) { diff --git a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-start.ts b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-start.ts index 91f590895dc..e26369e7fc1 100644 --- a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-start.ts +++ b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-start.ts @@ -4,6 +4,8 @@ import { ensureMutationReceiptCapacity } from '../../mutation-receipt-capacity' import { CURRENT_CONTRACT_VERSION } from '../contract-constants' import { generateId } from '../generated-id' import type { OrchestrationDb } from '../orchestration-db' +import { insertStartingDispatchContextRow } from '../dispatch-row-writer' +import type { DispatchCreator } from '../dispatch-depth' export function createStartingWorkerDispatch( this: OrchestrationDb, @@ -25,6 +27,9 @@ export function createStartingWorkerDispatch( method: string payloadHash: string } + /** Who is dispatching, for nesting depth. Required so a new caller must decide. */ + creator: DispatchCreator + maxDepth: number } ): { dispatch: DispatchContextRow; worker: WorkerDispatchRow } { this.db.exec('BEGIN IMMEDIATE') @@ -95,13 +100,14 @@ export function createStartingWorkerDispatch( params.mutationReceipt.requestId ) } - this.db - .prepare( - `INSERT INTO dispatch_contexts ( - id, run_id, task_id, contract_version, launch_token_hash, status, dispatched_at - ) VALUES (?, ?, ?, ?, ?, 'pending', datetime('now'))` - ) - .run(id, task.run_id, task.id, CURRENT_CONTRACT_VERSION, params.launchTokenHash ?? null) + insertStartingDispatchContextRow(this.db, { + id, + runId: task.run_id, + taskId: task.id, + contractVersion: CURRENT_CONTRACT_VERSION, + launchTokenHash: params.launchTokenHash ?? null, + depth: this.resolveChildDispatchDepth(params.creator, params.maxDepth) + }) this.db .prepare( `INSERT INTO worker_dispatches ( diff --git a/src/main/runtime/orchestration/dispatch-failure-idempotency.test.ts b/src/main/runtime/orchestration/dispatch-failure-idempotency.test.ts index 9b526c480cc..c6f75723cf3 100644 --- a/src/main/runtime/orchestration/dispatch-failure-idempotency.test.ts +++ b/src/main/runtime/orchestration/dispatch-failure-idempotency.test.ts @@ -1,12 +1,13 @@ import { describe, expect, it } from 'vitest' import type Database from '../../sqlite/sync-database' import { OrchestrationDb } from './db' +import { createRootDispatch } from './db/root-dispatch-test-fixture' describe('dispatch failure idempotency', () => { it('counts an active dispatch failure only once', () => { const db = new OrchestrationDb(':memory:') const task = db.createTask({ spec: 'work' }) - const dispatch = db.createDispatchContext(task.id, 'term_worker') + const dispatch = createRootDispatch(db, task.id, 'term_worker') expect(db.failDispatch(dispatch.id, 'exit')?.failure_count).toBe(1) const duplicate = db.failDispatch(dispatch.id, 'duplicate escalation') @@ -19,7 +20,7 @@ describe('dispatch failure idempotency', () => { it('does not overwrite a completed dispatch', () => { const db = new OrchestrationDb(':memory:') const task = db.createTask({ spec: 'work' }) - const dispatch = db.createDispatchContext(task.id, 'term_worker') + const dispatch = createRootDispatch(db, task.id, 'term_worker') db.completeDispatch(dispatch.id) const lateFailure = db.failDispatch(dispatch.id, 'late exit') @@ -33,7 +34,7 @@ describe('dispatch failure idempotency', () => { const db = new OrchestrationDb(':memory:') const sqlite = (db as unknown as { db: Database.Database }).db const task = db.createTask({ spec: 'work' }) - const dispatch = db.createDispatchContext(task.id, 'term_worker') + const dispatch = createRootDispatch(db, task.id, 'term_worker') sqlite.exec(` CREATE TRIGGER reject_task_failure_update BEFORE UPDATE ON tasks WHEN OLD.id = '${task.id}' diff --git a/src/main/runtime/orchestration/federation-sync.test.ts b/src/main/runtime/orchestration/federation-sync.test.ts index 55372924d57..a944b494660 100644 --- a/src/main/runtime/orchestration/federation-sync.test.ts +++ b/src/main/runtime/orchestration/federation-sync.test.ts @@ -128,6 +128,8 @@ describe('federation relay parsing', () => { }) const task = db.createTask({ spec: 'Remote work', runId: run.id }) const { dispatch } = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, taskId: task.id, startOptions: {}, federation: { @@ -196,6 +198,8 @@ describe('federation relay acknowledgments', () => { }) const task = db.createTask({ spec: 'Remote work', runId: run.id }) const { dispatch } = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, taskId: task.id, startOptions: {}, federation: { diff --git a/src/main/runtime/orchestration/lifecycle-reconciliation.test.ts b/src/main/runtime/orchestration/lifecycle-reconciliation.test.ts index 3a916751533..7011c191c00 100644 --- a/src/main/runtime/orchestration/lifecycle-reconciliation.test.ts +++ b/src/main/runtime/orchestration/lifecycle-reconciliation.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { OrchestrationDb } from './db' import { reconcileLifecycleMessage } from './lifecycle-reconciliation' +import { createRootDispatch } from './db/root-dispatch-test-fixture' describe('lifecycle reconciliation', () => { let db: OrchestrationDb @@ -10,7 +11,7 @@ describe('lifecycle reconciliation', () => { it('rejects handle churn when neither side has stable pane identity', () => { db = new OrchestrationDb(':memory:') const task = db.createTask({ spec: 'work' }) - const dispatch = db.createDispatchContext(task.id, 'term_before_restart') + const dispatch = createRootDispatch(db, task.id, 'term_before_restart') const logs: string[] = [] const message = db.insertMessage({ from: 'term_after_restart', @@ -37,7 +38,7 @@ describe('lifecycle reconciliation', () => { it('completes worker_done from the dispatched pane after a handle remint', () => { db = new OrchestrationDb(':memory:') const task = db.createTask({ spec: 'work' }) - const dispatch = db.createDispatchContext(task.id, 'term_before_restart', `tab_w:${LEAF_A}`) + const dispatch = createRootDispatch(db, task.id, 'term_before_restart', `tab_w:${LEAF_A}`) const message = db.insertMessage({ from: 'term_after_restart', to: 'term_coordinator', @@ -54,7 +55,7 @@ describe('lifecycle reconciliation', () => { it('fails both the dispatch and task from an authenticated failed worker report', () => { db = new OrchestrationDb(':memory:') const task = db.createTask({ spec: 'work' }) - const dispatch = db.createDispatchContext(task.id, 'term_worker', `tab_w:${LEAF_A}`) + const dispatch = createRootDispatch(db, task.id, 'term_worker', `tab_w:${LEAF_A}`) const message = db.insertMessage({ from: 'term_worker', to: 'term_coordinator', @@ -87,7 +88,7 @@ describe('lifecycle reconciliation', () => { it('replays an identical terminal outcome without mutating settled state', () => { db = new OrchestrationDb(':memory:') const task = db.createTask({ spec: 'work' }) - const dispatch = db.createDispatchContext(task.id, 'term_worker') + const dispatch = createRootDispatch(db, task.id, 'term_worker') const makeMessage = () => db.insertMessage({ from: 'term_worker', @@ -144,7 +145,7 @@ describe('lifecycle reconciliation', () => { const task = db.createTask({ spec: 'work' }) // Dispatch recorded the post-break-out pane key; the worker shell still // holds the spawn-time key with the old tab id. - const dispatch = db.createDispatchContext(task.id, 'term_before_restart', `tab_new:${LEAF_A}`) + const dispatch = createRootDispatch(db, task.id, 'term_before_restart', `tab_new:${LEAF_A}`) const message = db.insertMessage({ from: 'term_after_restart', to: 'term_coordinator', @@ -161,7 +162,7 @@ describe('lifecycle reconciliation', () => { it('rejects mismatched opaque pane keys instead of treating them as legacy', () => { db = new OrchestrationDb(':memory:') const task = db.createTask({ spec: 'work' }) - const dispatch = db.createDispatchContext(task.id, 'term_owner', `tab_w:${LEAF_A}`) + const dispatch = createRootDispatch(db, task.id, 'term_owner', `tab_w:${LEAF_A}`) const message = db.insertMessage({ from: 'term_reminted', to: 'term_coordinator', @@ -178,7 +179,7 @@ describe('lifecycle reconciliation', () => { it('rejects worker_done from a foreign pane that claims the assignee handle', () => { db = new OrchestrationDb(':memory:') const task = db.createTask({ spec: 'work' }) - const dispatch = db.createDispatchContext(task.id, 'term_owner', `tab_w1:${LEAF_A}`) + const dispatch = createRootDispatch(db, task.id, 'term_owner', `tab_w1:${LEAF_A}`) const message = db.insertMessage({ from: 'term_owner', to: 'term_coordinator', @@ -221,7 +222,7 @@ describe('lifecycle reconciliation', () => { it('does not let a caller-supplied rejection marker turn completion into success', () => { db = new OrchestrationDb(':memory:') const task = db.createTask({ spec: 'work' }) - const dispatch = db.createDispatchContext(task.id, 'term_worker', `tab_w:${LEAF_A}`) + const dispatch = createRootDispatch(db, task.id, 'term_worker', `tab_w:${LEAF_A}`) const message = db.insertMessage({ from: 'term_worker', to: 'term_coordinator', @@ -250,7 +251,7 @@ describe('lifecycle reconciliation', () => { it('rejects a coordinator completion for a pane-bound dispatch', () => { db = new OrchestrationDb(':memory:') const task = db.createTask({ spec: 'work' }) - const dispatch = db.createDispatchContext(task.id, 'term_worker', `tab_w:${LEAF_A}`) + const dispatch = createRootDispatch(db, task.id, 'term_worker', `tab_w:${LEAF_A}`) const message = db.insertMessage({ from: 'term_coordinator', to: 'term_coordinator', @@ -269,7 +270,7 @@ describe('lifecycle reconciliation', () => { it('uses exact handle equality only for a legacy dispatch without a pane key', () => { db = new OrchestrationDb(':memory:') const acceptedTask = db.createTask({ spec: 'legacy work' }) - const acceptedDispatch = db.createDispatchContext(acceptedTask.id, 'term_legacy') + const acceptedDispatch = createRootDispatch(db, acceptedTask.id, 'term_legacy') const accepted = db.insertMessage({ from: 'term_legacy', to: 'term_coordinator', @@ -284,7 +285,7 @@ describe('lifecycle reconciliation', () => { expect(reconcileLifecycleMessage(db, accepted).action).toBe('completed') const rejectedTask = db.createTask({ spec: 'other legacy work' }) - const rejectedDispatch = db.createDispatchContext(rejectedTask.id, 'term_other_legacy') + const rejectedDispatch = createRootDispatch(db, rejectedTask.id, 'term_other_legacy') const rejected = db.insertMessage({ from: 'term_foreign', to: 'term_coordinator', @@ -307,7 +308,7 @@ describe('lifecycle reconciliation', () => { db = new OrchestrationDb(':memory:') const parent = db.createTask({ spec: 'parent' }) const child = db.createTask({ spec: 'child', deps: [parent.id] }) - const dispatch = db.createDispatchContext(parent.id, 'term_worker', `tab_w:${LEAF_A}`) + const dispatch = createRootDispatch(db, parent.id, 'term_worker', `tab_w:${LEAF_A}`) const payload = JSON.stringify({ taskId: parent.id, dispatchId: dispatch.id, @@ -343,7 +344,7 @@ describe('lifecycle reconciliation', () => { it('does not let a foreign replay overwrite an authorized completion', () => { db = new OrchestrationDb(':memory:') const task = db.createTask({ spec: 'work' }) - const dispatch = db.createDispatchContext(task.id, 'term_worker', `tab_w:${LEAF_A}`) + const dispatch = createRootDispatch(db, task.id, 'term_worker', `tab_w:${LEAF_A}`) const payload = JSON.stringify({ taskId: task.id, dispatchId: dispatch.id, @@ -378,7 +379,7 @@ describe('lifecycle reconciliation', () => { it('surfaces worker_done sent from a different pane as rejected', () => { db = new OrchestrationDb(':memory:') const task = db.createTask({ spec: 'work' }) - const dispatch = db.createDispatchContext(task.id, 'term_owner', `tab_w1:${LEAF_A}`) + const dispatch = createRootDispatch(db, task.id, 'term_owner', `tab_w1:${LEAF_A}`) const logs: string[] = [] const message = db.insertMessage({ from: 'term_other_worker', @@ -401,7 +402,7 @@ describe('lifecycle reconciliation', () => { it('surfaces a heartbeat sent from a different pane without recording liveness', () => { db = new OrchestrationDb(':memory:') const task = db.createTask({ spec: 'work' }) - const dispatch = db.createDispatchContext(task.id, 'term_owner', `tab_w1:${LEAF_A}`) + const dispatch = createRootDispatch(db, task.id, 'term_owner', `tab_w1:${LEAF_A}`) const heartbeat = db.insertMessage({ from: 'term_other_worker', to: 'term_coordinator', @@ -435,7 +436,7 @@ describe('lifecycle reconciliation', () => { it('surfaces a foreign heartbeat that claims the assignee handle', () => { db = new OrchestrationDb(':memory:') const task = db.createTask({ spec: 'work' }) - const dispatch = db.createDispatchContext(task.id, 'term_owner', `tab_w1:${LEAF_A}`) + const dispatch = createRootDispatch(db, task.id, 'term_owner', `tab_w1:${LEAF_A}`) const heartbeat = db.insertMessage({ from: 'term_owner', to: 'term_coordinator', @@ -455,7 +456,7 @@ describe('lifecycle reconciliation', () => { it('records a heartbeat whose pane key drifted only in the tab half', () => { db = new OrchestrationDb(':memory:') const task = db.createTask({ spec: 'work' }) - const dispatch = db.createDispatchContext(task.id, 'term_owner', `tab_new:${LEAF_A}`) + const dispatch = createRootDispatch(db, task.id, 'term_owner', `tab_new:${LEAF_A}`) const heartbeat = db.insertMessage({ from: 'term_owner', to: 'term_coordinator', @@ -475,9 +476,9 @@ describe('lifecycle reconciliation', () => { it('suppresses same-dispatch heartbeats once worker_done is reconciled', () => { db = new OrchestrationDb(':memory:') const task = db.createTask({ spec: 'work' }) - const dispatch = db.createDispatchContext(task.id, 'term_worker') + const dispatch = createRootDispatch(db, task.id, 'term_worker') const otherTask = db.createTask({ spec: 'other work' }) - const otherDispatch = db.createDispatchContext(otherTask.id, 'term_other') + const otherDispatch = createRootDispatch(db, otherTask.id, 'term_other') const insertHeartbeat = (dispatchId: string, from: string) => db.insertMessage({ from, diff --git a/src/main/runtime/orchestration/lightweight-run-worker-exit-escalation.test.ts b/src/main/runtime/orchestration/lightweight-run-worker-exit-escalation.test.ts index 43c0cc7a367..f34b6352e06 100644 --- a/src/main/runtime/orchestration/lightweight-run-worker-exit-escalation.test.ts +++ b/src/main/runtime/orchestration/lightweight-run-worker-exit-escalation.test.ts @@ -5,6 +5,7 @@ import { DISPATCH_CIRCUIT_BREAK_FAILURES } from './db/dispatch-context/dispatch- import { makePaneKey } from '../../../shared/stable-pane-id' import { getDefaultWorkspaceSession } from '../../../shared/constants' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { createRootDispatch } from './db/root-dispatch-test-fixture' // STA-4604: failActiveDispatchOnExit fails the dispatch on worker PTY exit but used to // gate the "Agent exited unexpectedly" escalation on the legacy coordinator_runs table. @@ -130,7 +131,7 @@ async function gradeWorkerExit( db.createCoordinatorRun({ spec: 'legacy coordinator loop', coordinatorHandle }) } const task = db.createTask({ spec: 'do the work', runId }) - const dispatch = db.createDispatchContext(task.id, workerHandle, WORKER_PANE_KEY) + const dispatch = createRootDispatch(db, task.id, workerHandle, WORKER_PANE_KEY) runtime.setOrchestrationDb(db as never) runtime.onPtyExit(WORKER_PTY_ID, 137) @@ -239,7 +240,7 @@ describe('STA-4604 worker PTY exit escalation reaches the coordinator', () => { coordinatorPaneKey: COORDINATOR_PANE_KEY }) const task = db.createTask({ spec: 'do the work', runId: run.id }) - db.createDispatchContext(task.id, workerHandle, WORKER_PANE_KEY) + createRootDispatch(db, task.id, workerHandle, WORKER_PANE_KEY) runtime.setOrchestrationDb(db as never) const waiting = runtime.waitForMessage(`run:${run.id}`, { @@ -281,7 +282,7 @@ describe('STA-4604 worker PTY exit escalation reaches the coordinator', () => { coordinatorPaneKey: makePaneKey('tab-other', '33333333-3333-4333-8333-333333333333') }) const task = db.createTask({ spec: 'owned work', runId: ownRun.id }) - db.createDispatchContext(task.id, workerHandle, WORKER_PANE_KEY) + createRootDispatch(db, task.id, workerHandle, WORKER_PANE_KEY) runtime.setOrchestrationDb(db as never) runtime.onPtyExit(WORKER_PTY_ID, 137) @@ -306,7 +307,12 @@ describe('STA-4604 worker PTY exit escalation reaches the coordinator', () => { coordinatorPaneKey: COORDINATOR_PANE_KEY }) const task = db.createTask({ spec: 'supervised work', runId: run.id }) - const started = db.createStartingWorkerDispatch({ taskId: task.id, startOptions: {} }) + const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) db.prepareStartingWorkerAuthority({ dispatchId: started.dispatch.id, handle: workerHandle, @@ -350,10 +356,10 @@ describe('STA-4604 worker PTY exit escalation reaches the coordinator', () => { const task = db.createTask({ spec: 'repeatedly failing work', runId: run.id }) // Burn the breaker down to its last life so this exit is the one that trips it. for (let attempt = 1; attempt < DISPATCH_CIRCUIT_BREAK_FAILURES; attempt += 1) { - const previous = db.createDispatchContext(task.id, workerHandle, WORKER_PANE_KEY) + const previous = createRootDispatch(db, task.id, workerHandle, WORKER_PANE_KEY) db.failDispatch(previous.id, `attempt ${attempt}`, { workerProcessExited: true }) } - const dispatch = db.createDispatchContext(task.id, workerHandle, WORKER_PANE_KEY) + const dispatch = createRootDispatch(db, task.id, workerHandle, WORKER_PANE_KEY) runtime.setOrchestrationDb(db as never) runtime.onPtyExit(WORKER_PTY_ID, 137) @@ -414,7 +420,7 @@ describe('STA-4604 worker PTY exit escalation reaches the coordinator', () => { coordinatorPaneKey: COORDINATOR_PANE_KEY }) const task = db.createTask({ spec: 'work outliving its coordinator', runId: run.id }) - db.createDispatchContext(task.id, workerHandle, WORKER_PANE_KEY) + createRootDispatch(db, task.id, workerHandle, WORKER_PANE_KEY) // Rebinding the pane to a newer Run clears the old Run's coordinator_handle. db.createRun({ objective: 'newer run on the same coordinator pane', diff --git a/src/main/runtime/orchestration/nested-worker-depth-migration.test.ts b/src/main/runtime/orchestration/nested-worker-depth-migration.test.ts new file mode 100644 index 00000000000..fa955b7cf08 --- /dev/null +++ b/src/main/runtime/orchestration/nested-worker-depth-migration.test.ts @@ -0,0 +1,114 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import Database from '../../sqlite/sync-database' +import { OrchestrationDb } from './db' +import { resolveOrchestrationMigrationStartVersion } from './orchestration-schema-version-skew' +import { SCHEMA_VERSION } from './db/contract-constants' + +/** + * Backfilling to 1 rather than 0 is the whole point: every pre-v30 row belongs to + * a worker that was already dispatched, so reading it as a root coordinator would + * hand every in-flight worker a free generation of sub-workers at upgrade. + */ +describe('nested worker depth migration (v30)', () => { + let db: OrchestrationDb | undefined + let tempDir: string | undefined + + afterEach(() => { + db?.close() + db = undefined + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }) + tempDir = undefined + } + }) + + function createV29Database(): string { + tempDir = mkdtempSync(join(tmpdir(), 'orca-nested-depth-migration-')) + const dbPath = join(tempDir, 'orchestration.db') + const fresh = new OrchestrationDb(dbPath) + fresh.close() + + const oldDb = new Database(dbPath) + oldDb.exec('ALTER TABLE dispatch_contexts DROP COLUMN depth') + oldDb.exec('ALTER TABLE remote_dispatch_attachments DROP COLUMN depth') + oldDb.pragma('user_version = 29') + oldDb + .prepare( + `INSERT INTO dispatch_contexts (id, run_id, task_id, contract_version, status) + VALUES ('ctx_inflight', 'run_legacy', 'task_legacy', 1, 'dispatched')` + ) + .run() + oldDb + .prepare( + `INSERT INTO remote_dispatch_attachments + (dispatch_id, task_id, home_peer_fingerprint, protocol_version, runtime_epoch, state) + VALUES ('ctx_remote', 'task_remote', 'peer', 1, 'epoch', 'ready')` + ) + .run() + oldDb.close() + return dbPath + } + + it('backfills in-flight rows to depth 1, not 0', () => { + const dbPath = createV29Database() + db = new OrchestrationDb(dbPath) + const sqlite = (db as unknown as { db: Database.Database }).db + + expect(sqlite.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) + expect( + sqlite.prepare("SELECT depth FROM dispatch_contexts WHERE id = 'ctx_inflight'").get() + ).toEqual({ depth: 1 }) + expect( + sqlite + .prepare("SELECT depth FROM remote_dispatch_attachments WHERE dispatch_id = 'ctx_remote'") + .get() + ).toEqual({ depth: 1 }) + }) + + it('leaves an upgraded in-flight worker unable to spawn at the default cap', () => { + const dbPath = createV29Database() + db = new OrchestrationDb(dbPath) + const sqlite = (db as unknown as { db: Database.Database }).db + sqlite + .prepare( + "UPDATE dispatch_contexts SET assignee_handle = 'term_upgraded' WHERE id = 'ctx_inflight'" + ) + .run() + + const task = db.createTask({ spec: 'post-upgrade nesting attempt' }) + expect(() => + db!.createDispatchContext({ + taskId: task.id, + assigneeHandle: 'term_sub', + creator: { kind: 'terminal', handle: 'term_upgraded' }, + maxDepth: 1 + }) + ).toThrow(/depth 2 \(max 1\)/) + }) + + it('does not mistake a real v29 database for a broken v30 one', () => { + // An unconditional column check here would report the schema incomplete and + // replay migrations from v6 instead of starting at 29. + const dbPath = createV29Database() + const oldDb = new Database(dbPath) + expect(resolveOrchestrationMigrationStartVersion(oldDb, 29, SCHEMA_VERSION)).toBe(29) + oldDb.close() + }) + + it('widens the attachment pane indexes to the potentially-live states', () => { + const dbPath = createV29Database() + db = new OrchestrationDb(dbPath) + const sqlite = (db as unknown as { db: Database.Database }).db + const sql = sqlite + .prepare( + "SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'idx_remote_dispatch_attachments_active_pane'" + ) + .get() as { sql: string } + for (const state of ['start_unknown', 'stopping', 'stop_unknown']) { + expect(sql.sql).toContain(state) + } + }) +}) diff --git a/src/main/runtime/orchestration/orchestration-adopted-run-binding.test.ts b/src/main/runtime/orchestration/orchestration-adopted-run-binding.test.ts index db6a63ef790..800a7511451 100644 --- a/src/main/runtime/orchestration/orchestration-adopted-run-binding.test.ts +++ b/src/main/runtime/orchestration/orchestration-adopted-run-binding.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest' import type Database from '../../sqlite/sync-database' import SyncDatabase from '../../sqlite/sync-database' import { OrchestrationDb } from './db' +import { createRootDispatch } from './db/root-dispatch-test-fixture' const LEGACY_COORDINATOR_HANDLE = 'term_legacy_coord' const LEGACY_COORDINATOR_PANE = 'tab_coord:44444444-4444-4444-8444-444444444444' @@ -49,7 +50,7 @@ function createAdoptedFixture(options: { settleWork: boolean }): AdoptedFixture spec: 'legacy assignment', createdByTerminalHandle: LEGACY_COORDINATOR_HANDLE }) - const dispatch = before.createDispatchContext(task.id, LEGACY_WORKER_HANDLE, LEGACY_WORKER_PANE) + const dispatch = createRootDispatch(before, task.id, LEGACY_WORKER_HANDLE, LEGACY_WORKER_PANE) const recovery = before.insertMessage({ from: LEGACY_WORKER_HANDLE, to: LEGACY_COORDINATOR_HANDLE, diff --git a/src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts b/src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts index af67cf49ba9..df7f3592355 100644 --- a/src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts +++ b/src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest' import type Database from '../../sqlite/sync-database' import { DISPATCH_CONTEXT_CLAIM_SQL, OrchestrationDb } from './db' +import { createRootDispatch } from './db/root-dispatch-test-fixture' const CREATOR_PANE = 'tab-creator:11111111-1111-4111-8111-111111111111' const CREATOR_PROCESS = 'pty-creator:incarnation-a' @@ -84,13 +85,7 @@ describe('creator authority lookup performance', () => { coordinatorPaneKey: 'tab-coordinator:22222222-2222-4222-8222-222222222222' }) const creatorTask = db.createTask({ spec: 'creator', runId: run.id }) - db.createDispatchContext( - creatorTask.id, - 'term-creator', - CREATOR_PANE, - undefined, - CREATOR_PROCESS - ) + createRootDispatch(db, creatorTask.id, 'term-creator', CREATOR_PANE, undefined, CREATOR_PROCESS) const workerTask = db.createTask({ spec: 'worker', runId: run.id, @@ -145,7 +140,8 @@ describe('creator authority lookup performance', () => { ) .run(retainedDispatchCount, run.id) const creatorTask = db.createTask({ spec: 'creator', runId: run.id }) - const creatorDispatch = db.createDispatchContext( + const creatorDispatch = createRootDispatch( + db, creatorTask.id, 'term-creator', CREATOR_PANE, @@ -173,9 +169,14 @@ describe('creator authority lookup performance', () => { const elapsedMs = performance.now() - startedAt const competingTask = db.createTask({ spec: 'competing creator', runId: run.id }) - expect(() => db!.createDispatchContext(competingTask.id, 'term-creator')).toThrow( - `Terminal term-creator already has an active dispatch (${creatorDispatch.id}` - ) + expect(() => + db!.createDispatchContext({ + taskId: competingTask.id, + assigneeHandle: 'term-creator', + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + ).toThrow(`Terminal term-creator already has an active dispatch (${creatorDispatch.id}`) expect(elapsedMs).toBeLessThan(200) } ) diff --git a/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts b/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts index d95524d17fa..38eeca64337 100644 --- a/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts +++ b/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest' import Database from '../../sqlite/sync-database' import { OrchestrationDb } from './db' import { SCHEMA_VERSION } from './db/contract-constants' +import { createRootDispatch } from './db/root-dispatch-test-fixture' const MUTATION_RECEIPT_MAX_ROWS = 10_000 @@ -155,6 +156,8 @@ describe('OrchestrationDb bounded mutation receipts', () => { expect(() => db!.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, taskId: task.id, startOptions: {}, mutationReceipt: { @@ -224,7 +227,7 @@ describe('OrchestrationDb dispatch assignee index migration', () => { const dbPath = join(tempDir, 'orchestration.db') db = new OrchestrationDb(dbPath) const task = db.createTask({ spec: 'indexed lookup' }) - const dispatch = db.createDispatchContext(task.id, 'term_worker') + const dispatch = createRootDispatch(db, task.id, 'term_worker') db.close() db = undefined @@ -299,7 +302,7 @@ describe('OrchestrationDb dispatch assignee index migration', () => { createdByProcessIncarnation: 'pty_creator:incarnation-a', createdByRunGeneration: run.consumer_generation }) - const dispatch = db.createDispatchContext(task.id, 'term_worker') + const dispatch = createRootDispatch(db, task.id, 'term_worker') db.close() db = undefined diff --git a/src/main/runtime/orchestration/orchestration-legacy-storage-db.test.ts b/src/main/runtime/orchestration/orchestration-legacy-storage-db.test.ts index 6efdf16f1ff..c23c966f849 100644 --- a/src/main/runtime/orchestration/orchestration-legacy-storage-db.test.ts +++ b/src/main/runtime/orchestration/orchestration-legacy-storage-db.test.ts @@ -157,7 +157,13 @@ describe('OrchestrationDb legacy contract storage', () => { createdByTerminalHandle: 'term_legacy_coord' }) - const dispatch = db!.createDispatchContext(task.id, 'term_legacy_coord', 'tab_mixed:leaf_mixed') + const dispatch = db!.createDispatchContext({ + taskId: task.id, + assigneeHandle: 'term_legacy_coord', + assigneePaneKey: 'tab_mixed:leaf_mixed', + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) expect(dispatch.contract_version).toBe(CURRENT_CONTRACT_VERSION) expect(db!.getRunMailboxOwnerIdsForHandle('term_legacy_coord')).toEqual([]) @@ -749,7 +755,12 @@ describe('OrchestrationDb legacy contract storage', () => { ).toThrow(/different answer/) const currentTask = db!.createTask({ runId: state.adoptedRunId, spec: 'current retry' }) - const currentDispatch = db!.createDispatchContext(currentTask.id, 'term_current_retry') + const currentDispatch = db!.createDispatchContext({ + taskId: currentTask.id, + assigneeHandle: 'term_current_retry', + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) const currentQuestion = db!.createQuestion({ runId: state.adoptedRunId, dispatchId: currentDispatch.id, diff --git a/src/main/runtime/orchestration/orchestration-legacy-storage-test-fixture.ts b/src/main/runtime/orchestration/orchestration-legacy-storage-test-fixture.ts index d28498e9c2f..9ae0df316b3 100644 --- a/src/main/runtime/orchestration/orchestration-legacy-storage-test-fixture.ts +++ b/src/main/runtime/orchestration/orchestration-legacy-storage-test-fixture.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import Database from '../../sqlite/sync-database' import { LEGACY_RUN_ID, OrchestrationDb } from './db' +import { createRootDispatch } from './db/root-dispatch-test-fixture' export type LegacyStorageCutoverFixture = { dbPath: string @@ -38,7 +39,8 @@ export function createLegacyStorageCutoverFixture(): { coordinatorHandle: 'term_unrelated_coord', coordinatorPaneKey: 'tab_unrelated:55555555-5555-4555-8555-555555555555' }) - const currentDispatch = first.createDispatchContext( + const currentDispatch = createRootDispatch( + first, currentTask.id, 'term_current_worker', 'tab_current:22222222-2222-4222-9222-222222222222', @@ -55,7 +57,8 @@ export function createLegacyStorageCutoverFixture(): { spec: 'legacy', createdByTerminalHandle: 'term_legacy_coord' }) - first.createDispatchContext( + createRootDispatch( + first, legacyTask.id, 'term_legacy_worker', 'tab_legacy:33333333-3333-4333-8333-333333333333' @@ -65,7 +68,8 @@ export function createLegacyStorageCutoverFixture(): { question: 'Retained gate?' }) first.resolveGate(legacyGate.id, 'continue') - const retryDispatch = first.createDispatchContext( + const retryDispatch = createRootDispatch( + first, legacyTask.id, 'term_legacy_worker', 'tab_legacy:33333333-3333-4333-8333-333333333333' diff --git a/src/main/runtime/orchestration/orchestration-mutation-question-db.test.ts b/src/main/runtime/orchestration/orchestration-mutation-question-db.test.ts index a6039c381f9..5a26448bd98 100644 --- a/src/main/runtime/orchestration/orchestration-mutation-question-db.test.ts +++ b/src/main/runtime/orchestration/orchestration-mutation-question-db.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' import { OrchestrationDb } from './db' +import { createRootDispatch } from './db/root-dispatch-test-fixture' describe('OrchestrationDb mutation and question state', () => { let db: OrchestrationDb | undefined @@ -93,7 +94,7 @@ describe('OrchestrationDb mutation and question state', () => { coordinatorPaneKey: 'tab_coord:11111111-1111-4111-8111-111111111111' }) const task = d.createTask({ spec: 'ask', runId: run.id }) - const dispatch = d.createDispatchContext(task.id, 'term_worker') + const dispatch = createRootDispatch(d, task.id, 'term_worker') const created = d.createQuestion({ runId: run.id, dispatchId: dispatch.id, @@ -144,7 +145,7 @@ describe('OrchestrationDb mutation and question state', () => { coordinatorPaneKey: 'tab_coord:11111111-1111-4111-8111-111111111111' }) const task = d.createTask({ spec: 'ask', runId: run.id }) - const dispatch = d.createDispatchContext(task.id, 'term_worker') + const dispatch = createRootDispatch(d, task.id, 'term_worker') const created = d.createQuestion({ runId: run.id, dispatchId: dispatch.id, diff --git a/src/main/runtime/orchestration/orchestration-reset-db.test.ts b/src/main/runtime/orchestration/orchestration-reset-db.test.ts index 0ec8d3bf193..4b3e033a019 100644 --- a/src/main/runtime/orchestration/orchestration-reset-db.test.ts +++ b/src/main/runtime/orchestration/orchestration-reset-db.test.ts @@ -15,6 +15,8 @@ describe('OrchestrationDb reset scopes', () => { }) const task = db.createTask({ spec: 'work', runId: run.id }) const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, taskId: task.id, startOptions: { worktree: 'current' }, runtimeEpoch: 'runtime_1', diff --git a/src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts b/src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts index 1d7a3ce45d5..fdaa5e92a80 100644 --- a/src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts +++ b/src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { LEGACY_RUN_ID, OrchestrationDb } from './db' +import { createRootDispatch } from './db/root-dispatch-test-fixture' describe('OrchestrationDb Run state', () => { let db: OrchestrationDb | undefined @@ -172,7 +173,7 @@ describe('OrchestrationDb Run state', () => { coordinatorPaneKey: 'tab_other:22222222-2222-4222-9222-222222222222' }) const task = d.createTask({ spec: 'work', runId: runB.id }) - const dispatch = d.createDispatchContext(task.id, 'term_worker') + const dispatch = createRootDispatch(d, task.id, 'term_worker') const mismatched = d.insertMessage({ from: 'worker', to: `dispatch:${dispatch.id}`, @@ -285,7 +286,7 @@ describe('OrchestrationDb Run state', () => { coordinatorPaneKey: 'tab_coord:11111111-1111-4111-8111-111111111111' }) const task = d.createTask({ spec: 'work', runId: run.id }) - const dispatch = d.createDispatchContext(task.id, 'term_worker') + const dispatch = createRootDispatch(d, task.id, 'term_worker') const message = d.insertMessage({ runId: run.id, from: 'term_worker', diff --git a/src/main/runtime/orchestration/orchestration-schema-version-skew.ts b/src/main/runtime/orchestration/orchestration-schema-version-skew.ts index 8fb3db36777..95ecbe6b034 100644 --- a/src/main/runtime/orchestration/orchestration-schema-version-skew.ts +++ b/src/main/runtime/orchestration/orchestration-schema-version-skew.ts @@ -26,7 +26,9 @@ const POST_V6_COLUMNS = [ ] as const const VERSIONED_POST_V6_COLUMNS = [ - { version: 27, table: 'federated_dispatches', column: 'to_home_acknowledged_sequence' } + { version: 27, table: 'federated_dispatches', column: 'to_home_acknowledged_sequence' }, + { version: 30, table: 'dispatch_contexts', column: 'depth' }, + { version: 30, table: 'remote_dispatch_attachments', column: 'depth' } ] as const const POST_V6_INDEXES = [ diff --git a/src/main/runtime/orchestration/orchestration-version-skew-migration.test.ts b/src/main/runtime/orchestration/orchestration-version-skew-migration.test.ts index 763301bde22..6cb58f00ba4 100644 --- a/src/main/runtime/orchestration/orchestration-version-skew-migration.test.ts +++ b/src/main/runtime/orchestration/orchestration-version-skew-migration.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest' import Database from '../../sqlite/sync-database' import { LEGACY_CONTRACT_VERSION, LEGACY_RUN_ID, OrchestrationDb } from './db' import { resolveOrchestrationMigrationStartVersion } from './orchestration-schema-version-skew' +import { createRootDispatch } from './db/root-dispatch-test-fixture' describe('OrchestrationDb version-skew migration', () => { let db: OrchestrationDb | undefined @@ -158,7 +159,7 @@ describe('OrchestrationDb version-skew migration', () => { coordinatorPaneKey: 'tab_v2:leaf_coord' }) const task = db.createTask({ spec: 'reply with ack', runId: run.id }) - const dispatch = db.createDispatchContext(task.id, 'term_worker_v2', 'tab_v2:leaf_worker') + const dispatch = createRootDispatch(db, task.id, 'term_worker_v2', 'tab_v2:leaf_worker') const question = db.createQuestion({ runId: run.id, dispatchId: dispatch.id, diff --git a/src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts b/src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts index 5e147f51176..9e4a2768a1d 100644 --- a/src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts +++ b/src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts @@ -17,6 +17,8 @@ describe('OrchestrationDb worker Dispatch state', () => { const d = createDb() const task = d.createTask({ spec: 'worker' }) const started = d.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, taskId: task.id, startOptions: { topology: 'current', agent: 'codex' } }) @@ -57,7 +59,12 @@ describe('OrchestrationDb worker Dispatch state', () => { it('retains an active supervised worker terminal', () => { const d = createDb() const task = d.createTask({ spec: 'retain active worker' }) - const started = d.createStartingWorkerDispatch({ taskId: task.id, startOptions: {} }) + const started = d.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) d.prepareStartingWorkerAuthority({ dispatchId: started.dispatch.id, handle: 'term_worker', @@ -81,6 +88,8 @@ describe('OrchestrationDb worker Dispatch state', () => { const d = createDb() const task = d.createTask({ spec: 'recover missing worker' }) const started = d.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, taskId: task.id, startOptions: { topology: 'current', agent: 'codex' } }) @@ -125,6 +134,8 @@ describe('OrchestrationDb worker Dispatch state', () => { } const started = d.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, taskId: task.id, startOptions: { topology: 'current' }, mutationReceipt @@ -146,6 +157,8 @@ describe('OrchestrationDb worker Dispatch state', () => { expect(() => d.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, taskId: 'task_missing', startOptions: {}, mutationReceipt: { @@ -162,7 +175,12 @@ describe('OrchestrationDb worker Dispatch state', () => { it('fails a composed start without losing residual resource receipts', () => { const d = createDb() const task = d.createTask({ spec: 'worker' }) - const started = d.createStartingWorkerDispatch({ taskId: task.id, startOptions: {} }) + const started = d.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) d.recordWorkerStage({ dispatchId: started.dispatch.id, stage: 'terminal_created', @@ -182,9 +200,16 @@ describe('OrchestrationDb worker Dispatch state', () => { it('allows retry only from the Task current terminal Dispatch', () => { const d = createDb() const task = d.createTask({ spec: 'retry current' }) - const first = d.createStartingWorkerDispatch({ taskId: task.id, startOptions: {} }) + const first = d.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) d.failWorkerStart(first.dispatch.id, 'agent_readiness', 'first failed') const second = d.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, taskId: task.id, retryOf: first.dispatch.id, startOptions: {} @@ -193,6 +218,8 @@ describe('OrchestrationDb worker Dispatch state', () => { expect(() => d.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, taskId: task.id, retryOf: first.dispatch.id, startOptions: {} @@ -200,6 +227,8 @@ describe('OrchestrationDb worker Dispatch state', () => { ).toThrow('cannot retry') expect( d.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, taskId: task.id, retryOf: second.dispatch.id, startOptions: {} @@ -210,9 +239,16 @@ describe('OrchestrationDb worker Dispatch state', () => { it('treats abandon of a superseded Dispatch as a no-op', () => { const d = createDb() const task = d.createTask({ spec: 'stale abandon' }) - const first = d.createStartingWorkerDispatch({ taskId: task.id, startOptions: {} }) + const first = d.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) d.failWorkerStart(first.dispatch.id, 'agent_readiness', 'first failed') const second = d.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, taskId: task.id, retryOf: first.dispatch.id, startOptions: {} @@ -248,7 +284,12 @@ describe('OrchestrationDb worker Dispatch state', () => { it('lets the stop fence win before a late worker completion', () => { const d = createDb() const task = d.createTask({ spec: 'race' }) - const started = d.createStartingWorkerDispatch({ taskId: task.id, startOptions: {} }) + const started = d.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) d.prepareStartingWorkerAuthority({ dispatchId: started.dispatch.id, handle: 'term_worker', @@ -276,7 +317,12 @@ describe('OrchestrationDb worker Dispatch state', () => { it('allows explicit stop recovery from uncertain local and remote starts', () => { const d = createDb() const task = d.createTask({ spec: 'uncertain local start' }) - const started = d.createStartingWorkerDispatch({ taskId: task.id, startOptions: {} }) + const started = d.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) d.markWorkerStartUnknown(started.dispatch.id, 'agent_readiness', 'connection lost') expect(d.beginWorkerStop(started.dispatch.id, 'runtime_test')).toMatchObject({ @@ -359,7 +405,12 @@ describe('OrchestrationDb worker Dispatch state', () => { it('returns already-settled when completion wins before stop', () => { const d = createDb() const task = d.createTask({ spec: 'race' }) - const started = d.createStartingWorkerDispatch({ taskId: task.id, startOptions: {} }) + const started = d.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) d.prepareStartingWorkerAuthority({ dispatchId: started.dispatch.id, handle: 'term_worker', diff --git a/src/main/runtime/orchestration/types.ts b/src/main/runtime/orchestration/types.ts index 7b73f227a0a..b34f69e3c22 100644 --- a/src/main/runtime/orchestration/types.ts +++ b/src/main/runtime/orchestration/types.ts @@ -205,6 +205,8 @@ export type RemoteDispatchAttachmentRow = { effects: string residual_resources: string to_worker_imported_sequence: number + /** Nesting depth propagated from the Run home; 1 when an old client omitted it. */ + depth: number last_error: string | null created_at: string updated_at: string @@ -278,6 +280,8 @@ export type DispatchContextRow = { /** Why the dispatch ended, when Orca could establish it — `operator_close`, * `signaled`, `exited`, `unknown`. Null on rows written before STA-4603. */ termination_reason: TerminalExitCause['kind'] | null + /** Nesting depth; a root coordinator's worker is 1. Never 0 on a persisted row. */ + depth: number dispatched_at: string | null completed_at: string | null created_at: string diff --git a/src/main/runtime/rpc/methods/orchestration-ask.test.ts b/src/main/runtime/rpc/methods/orchestration-ask.test.ts index deaaf12d438..18c1e9f415d 100644 --- a/src/main/runtime/rpc/methods/orchestration-ask.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-ask.test.ts @@ -4,6 +4,7 @@ import { createOrchestrationRpcHarness } from './orchestration-rpc-test-harness' import type { OrchestrationDb } from '../../orchestration/db' import type { OrcaRuntimeService } from '../../orca-runtime' import { ORCHESTRATION_ASK_MAX_TIMEOUT_MS } from '../../../../shared/orchestration-ask-timeout' +import { createRootDispatch } from '../../orchestration/db/root-dispatch-test-fixture' describe('orchestration RPC methods', () => { const h = createOrchestrationRpcHarness() @@ -59,7 +60,7 @@ describe('orchestration RPC methods', () => { it('records one idempotent answer from the current Run consumer', async () => { setup() const task = db.createTask({ spec: 'question work' }) - const dispatch = db.createDispatchContext(task.id, 'term_worker') + const dispatch = createRootDispatch(db, task.id, 'term_worker') const created = db.createQuestion({ runId: activeRunId!, dispatchId: dispatch.id, @@ -103,7 +104,7 @@ describe('orchestration RPC methods', () => { describe('orchestration.ask', () => { function createAskingDispatch(handle = 'term_worker') { const task = db.createTask({ spec: 'question work' }) - const dispatch = db.createDispatchContext(task.id, handle) + const dispatch = createRootDispatch(db, task.id, handle) return { task, dispatch } } diff --git a/src/main/runtime/rpc/methods/orchestration-check.test.ts b/src/main/runtime/rpc/methods/orchestration-check.test.ts index b933bbb0831..331e7448734 100644 --- a/src/main/runtime/rpc/methods/orchestration-check.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-check.test.ts @@ -4,6 +4,7 @@ import { createOrchestrationRpcHarness } from './orchestration-rpc-test-harness' import type { OrchestrationDb } from '../../orchestration/db' import { reconcileLifecycleMessage } from '../../orchestration/lifecycle-reconciliation' import type { OrcaRuntimeService } from '../../orca-runtime' +import { createRootDispatch } from '../../orchestration/db/root-dispatch-test-fixture' describe('orchestration RPC methods', () => { const h = createOrchestrationRpcHarness() @@ -28,7 +29,7 @@ describe('orchestration RPC methods', () => { describe('orchestration.check', () => { function createDispatchedTask(assigneeHandle = 'term_worker', assigneePaneKey?: string) { const task = db.createTask({ spec: 'manual check work' }) - const dispatch = db.createDispatchContext(task.id, assigneeHandle, assigneePaneKey) + const dispatch = createRootDispatch(db, task.id, assigneeHandle, assigneePaneKey) return { task, dispatch } } @@ -432,9 +433,9 @@ describe('orchestration RPC methods', () => { it('does not complete worker_done for a stale inactive dispatch', async () => { setup() const task = db.createTask({ spec: 'retry-sensitive work' }) - const staleDispatch = db.createDispatchContext(task.id, 'term_old') + const staleDispatch = createRootDispatch(db, task.id, 'term_old') db.failDispatch(staleDispatch.id, 'retry elsewhere') - const activeDispatch = db.createDispatchContext(task.id, 'term_current') + const activeDispatch = createRootDispatch(db, task.id, 'term_current') insertWorkerDone({ from: 'term_old', taskId: task.id, diff --git a/src/main/runtime/rpc/methods/orchestration-composed-workers.test.ts b/src/main/runtime/rpc/methods/orchestration-composed-workers.test.ts index 7080f927995..10dbfa97a53 100644 --- a/src/main/runtime/rpc/methods/orchestration-composed-workers.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-composed-workers.test.ts @@ -66,6 +66,65 @@ describe('orchestration RPC methods', () => { }) } + it('rejects a declared caller that disagrees with complete attested evidence', async () => { + setup() + mockCurrentWorkerStart() + vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => + handle === 'term_coord' || handle === 'term_other' + ? coordinatorPaneKey + : handle === 'term_worker' + ? 'tab_worker:leaf_worker' + : null + ) + const attestedEvidence = { + terminalHandle: 'term_attested', + paneKey: 'tab_attested:leaf_attested', + launchToken: 'attested-launch-token' + } as const + vi.spyOn(runtime, 'verifyOrchestrationCompatibilityCaller').mockReturnValue({ + terminalHandle: attestedEvidence.terminalHandle, + paneKey: attestedEvidence.paneKey, + processIncarnation: 'runtime_test:attested:1', + launchTokenHash: 'attested-launch-token-hash', + hostScope: { kind: 'local', hostId: 'local' } + }) + ctx = { ...ctx, orchestrationCompatibilityEvidence: attestedEvidence } + const task = db.createTask({ spec: 'mismatched caller' }) + + await expect( + call('orchestration.workerStart', { + task: task.id, + from: 'term_other', + agent: 'codex' + }) + ).rejects.toMatchObject({ code: 'consumer_fenced' }) + expect(db.getDispatchContext(task.id)).toBeUndefined() + }) + + it('deliberately permits present but unverifiable restored-terminal evidence', async () => { + setup() + mockCurrentWorkerStart() + // Restored/adopted terminals have no launch token, so verification returns null; this + // fail-open is deliberate compatibility behavior, not an oversight. + const task = db.createTask({ spec: 'restored caller limitation' }) + ctx = { + ...ctx, + orchestrationCompatibilityEvidence: { + terminalHandle: 'term_worker', + paneKey: 'tab_worker:leaf_worker' + } + } + + const result = (await call('orchestration.workerStart', { + task: task.id, + from: 'term_coord', + agent: 'codex' + })) as { state: string } + + expect(result.state).toBe('ready') + expect(db.getDispatchContext(task.id)).toBeDefined() + }) + it('starts a fresh agent in the coordinator current worktree', async () => { setup() mockCurrentWorkerStart() diff --git a/src/main/runtime/rpc/methods/orchestration-dispatch-creator.ts b/src/main/runtime/rpc/methods/orchestration-dispatch-creator.ts new file mode 100644 index 00000000000..4da46b6eeb0 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-dispatch-creator.ts @@ -0,0 +1,27 @@ +import type { DispatchCreator } from '../../orchestration/db/dispatch-depth' +import type { OrcaRuntimeService } from '../../orca-runtime' + +/** + * Identify a CLI caller for nesting-depth purposes. + * + * Pane key and process incarnation come from the runtime's dispatch authority + * rather than the caller's params: remote attachment matching needs the exact + * incarnation, and a caller cannot be trusted to report its own. + */ +export function resolveDispatchCreator( + runtime: OrcaRuntimeService, + callerHandle: string | undefined +): DispatchCreator { + if (!callerHandle) { + // No declared caller means no resolvable parent. Depth 0 is the same answer + // the pre-existing Run-binding check already gives this case. + return { kind: 'system' } + } + const authority = runtime.getOrchestrationDispatchAuthority?.(callerHandle) + return { + kind: 'terminal', + handle: callerHandle, + paneKey: authority?.paneKey ?? runtime.getTerminalPaneKey(callerHandle) ?? undefined, + processIncarnation: authority?.processIncarnation ?? undefined + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts b/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts index 561c35603f2..28c6530d2a0 100644 --- a/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts @@ -21,6 +21,7 @@ import { type OrchestrationWorkerLaunchReceipt } from './orchestration-worker-launch-preferences' import { validateFederatedWorkerStartPlacement } from './orchestration-worker-start-validation' +import { resolveDispatchCreator } from './orchestration-dispatch-creator' export async function startFederatedWorker(args: { params: WorkerStartInput @@ -97,6 +98,8 @@ export async function startFederatedWorker(args: { const setupDecision = createsWorktree ? (params.setup ?? 'run') : 'not_applicable' const started = db.createStartingWorkerDispatch({ + creator: resolveDispatchCreator(runtime, params.from), + maxDepth: runtime.getNestedWorkerMaxDepth(), taskId: task.id, retryOf: params.retryOf, startOptions: { @@ -135,6 +138,9 @@ export async function startFederatedWorker(args: { dispatchId: started.dispatch.id, taskId: task.id, taskSpec: task.spec, + // Carry the home dispatch depth across the federation boundary so a + // remote worker cannot be mistaken for a root when it dispatches again. + depth: started.dispatch.depth, protocolVersion: federationProtocolVersion, worktree, name: params.name, diff --git a/src/main/runtime/rpc/methods/orchestration-federation-agent-launch.test.ts b/src/main/runtime/rpc/methods/orchestration-federation-agent-launch.test.ts index 5009f6ba027..4948e196c66 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation-agent-launch.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation-agent-launch.test.ts @@ -59,6 +59,7 @@ describe('federated worker agent launch', () => { dispatchId: 'ctx_remote', taskId: 'task_remote', taskSpec: 'remote cursor worker', + depth: 2, protocolVersion: 3, worktree: 'folder:remote-workspace', agent: 'cursor', @@ -90,6 +91,7 @@ describe('federated worker agent launch', () => { effective: { agent: 'cursor', model: 'gpt-5.3-codex', effort: 'high' } } }) + expect(db.getRemoteDispatchAttachment('ctx_remote')?.depth).toBe(2) expect(createTerminal).toHaveBeenCalledWith( 'id:folder:remote-workspace', expect.objectContaining({ diff --git a/src/main/runtime/rpc/methods/orchestration-federation-control-mail.test.ts b/src/main/runtime/rpc/methods/orchestration-federation-control-mail.test.ts index 43bc0bc96ff..401318d82c0 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation-control-mail.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation-control-mail.test.ts @@ -91,6 +91,8 @@ describe('orchestration federation control mail', () => { runId = run.id const task = homeDb.createTask({ spec: 'Wait for coordinator guidance', runId }) const started = homeDb.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, taskId: task.id, startOptions: {}, federation: { diff --git a/src/main/runtime/rpc/methods/orchestration-federation-setup.test.ts b/src/main/runtime/rpc/methods/orchestration-federation-setup.test.ts index 6eec52cd93f..c1d91c18479 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation-setup.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation-setup.test.ts @@ -118,6 +118,8 @@ describe('orchestration federated setup evidence', () => { }) const task = db.createTask({ spec: 'remote setup', runId: run.id }) const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, taskId: task.id, startOptions: {}, runtimeEpoch: runtime.getRuntimeId(), diff --git a/src/main/runtime/rpc/methods/orchestration-federation-start-schema.ts b/src/main/runtime/rpc/methods/orchestration-federation-start-schema.ts index 9c03345ef88..61bf385b782 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation-start-schema.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation-start-schema.ts @@ -6,6 +6,8 @@ export const FederationAttachStartParams = z.object({ dispatchId: requiredString('Missing Dispatch ID'), taskId: requiredString('Missing Task ID'), taskSpec: requiredString('Missing Task spec'), + /** Depth stamped by the Run home; omitted by older clients and defaults to 1. */ + depth: z.number().int().min(1).optional(), protocolVersion: z.union([z.literal(1), z.literal(2), z.literal(3)]), worktree: requiredString('Missing remote worktree selector'), name: OptionalString, diff --git a/src/main/runtime/rpc/methods/orchestration-federation.ts b/src/main/runtime/rpc/methods/orchestration-federation.ts index 83cbbfe385f..da4e29d4fc0 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation.ts @@ -57,6 +57,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ homePeerFingerprint: orchestrationMutation.callerFingerprint, protocolVersion: params.protocolVersion, runtimeEpoch: runtime.getRuntimeId(), + depth: params.depth, mutationReceipt: orchestrationMutation }) const effects: FederationEffect[] = [] diff --git a/src/main/runtime/rpc/methods/orchestration-manual-dispatch-observation.test.ts b/src/main/runtime/rpc/methods/orchestration-manual-dispatch-observation.test.ts index 47fbc8067cc..c99fcb1c328 100644 --- a/src/main/runtime/rpc/methods/orchestration-manual-dispatch-observation.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-manual-dispatch-observation.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from '../../orca-runtime' import { OrchestrationDb } from '../../orchestration/db' import { ORCHESTRATION_METHODS } from './orchestration' +import { createRootDispatch } from '../../orchestration/db/root-dispatch-test-fixture' describe('manual Dispatch observation', () => { let db: OrchestrationDb | undefined @@ -111,7 +112,8 @@ describe('manual Dispatch observation', () => { coordinatorPaneKey: 'tab_coord:leaf_coord' }) const task = db.createTask({ spec: 'injected lane', runId: run.id }) - const dispatch = db.createDispatchContext( + const dispatch = createRootDispatch( + db, task.id, 'term_worker', 'tab_worker:leaf_worker', @@ -207,6 +209,39 @@ describe('manual Dispatch observation', () => { }) }) + it('lists an unsupervised context-only dispatch even when process identity is absent', async () => { + db = new OrchestrationDb(':memory:') + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + const run = db.createRun({ + objective: 'context-only listing', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:leaf_coord' + }) + const task = db.createTask({ spec: 'operator lane', runId: run.id }) + const dispatch = createRootDispatch(db, task.id, 'term_worker', 'tab_worker:leaf_worker') + + const workerListMethod = ORCHESTRATION_METHODS.find( + (candidate) => candidate.name === 'orchestration.workerList' + ) + if (!workerListMethod) { + throw new Error('Missing method orchestration.workerList') + } + const result = (await workerListMethod.handler( + workerListMethod.params?.parse({ run: run.id }), + { runtime } + )) as { workers: { dispatchId: string; workerState: string; terminalState: string | null }[] } + + expect(result.workers).toEqual([ + expect.objectContaining({ + dispatchId: dispatch.id, + workerState: 'unsupervised', + terminalState: 'retained' + }) + ]) + expect(db.getDispatchContextById(dispatch.id)?.process_incarnation).toBeNull() + }) + it.each([ ['orchestration.workerStop', 'stopped'], ['orchestration.workerAbandon', 'abandoned'] @@ -216,7 +251,8 @@ describe('manual Dispatch observation', () => { runtime.setOrchestrationDb(db) const closeTerminal = vi.spyOn(runtime, 'closeTerminal') const task = db.createTask({ spec: 'operator-owned lane' }) - const dispatch = db.createDispatchContext( + const dispatch = createRootDispatch( + db, task.id, 'term_worker', 'tab_worker:leaf_worker', diff --git a/src/main/runtime/rpc/methods/orchestration-manual-dispatch-release.test.ts b/src/main/runtime/rpc/methods/orchestration-manual-dispatch-release.test.ts index 6f98b3b8cf2..d684adbfacc 100644 --- a/src/main/runtime/rpc/methods/orchestration-manual-dispatch-release.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-manual-dispatch-release.test.ts @@ -158,6 +158,8 @@ describe('manual Dispatch release', () => { function createSupervisedWorker(): string { const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, taskId: createTask('supervised'), startOptions: {} }) diff --git a/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts b/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts index a8528803bf1..1d562e1ee5a 100644 --- a/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts @@ -8,6 +8,7 @@ import { OrchestrationDb } from '../../orchestration/db' import { RpcDispatcher } from '../dispatcher' import { ORCHESTRATION_METHODS } from './orchestration' import { startFederatedWorker } from './orchestration-federated-worker-start' +import { createRootDispatch } from '../../orchestration/db/root-dispatch-test-fixture' describe('orchestration migration behavior', () => { const databases: OrchestrationDb[] = [] @@ -124,7 +125,7 @@ describe('orchestration migration behavior', () => { coordinatorPaneKey: 'tab_coord:leaf_coord' }) const task = db.createTask({ spec: 'legacy worker', runId: run.id }) - const dispatch = db.createDispatchContext(task.id, 'term_worker', 'tab_worker:leaf_worker') + const dispatch = createRootDispatch(db, task.id, 'term_worker', 'tab_worker:leaf_worker') const dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) const response = await dispatcher.dispatch({ diff --git a/src/main/runtime/rpc/methods/orchestration-recipient-routing.test.ts b/src/main/runtime/rpc/methods/orchestration-recipient-routing.test.ts index 99a4c238807..41d10266dee 100644 --- a/src/main/runtime/rpc/methods/orchestration-recipient-routing.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-recipient-routing.test.ts @@ -7,6 +7,7 @@ import type { RpcContext, RpcRequest } from '../core' import { RpcDispatcher } from '../dispatcher' import { ORCHESTRATION_METHODS } from './orchestration' import { createOrchestrationRpcHarness } from './orchestration-rpc-test-harness' +import { createRootDispatch } from '../../orchestration/db/root-dispatch-test-fixture' type SendWarning = { code: string; recipient: string; message: string } type SendResult = { @@ -170,7 +171,7 @@ describe('orchestration recipient routing oracle', () => { it('normalizes an active Dispatch owner even when no pane is live', async () => { setup() const task = db.createTask({ spec: 'detached worker' }) - const dispatch = db.createDispatchContext(task.id, 'term_detached', 'tab_gone:leaf_gone') + const dispatch = createRootDispatch(db, task.id, 'term_detached', 'tab_gone:leaf_gone') const result = (await call({ from: 'term_coord', @@ -194,7 +195,7 @@ describe('orchestration recipient routing oracle', () => { coordinatorPaneKey: 'tab_foreign:leaf_coord' }) const task = db.createTask({ spec: 'detached foreign worker', runId: foreignRun.id }) - db.createDispatchContext(task.id, 'term_detached_foreign', 'tab_gone:leaf_gone') + createRootDispatch(db, task.id, 'term_detached_foreign', 'tab_gone:leaf_gone') await expect( call({ @@ -211,7 +212,7 @@ describe('orchestration recipient routing oracle', () => { setup() const overlapPane = 'tab_overlap:leaf_overlap' const task = db.createTask({ spec: 'overlapped worker' }) - db.createDispatchContext(task.id, 'term_overlap', overlapPane) + createRootDispatch(db, task.id, 'term_overlap', overlapPane) const recipientRun = db.createRun({ objective: 'Overlapping coordinator', coordinatorHandle: 'term_overlap', diff --git a/src/main/runtime/rpc/methods/orchestration-run-scope.ts b/src/main/runtime/rpc/methods/orchestration-run-scope.ts index 113c135c153..cdf6968bcbc 100644 --- a/src/main/runtime/rpc/methods/orchestration-run-scope.ts +++ b/src/main/runtime/rpc/methods/orchestration-run-scope.ts @@ -2,7 +2,10 @@ import type { OrchestrationCompatibilityEvidence } from '../../../../shared/orch import { orchestrationSkillRecoveryData } from '../../../../shared/orchestration-rpc-contract' import { OrchestrationError } from '../../orchestration/orchestration-error' import type { RunRow } from '../../orchestration/types' -import type { OrcaRuntimeService } from '../../orca-runtime' +import type { + OrcaRuntimeService, + OrchestrationCompatibilityCallerAuthority +} from '../../orca-runtime' export type RunScopeParams = { runId?: string @@ -33,6 +36,49 @@ export function assertCallerHandleMatchesEvidence( } } +export type OrchestrationCallerParams = { + callerTerminalHandle: string + callerEvidence?: OrchestrationCompatibilityEvidence + callerAuthority?: OrchestrationCompatibilityCallerAuthority + /** Preserve legacy callers that treated a missing pane as an ordinary fence. */ + requireStablePane?: boolean + /** + * Skip attestation here because the caller performs it itself — run-use must run + * its legacy-takeover check between pane resolution and attestation. Setting this + * without asserting elsewhere reopens the hole this helper exists to close. + */ + evidenceAssertedByCaller?: boolean +} + +/** Resolve the caller's runtime pane and, by default, attest its declared handle. */ +export function resolveOrchestrationCaller( + runtime: OrcaRuntimeService, + params: OrchestrationCallerParams & { requireStablePane: true } +): string +export function resolveOrchestrationCaller( + runtime: OrcaRuntimeService, + params: OrchestrationCallerParams +): string | null +export function resolveOrchestrationCaller( + runtime: OrcaRuntimeService, + params: OrchestrationCallerParams +): string | null { + if (!params.evidenceAssertedByCaller) { + assertCallerHandleMatchesEvidence(runtime, params.callerTerminalHandle, params.callerEvidence) + } + const paneKey = + params.callerAuthority?.terminalHandle === params.callerTerminalHandle + ? params.callerAuthority.paneKey + : runtime.getTerminalPaneKey(params.callerTerminalHandle) + if (!paneKey && params.requireStablePane) { + throw new OrchestrationError( + 'stable_pane_required', + 'The coordinator terminal has no stable pane identity. Run this command inside a live Orca terminal.' + ) + } + return paneKey ?? null +} + // Why: task and gate mutations must share one Run-binding rule. export function resolveRunScope(runtime: OrcaRuntimeService, params: RunScopeParams): RunRow { const db = runtime.getOrchestrationDb() diff --git a/src/main/runtime/rpc/methods/orchestration-runs.ts b/src/main/runtime/rpc/methods/orchestration-runs.ts index aa902171303..7938bc240f1 100644 --- a/src/main/runtime/rpc/methods/orchestration-runs.ts +++ b/src/main/runtime/rpc/methods/orchestration-runs.ts @@ -2,12 +2,11 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { OptionalBoolean, OptionalString, requiredString } from '../schemas' import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../../../shared/orchestration-run-pagination' -import type { - OrcaRuntimeService, - OrchestrationCompatibilityCallerAuthority -} from '../../orca-runtime' import { OrchestrationError } from '../../orchestration/orchestration-error' -import { assertCallerHandleMatchesEvidence } from './orchestration-run-scope' +import { + assertCallerHandleMatchesEvidence, + resolveOrchestrationCaller +} from './orchestration-run-scope' const RunCreateParams = z.object({ objective: requiredString('Missing --objective'), @@ -27,31 +26,16 @@ const RunListParams = z.object({ }) const RunShowParams = z.object({ id: requiredString('Missing --id'), from: OptionalString }) -function requireCallerPane( - runtime: OrcaRuntimeService, - handle: string, - callerAuthority?: OrchestrationCompatibilityCallerAuthority -): string { - const paneKey = - callerAuthority?.terminalHandle === handle - ? callerAuthority.paneKey - : runtime.getTerminalPaneKey(handle) - if (!paneKey) { - throw new OrchestrationError( - 'stable_pane_required', - 'The coordinator terminal has no stable pane identity. Run this command inside a live Orca terminal.' - ) - } - return paneKey -} - export const ORCHESTRATION_RUN_METHODS: RpcMethod[] = [ defineMethod({ name: 'orchestration.runCreate', params: RunCreateParams, handler: (params, { orchestrationCompatibilityEvidence, runtime }) => { - assertCallerHandleMatchesEvidence(runtime, params.from, orchestrationCompatibilityEvidence) - const paneKey = requireCallerPane(runtime, params.from) + const paneKey = resolveOrchestrationCaller(runtime, { + callerTerminalHandle: params.from, + callerEvidence: orchestrationCompatibilityEvidence, + requireStablePane: true + }) const db = runtime.getOrchestrationDb() const priorRun = db.getCurrentRunForPane(paneKey) const run = db.createRun({ @@ -78,7 +62,13 @@ export const ORCHESTRATION_RUN_METHODS: RpcMethod[] = [ orchestrationCompatibilityCallerAuthority: callerAuthority } ) => { - const paneKey = requireCallerPane(runtime, params.from, callerAuthority) + const paneKey = resolveOrchestrationCaller(runtime, { + callerTerminalHandle: params.from, + callerEvidence: orchestrationCompatibilityEvidence, + callerAuthority, + requireStablePane: true, + evidenceAssertedByCaller: true + }) if ( params.takeoverLegacy && (callerAuthority?.terminalHandle !== params.from || callerAuthority.paneKey !== paneKey) @@ -117,8 +107,11 @@ export const ORCHESTRATION_RUN_METHODS: RpcMethod[] = [ name: 'orchestration.runCurrent', params: RunCurrentParams, handler: (params, { orchestrationCompatibilityEvidence, runtime }) => { - assertCallerHandleMatchesEvidence(runtime, params.from, orchestrationCompatibilityEvidence) - const paneKey = requireCallerPane(runtime, params.from) + const paneKey = resolveOrchestrationCaller(runtime, { + callerTerminalHandle: params.from, + callerEvidence: orchestrationCompatibilityEvidence, + requireStablePane: true + }) return { run: runtime.getOrchestrationDb().getCurrentRunForPane(paneKey) ?? null } } }), diff --git a/src/main/runtime/rpc/methods/orchestration-send-dispatch-authority.test.ts b/src/main/runtime/rpc/methods/orchestration-send-dispatch-authority.test.ts index cbba56c1c2a..7eaa19d9d8f 100644 --- a/src/main/runtime/rpc/methods/orchestration-send-dispatch-authority.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-send-dispatch-authority.test.ts @@ -5,6 +5,7 @@ import type { OrcaRuntimeService } from '../../orca-runtime' import { openDecisionGateFromMessage } from '../../orchestration/coordinator-decision-gates' import { applyEscalationToDispatch } from '../../orchestration/coordinator-escalation-triage' import { createOrchestrationRpcHarness } from './orchestration-rpc-test-harness' +import { createRootDispatch } from '../../orchestration/db/root-dispatch-test-fixture' describe('orchestration.send Dispatch authority', () => { const harness = createOrchestrationRpcHarness() @@ -29,7 +30,8 @@ describe('orchestration.send Dispatch authority', () => { async (legacyAuthority) => { setup() const attackerTask = db.createTask({ spec: 'attacker assignment' }) - const attacker = db.createDispatchContext( + const attacker = createRootDispatch( + db, attackerTask.id, 'term_attacker', 'tab_attacker:leaf_attacker', @@ -37,7 +39,7 @@ describe('orchestration.send Dispatch authority', () => { legacyAuthority ? undefined : 'runtime_test:term_attacker:1' ) const victimTask = db.createTask({ spec: 'victim assignment' }) - const victim = db.createDispatchContext(victimTask.id, 'term_victim') + const victim = createRootDispatch(db, victimTask.id, 'term_victim') vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => handle === 'term_attacker' ? 'tab_attacker:leaf_attacker' : harness.coordinatorPaneKey ) @@ -74,7 +76,7 @@ describe('orchestration.send Dispatch authority', () => { it('rejects a caller-spoofed canonical Dispatch sender', async () => { setup() const task = db.createTask({ spec: 'legacy victim assignment' }) - const dispatch = db.createDispatchContext(task.id, 'term_victim') + const dispatch = createRootDispatch(db, task.id, 'term_victim') const result = (await send({ from: `dispatch:${dispatch.id}`, @@ -97,7 +99,7 @@ describe('orchestration.send Dispatch authority', () => { async (type) => { setup() const task = db.createTask({ spec: 'legacy owned assignment' }) - db.createDispatchContext(task.id, 'term_legacy') + createRootDispatch(db, task.id, 'term_legacy') vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => handle === 'term_legacy' ? 'tab_legacy:leaf_legacy' : harness.coordinatorPaneKey ) @@ -123,7 +125,7 @@ describe('orchestration.send Dispatch authority', () => { async (type) => { setup() const task = db.createTask({ spec: 'legacy re-dispatch target' }) - const first = db.createDispatchContext(task.id, 'term_legacy') + const first = createRootDispatch(db, task.id, 'term_legacy') const sent = (await send({ from: 'term_legacy', @@ -137,7 +139,7 @@ describe('orchestration.send Dispatch authority', () => { expect(JSON.parse(sent.message.payload)).toMatchObject({ dispatchId: first.id }) db.failDispatch(first.id, 'worker stopped before coordinator read its mail') - const second = db.createDispatchContext(task.id, 'term_legacy') + const second = createRootDispatch(db, task.id, 'term_legacy') if (type === 'escalation') { applyEscalationToDispatch(db, db.getMessageById(sent.message.id)!, () => {}) diff --git a/src/main/runtime/rpc/methods/orchestration-send.test.ts b/src/main/runtime/rpc/methods/orchestration-send.test.ts index dafcbcc4327..36eaaa8b0a9 100644 --- a/src/main/runtime/rpc/methods/orchestration-send.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-send.test.ts @@ -7,6 +7,7 @@ import type { OrchestrationDb } from '../../orchestration/db' import type { OrcaRuntimeService } from '../../orca-runtime' import type { RuntimeTerminalSummary } from '../../../../shared/runtime-types' import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../shared/protocol-version' +import { createRootDispatch } from '../../orchestration/db/root-dispatch-test-fixture' function lifecycleGroupRecipientError( type: 'worker_done' | 'heartbeat' | 'escalation' | 'decision_gate' @@ -92,7 +93,7 @@ describe('orchestration RPC methods', () => { it('routes exact Dispatch mail independently of terminal handles', async () => { setup() const task = db.createTask({ spec: 'controlled worker' }) - const dispatch = db.createDispatchContext(task.id, 'term_worker') + const dispatch = createRootDispatch(db, task.id, 'term_worker') const result = (await call('orchestration.send', { from: 'term_coord', @@ -117,7 +118,8 @@ describe('orchestration RPC methods', () => { it('routes Dispatch mail by stable pane identity after worker handle remint', async () => { setup() const task = db.createTask({ spec: 'controlled worker after restart' }) - const dispatch = db.createDispatchContext( + const dispatch = createRootDispatch( + db, task.id, 'term_worker_before', 'tab_worker:leaf_worker' @@ -185,7 +187,7 @@ describe('orchestration RPC methods', () => { it('completes an identity-less injected send through its explicit worker handle', async () => { setup() const task = db.createTask({ spec: 'work' }) - const dispatch = db.createDispatchContext(task.id, 'term_worker', 'tab_worker:leaf_worker') + const dispatch = createRootDispatch(db, task.id, 'term_worker', 'tab_worker:leaf_worker') vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => handle === 'term_worker' ? 'tab_worker:leaf_worker' : null ) @@ -211,7 +213,8 @@ describe('orchestration RPC methods', () => { it('fences a replacement process for a capability-less manual Dispatch', async () => { setup() const task = db.createTask({ spec: 'process-bound manual work' }) - const dispatch = db.createDispatchContext( + const dispatch = createRootDispatch( + db, task.id, 'term_worker', 'tab_worker:leaf_worker', @@ -257,7 +260,8 @@ describe('orchestration RPC methods', () => { async (type) => { setup() const task = db.createTask({ spec: `process-bound ${type}` }) - const dispatch = db.createDispatchContext( + const dispatch = createRootDispatch( + db, task.id, 'term_worker', 'tab_worker:leaf_worker', @@ -313,7 +317,7 @@ describe('orchestration RPC methods', () => { setup() const task = db.createTask({ spec: 'work' }) const dependent = db.createTask({ spec: 'dependent', deps: [task.id] }) - const dispatch = db.createDispatchContext(task.id, 'term_worker', 'tab_worker:leaf_worker') + const dispatch = createRootDispatch(db, task.id, 'term_worker', 'tab_worker:leaf_worker') vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => handle === 'term_coord' ? 'tab_coord:leaf_coord' : null ) @@ -338,7 +342,7 @@ describe('orchestration RPC methods', () => { it('ignores caller-supplied pane claims and uses the runtime-observed pane', async () => { setup() const task = db.createTask({ spec: 'work' }) - const dispatch = db.createDispatchContext(task.id, 'term_worker', 'tab_worker:leaf_worker') + const dispatch = createRootDispatch(db, task.id, 'term_worker', 'tab_worker:leaf_worker') vi.spyOn(runtime, 'getTerminalPaneKey').mockReturnValue('tab_worker:leaf_worker') vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) @@ -374,7 +378,7 @@ describe('orchestration RPC methods', () => { it('requires the minted capability, exact pane, and process incarnation', async () => { setup() const task = db.createTask({ spec: 'capability work' }) - const dispatch = db.createDispatchContext(task.id, 'term_worker', 'tab_worker:leaf_worker') + const dispatch = createRootDispatch(db, task.id, 'term_worker', 'tab_worker:leaf_worker') const capability = db.mintDispatchCapability({ dispatchId: dispatch.id, paneKey: 'tab_worker:leaf_worker', @@ -456,7 +460,7 @@ describe('orchestration RPC methods', () => { it('does not wake waiters for a heartbeat suppressed at send time', async () => { setup() const task = db.createTask({ spec: 'work' }) - const dispatch = db.createDispatchContext(task.id, 'term_worker') + const dispatch = createRootDispatch(db, task.id, 'term_worker') db.updateTaskStatus(task.id, 'completed') vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) const notify = vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) @@ -476,7 +480,7 @@ describe('orchestration RPC methods', () => { it('still wakes waiters for a heartbeat on an active dispatch', async () => { setup() const task = db.createTask({ spec: 'work' }) - const dispatch = db.createDispatchContext(task.id, 'term_worker') + const dispatch = createRootDispatch(db, task.id, 'term_worker') vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) const notify = vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) @@ -685,7 +689,7 @@ describe('orchestration RPC methods', () => { it('continues to send worker_done to a concrete terminal handle', async () => { setup() const task = db.createTask({ spec: 'work' }) - const dispatch = db.createDispatchContext(task.id, 'term_worker') + const dispatch = createRootDispatch(db, task.id, 'term_worker') const result = (await call('orchestration.send', { from: 'term_worker', @@ -832,7 +836,7 @@ describe('orchestration RPC methods', () => { it('releases dispatch lock before waking recipients when worker_done is sent via send', async () => { setup() const task = db.createTask({ spec: 'lock-release work' }) - const dispatch = db.createDispatchContext(task.id, 'term_worker') + const dispatch = createRootDispatch(db, task.id, 'term_worker') // Why: waiter notification must observe the settled Dispatch, not stale lifecycle state. vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => { @@ -857,13 +861,13 @@ describe('orchestration RPC methods', () => { expect(db.getActiveDispatchForTerminal('term_worker')).toBeUndefined() // Lock released — a new dispatch to the same terminal must succeed. const t2 = db.createTask({ spec: 'follow-up work' }) - expect(() => db.createDispatchContext(t2.id, 'term_worker')).not.toThrow() + expect(() => createRootDispatch(db, t2.id, 'term_worker')).not.toThrow() }) it('records heartbeat when heartbeat is sent via send', async () => { setup() const task = db.createTask({ spec: 'heartbeat work' }) - const dispatch = db.createDispatchContext(task.id, 'term_worker') + const dispatch = createRootDispatch(db, task.id, 'term_worker') vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) await call('orchestration.send', { @@ -883,7 +887,7 @@ describe('orchestration RPC methods', () => { it('does not release dispatch lock for non-lifecycle sends', async () => { setup() const task = db.createTask({ spec: 'in-flight work' }) - const dispatch = db.createDispatchContext(task.id, 'term_worker') + const dispatch = createRootDispatch(db, task.id, 'term_worker') vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) await call('orchestration.send', { diff --git a/src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts b/src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts index 8c6c3156c52..e77f1fd78fe 100644 --- a/src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts @@ -4,6 +4,7 @@ import { createOrchestrationRpcHarness } from './orchestration-rpc-test-harness' import type { OrchestrationDb } from '../../orchestration/db' import type { OrcaRuntimeService } from '../../orca-runtime' import { buildInjectRejectionMessage } from './orchestration-inject-rejection-message' +import { createRootDispatch } from '../../orchestration/db/root-dispatch-test-fixture' describe('orchestration RPC methods', () => { const h = createOrchestrationRpcHarness() @@ -116,7 +117,7 @@ describe('orchestration RPC methods', () => { setup() const t1 = db.createTask({ spec: 'ready work' }) const t2 = db.createTask({ spec: 'active work' }) - const ctx = db.createDispatchContext(t2.id, 'term_worker') + const ctx = createRootDispatch(db, t2.id, 'term_worker') const result = (await call('orchestration.taskList', {})) as { tasks: { @@ -176,7 +177,7 @@ describe('orchestration RPC methods', () => { it('completion frees the active dispatch context', async () => { setup() const task = db.createTask({ spec: 'work' }) - db.createDispatchContext(task.id, 'term_a') + createRootDispatch(db, task.id, 'term_a') await call('orchestration.taskUpdate', { id: task.id, @@ -397,7 +398,7 @@ describe('orchestration RPC methods', () => { setup() const t1 = db.createTask({ spec: 'first' }) const t2 = db.createTask({ spec: 'second' }) - db.createDispatchContext(t1.id, 'term_a') + createRootDispatch(db, t1.id, 'term_a') await expect(call('orchestration.dispatch', { task: t2.id, to: 'term_a' })).rejects.toThrow( /already has an active dispatch/ @@ -453,7 +454,7 @@ describe('orchestration RPC methods', () => { it('shows dispatch context for a task', async () => { setup() const task = db.createTask({ spec: 'work' }) - db.createDispatchContext(task.id, 'term_a') + createRootDispatch(db, task.id, 'term_a') const result = (await call('orchestration.dispatchShow', { task: task.id @@ -474,7 +475,7 @@ describe('orchestration RPC methods', () => { it('--preamble returns the preamble text', async () => { setup() const task = db.createTask({ spec: 'refactor auth' }) - db.createDispatchContext(task.id, 'term_a') + createRootDispatch(db, task.id, 'term_a') const result = (await call('orchestration.dispatchShow', { task: task.id, diff --git a/src/main/runtime/rpc/methods/orchestration-worker-interactive-wait.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-interactive-wait.test.ts index 2392e3e4490..dac76afea71 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-interactive-wait.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-interactive-wait.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from '../../orca-runtime' import { OrchestrationDb } from '../../orchestration/db' import { ORCHESTRATION_METHODS } from './orchestration' +import { createRootDispatch } from '../../orchestration/db/root-dispatch-test-fixture' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -100,12 +101,12 @@ describe('worker-show interactive wait (STA-3714, STA-4513)', () => { if (!paneKey || !incarnation) { throw new Error('Runtime did not expose the worker pane identity.') } - const dispatch = db.createDispatchContext( + const dispatch = createRootDispatch( + db, task.id, terminal.handle, paneKey, - 'launch-hash', - // A dispatch recorded against a process that has since been replaced. + 'launch-hash', // A dispatch recorded against a process that has since been replaced. opts?.breakIdentity === true ? `${incarnation}:replaced` : incarnation ) db.mintDispatchCapability({ diff --git a/src/main/runtime/rpc/methods/orchestration-worker-stop-liveness-verdict.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-stop-liveness-verdict.test.ts index c08cc90554d..d381fa965ad 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-stop-liveness-verdict.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-stop-liveness-verdict.test.ts @@ -44,6 +44,8 @@ describe('worker-stop against a terminal we lost contact with', () => { }) const task = db.createTask({ spec: 'stop worker', runId: run.id }) const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, taskId: task.id, startOptions: {}, runtimeEpoch: runtime.getRuntimeId() diff --git a/src/main/runtime/rpc/methods/orchestration-workers-recovery.test.ts b/src/main/runtime/rpc/methods/orchestration-workers-recovery.test.ts index 35798feed76..3e8f9ec27be 100644 --- a/src/main/runtime/rpc/methods/orchestration-workers-recovery.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-workers-recovery.test.ts @@ -61,6 +61,8 @@ describe('orchestration worker recovery', () => { }) const task = db.createTask({ spec: 'recover worker', runId: run.id }) const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, taskId: task.id, startOptions: {}, runtimeEpoch @@ -206,6 +208,8 @@ describe('orchestration worker recovery', () => { }) const task = db.createTask({ spec: 'interrupted', runId: run.id }) const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, taskId: task.id, startOptions: {}, runtimeEpoch: 'previous_runtime' @@ -244,6 +248,8 @@ describe('orchestration worker recovery', () => { }) const task = db.createTask({ spec: 'stop remote worker', runId: run.id }) const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, taskId: task.id, startOptions: {}, runtimeEpoch: runtime.getRuntimeId(), diff --git a/src/main/runtime/rpc/methods/orchestration-workers.ts b/src/main/runtime/rpc/methods/orchestration-workers.ts index d1d94d897a5..6bc37f0c0b3 100644 --- a/src/main/runtime/rpc/methods/orchestration-workers.ts +++ b/src/main/runtime/rpc/methods/orchestration-workers.ts @@ -20,14 +20,24 @@ import { } from './orchestration-worker-setup-gate' import { failWorkerStartWithReceipt } from './orchestration-worker-start-receipt' import { prepareLocalWorkerStart } from './orchestration-worker-start-validation' +import { resolveDispatchCreator } from './orchestration-dispatch-creator' +import { resolveOrchestrationCaller } from './orchestration-run-scope' export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ defineMethod({ name: 'orchestration.workerStart', params: WorkerStartParams, - handler: async (params, { runtime, orchestrationMutation }) => { + handler: async ( + params, + { runtime, orchestrationMutation, orchestrationCompatibilityEvidence } + ) => { const db = runtime.getOrchestrationDb() - const coordinatorPane = runtime.getTerminalPaneKey(params.from) + // Why: worker-start was the only Run-scoped verb that skipped this, so a + // declared --from could name someone else's pane and inherit their depth. + const coordinatorPane = resolveOrchestrationCaller(runtime, { + callerTerminalHandle: params.from, + callerEvidence: orchestrationCompatibilityEvidence + }) const run = coordinatorPane ? db.getCurrentRunForPane(coordinatorPane) : undefined if (!run || (params.run && params.run !== run.id)) { throw new OrchestrationError( @@ -110,6 +120,8 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ : 'existing_worktree' } const started = db.createStartingWorkerDispatch({ + creator: resolveDispatchCreator(runtime, params.from), + maxDepth: runtime.getNestedWorkerMaxDepth(), taskId: task.id, retryOf: params.retryOf, startOptions, diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index b986fa25148..2cf7c8b1a10 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { setImmediate as yieldToEventLoop } from 'node:timers/promises' import { defineMethod, type RpcMethod } from '../core' +import { resolveDispatchCreator } from './orchestration-dispatch-creator' import { OptionalFiniteNumber, OptionalString, OptionalBoolean, requiredString } from '../schemas' import { LEGACY_CONTRACT_VERSION, @@ -1663,13 +1664,15 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ } revalidateLegacyCoordinator?.() - const ctx = db.createDispatchContext( - params.task, - to, + const ctx = db.createDispatchContext({ + taskId: params.task, + assigneeHandle: to, assigneePaneKey, - dispatchAuthority?.launchTokenHash ?? undefined, - processIncarnation - ) + launchTokenHash: dispatchAuthority?.launchTokenHash ?? undefined, + processIncarnation, + creator: resolveDispatchCreator(runtime, params.from), + maxDepth: runtime.getNestedWorkerMaxDepth() + }) const dispatchCapability = params.inject ? db.mintDispatchCapability({ dispatchId: ctx.id, diff --git a/src/main/runtime/rpc/orchestration-11745-regression-verification.test.ts b/src/main/runtime/rpc/orchestration-11745-regression-verification.test.ts index 73f201a91a2..47d585ca244 100644 --- a/src/main/runtime/rpc/orchestration-11745-regression-verification.test.ts +++ b/src/main/runtime/rpc/orchestration-11745-regression-verification.test.ts @@ -23,6 +23,7 @@ import { request, type LegacyCompatibilityDispatcherHarness } from './orchestration-legacy-compatibility-dispatcher-test-fixture' +import { createRootDispatch } from '../orchestration/db/root-dispatch-test-fixture' // Why: an unrelated caller the runtime CAN resolve to a pane — otherwise the refusal would be // stable_pane_required and would prove nothing about Run authorization. @@ -642,7 +643,8 @@ function createAdoptedDb(options: { settleWork: boolean }): { const before = new OrchestrationDb(dbPath) const task = before.createTask({ spec: 'legacy assignment', createdByTerminalHandle: 'term_old' }) - before.createDispatchContext( + createRootDispatch( + before, task.id, 'term_old_worker', 'tab_old:33333333-3333-4333-8333-333333333333' diff --git a/src/main/runtime/rpc/orchestration-current-authority-precedence.test.ts b/src/main/runtime/rpc/orchestration-current-authority-precedence.test.ts index 27e5b0e1bea..fc6cd30f82e 100644 --- a/src/main/runtime/rpc/orchestration-current-authority-precedence.test.ts +++ b/src/main/runtime/rpc/orchestration-current-authority-precedence.test.ts @@ -15,6 +15,7 @@ import { WORKER_HANDLE, WORKER_PANE } from './orchestration-legacy-compatibility-dispatcher-test-fixture' +import { createRootDispatch } from '../orchestration/db/root-dispatch-test-fixture' afterEach(() => { cleanupLegacyCompatibilityDispatcherHarnesses() @@ -287,7 +288,8 @@ function createCurrentDispatch(harness: ReturnType): { coordinatorPaneKey: CURRENT_COORDINATOR_PANE }) const task = harness.db.createTask({ spec: 'current assignment', runId: run.id }) - const dispatch = harness.db.createDispatchContext( + const dispatch = createRootDispatch( + harness.db, task.id, CURRENT_WORKER_HANDLE, CURRENT_WORKER_PANE @@ -326,7 +328,7 @@ async function createReusedCurrentDispatch( coordinatorPaneKey: CURRENT_COORDINATOR_PANE }) const task = harness.db.createTask({ spec: 'reused terminal assignment', runId: run.id }) - const dispatch = harness.db.createDispatchContext(task.id, WORKER_HANDLE, WORKER_PANE) + const dispatch = createRootDispatch(harness.db, task.id, WORKER_HANDLE, WORKER_PANE) const capability = harness.db.mintDispatchCapability({ dispatchId: dispatch.id, paneKey: WORKER_PANE, diff --git a/src/main/runtime/rpc/orchestration-legacy-compatibility-dispatcher-test-fixture.ts b/src/main/runtime/rpc/orchestration-legacy-compatibility-dispatcher-test-fixture.ts index b2c31d741c0..cca68da8f63 100644 --- a/src/main/runtime/rpc/orchestration-legacy-compatibility-dispatcher-test-fixture.ts +++ b/src/main/runtime/rpc/orchestration-legacy-compatibility-dispatcher-test-fixture.ts @@ -11,6 +11,7 @@ import { OrchestrationDb } from '../orchestration/db' import type { RpcRequest, RpcResponse } from './core' import { RpcDispatcher } from './dispatcher' import { ORCHESTRATION_METHODS } from './methods/orchestration' +import { createRootDispatch } from '../orchestration/db/root-dispatch-test-fixture' export const WORKER_HANDLE = 'term_legacy_worker' export const WORKER_PANE = 'tab_worker:33333333-3333-4333-8333-333333333333' @@ -55,7 +56,7 @@ export function createHarness(): LegacyCompatibilityDispatcherHarness { spec: 'legacy assignment', createdByTerminalHandle: COORDINATOR_HANDLE }) - const dispatch = before.createDispatchContext(task.id, WORKER_HANDLE, WORKER_PANE) + const dispatch = createRootDispatch(before, task.id, WORKER_HANDLE, WORKER_PANE) before.close() const raw = new Database(dbPath) diff --git a/src/main/runtime/rpc/orchestration-legacy-compatibility-dispatcher.test.ts b/src/main/runtime/rpc/orchestration-legacy-compatibility-dispatcher.test.ts index 0379646cb52..ca19570cbf5 100644 --- a/src/main/runtime/rpc/orchestration-legacy-compatibility-dispatcher.test.ts +++ b/src/main/runtime/rpc/orchestration-legacy-compatibility-dispatcher.test.ts @@ -15,6 +15,7 @@ import { WORKER_HANDLE, WORKER_PANE } from './orchestration-legacy-compatibility-dispatcher-test-fixture' +import { createRootDispatch } from '../orchestration/db/root-dispatch-test-fixture' afterEach(() => { cleanupLegacyCompatibilityDispatcherHarnesses() @@ -191,7 +192,8 @@ describe('legacy compatibility through RpcDispatcher', () => { processIncarnation: 'process-1' }) harness.db.updateTaskStatus(harness.taskId, 'ready') - const currentDispatch = harness.db.createDispatchContext( + const currentDispatch = createRootDispatch( + harness.db, harness.taskId, 'term_current_worker', 'tab_current_worker:77777777-7777-4777-8777-777777777777', @@ -395,7 +397,8 @@ describe('legacy compatibility through RpcDispatcher', () => { coordinatorPaneKey: 'tab_current_coord:55555555-5555-4555-8555-555555555555' }) const task = harness.db.createTask({ spec: 'current assignment', runId: run.id }) - const dispatch = harness.db.createDispatchContext( + const dispatch = createRootDispatch( + harness.db, task.id, 'term_current_worker', 'tab_current_worker:66666666-6666-4666-8666-666666666666', diff --git a/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts b/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts index 4b8bb388226..9236d643e40 100644 --- a/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts +++ b/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts @@ -11,6 +11,7 @@ import { OrchestrationDb } from '../orchestration/db' import type { RpcRequest, RpcResponse } from './core' import { RpcDispatcher } from './dispatcher' import { ORCHESTRATION_METHODS } from './methods/orchestration' +import { createRootDispatch } from '../orchestration/db/root-dispatch-test-fixture' const COORDINATOR_HANDLE = 'term_legacy_coord' const COORDINATOR_PANE = 'tab_coord:44444444-4444-4444-8444-444444444444' @@ -45,7 +46,7 @@ function createHarness(): Harness { spec: 'legacy assignment', createdByTerminalHandle: COORDINATOR_HANDLE }) - const dispatch = before.createDispatchContext(task.id, WORKER_HANDLE, WORKER_PANE) + const dispatch = createRootDispatch(before, task.id, WORKER_HANDLE, WORKER_PANE) before.close() const raw = new Database(dbPath) diff --git a/src/main/runtime/rpc/orchestration-legacy-question-takeover.test.ts b/src/main/runtime/rpc/orchestration-legacy-question-takeover.test.ts index 37fb70d116f..77d7e2d5a02 100644 --- a/src/main/runtime/rpc/orchestration-legacy-question-takeover.test.ts +++ b/src/main/runtime/rpc/orchestration-legacy-question-takeover.test.ts @@ -11,6 +11,7 @@ import { OrcaRuntimeService } from '../orca-runtime' import type { RpcRequest } from './core' import { RpcDispatcher } from './dispatcher' import { ORCHESTRATION_METHODS } from './methods/orchestration' +import { createRootDispatch } from '../orchestration/db/root-dispatch-test-fixture' const COORDINATOR_HANDLE = 'term_legacy_coord' const CURRENT_COORDINATOR_HANDLE = 'term_current_coord' @@ -42,7 +43,7 @@ function createHarness(options?: { seedCutoverQuestion?: boolean; seedCutoverAns spec: 'legacy assignment', createdByTerminalHandle: COORDINATOR_HANDLE }) - const dispatch = before.createDispatchContext(task.id, WORKER_HANDLE, WORKER_PANE) + const dispatch = createRootDispatch(before, task.id, WORKER_HANDLE, WORKER_PANE) const cutoverQuestion = options?.seedCutoverQuestion ? before.insertMessage({ from: WORKER_HANDLE, diff --git a/src/main/runtime/rpc/orchestration-legacy-takeover-delivery.test.ts b/src/main/runtime/rpc/orchestration-legacy-takeover-delivery.test.ts index 0451eed101a..bcaf5fca11f 100644 --- a/src/main/runtime/rpc/orchestration-legacy-takeover-delivery.test.ts +++ b/src/main/runtime/rpc/orchestration-legacy-takeover-delivery.test.ts @@ -11,6 +11,7 @@ import { OrchestrationDb } from '../orchestration/db' import type { RpcRequest } from './core' import { RpcDispatcher } from './dispatcher' import { ORCHESTRATION_METHODS } from './methods/orchestration' +import { createRootDispatch } from '../orchestration/db/root-dispatch-test-fixture' const WORKER_HANDLE = 'term_legacy_worker' const WORKER_PANE = 'tab_worker:33333333-3333-4333-8333-333333333333' @@ -53,7 +54,7 @@ function createHarness(): Harness { spec: 'legacy assignment', createdByTerminalHandle: COORDINATOR_HANDLE }) - const dispatch = before.createDispatchContext(task.id, WORKER_HANDLE, WORKER_PANE) + const dispatch = createRootDispatch(before, task.id, WORKER_HANDLE, WORKER_PANE) before.close() const raw = new Database(dbPath) diff --git a/src/main/runtime/rpc/orchestration-legacy-takeover-dispatcher.test.ts b/src/main/runtime/rpc/orchestration-legacy-takeover-dispatcher.test.ts index aa4c7daef01..b3774ce735f 100644 --- a/src/main/runtime/rpc/orchestration-legacy-takeover-dispatcher.test.ts +++ b/src/main/runtime/rpc/orchestration-legacy-takeover-dispatcher.test.ts @@ -11,6 +11,7 @@ import { OrchestrationDb } from '../orchestration/db' import type { RpcRequest } from './core' import { RpcDispatcher } from './dispatcher' import { ORCHESTRATION_METHODS } from './methods/orchestration' +import { createRootDispatch } from '../orchestration/db/root-dispatch-test-fixture' const WORKER_HANDLE = 'term_legacy_worker' const WORKER_PANE = 'tab_worker:33333333-3333-4333-8333-333333333333' @@ -48,7 +49,7 @@ function createHarness(): Harness { spec: 'legacy assignment', createdByTerminalHandle: COORDINATOR_HANDLE }) - const dispatch = before.createDispatchContext(task.id, WORKER_HANDLE, WORKER_PANE) + const dispatch = createRootDispatch(before, task.id, WORKER_HANDLE, WORKER_PANE) before.close() const raw = new Database(dbPath) diff --git a/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts b/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts index bc54f1643ed..d44d3f153d7 100644 --- a/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts +++ b/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts @@ -10,6 +10,7 @@ import { OrchestrationDb } from '../orchestration/db' import { defineMethod, type RpcRequest } from './core' import { RpcDispatcher } from './dispatcher' import { ORCHESTRATION_METHODS } from './methods/orchestration' +import { createRootDispatch } from '../orchestration/db/root-dispatch-test-fixture' const Params = z.object({ subject: z.string() }) @@ -304,6 +305,8 @@ describe('durable orchestration mutation ledger', () => { .update(JSON.stringify({ method: 'orchestration.workerStart', params })) .digest('hex') const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, taskId: params.task, startOptions: {}, mutationReceipt: { @@ -371,7 +374,7 @@ describe('durable orchestration mutation ledger', () => { coordinatorPaneKey: 'tab_coord:leaf_coord' }) const task = db.createTask({ spec: 'ask', runId: run.id }) - const dispatch = db.createDispatchContext(task.id, 'term_worker', 'tab_worker:leaf_worker') + const dispatch = createRootDispatch(db, task.id, 'term_worker', 'tab_worker:leaf_worker') const capability = db.mintDispatchCapability({ dispatchId: dispatch.id, paneKey: 'tab_worker:leaf_worker', diff --git a/src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts b/src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts index 4dc8cd892b1..52205d00791 100644 --- a/src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts +++ b/src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts @@ -10,6 +10,7 @@ import { OrchestrationDb } from '../orchestration/db' import type { RpcRequest, RpcResponse } from './core' import { RpcDispatcher } from './dispatcher' import { ORCHESTRATION_METHODS } from './methods/orchestration' +import { createRootDispatch } from '../orchestration/db/root-dispatch-test-fixture' const WORKER_HANDLE = 'term_pre_update_worker' const WORKER_PANE = 'tab_pre_update:33333333-3333-4333-8333-333333333333' @@ -64,7 +65,7 @@ function createUpdateHarness(): Harness { spec: 'finish work across an app update', createdByTerminalHandle: COORDINATOR_HANDLE }) - const dispatch = oldRuntimeDb.createDispatchContext(task.id, WORKER_HANDLE, WORKER_PANE) + const dispatch = createRootDispatch(oldRuntimeDb, task.id, WORKER_HANDLE, WORKER_PANE) const capability = oldRuntimeDb.mintDispatchCapability({ dispatchId: dispatch.id, paneKey: WORKER_PANE, diff --git a/src/main/runtime/rpc/orchestration-task-dispatch-invariant.test.ts b/src/main/runtime/rpc/orchestration-task-dispatch-invariant.test.ts index 539ddd1429a..0d194a372ba 100644 --- a/src/main/runtime/rpc/orchestration-task-dispatch-invariant.test.ts +++ b/src/main/runtime/rpc/orchestration-task-dispatch-invariant.test.ts @@ -44,7 +44,12 @@ describe('Task/Dispatch state invariant', () => { const task = harness.db.createTask({ spec: 'retain assignment', runId: harness.runId }) const dispatch = dispatchStatus === 'pending' - ? harness.db.createStartingWorkerDispatch({ taskId: task.id, startOptions: {} }).dispatch + ? harness.db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }).dispatch : await dispatchTask(harness, task.id, WORKER_HANDLE) const response = await updateTask(harness, task.id, 'ready', 'must not persist') @@ -285,7 +290,12 @@ async function createCapableDispatch( status: 'pending' | 'dispatched' ): Promise<{ dispatch: { id: string }; capability: string }> { if (status === 'pending') { - const dispatch = harness.db.createStartingWorkerDispatch({ taskId, startOptions: {} }).dispatch + const dispatch = harness.db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId, + startOptions: {} + }).dispatch const capability = harness.db.prepareStartingWorkerAuthority({ dispatchId: dispatch.id, handle: WORKER_HANDLE, @@ -312,7 +322,12 @@ function createSupervisedDispatch( taskId: string, status: 'pending' | 'dispatched' ): { dispatch: { id: string }; capability: string } { - const dispatch = harness.db.createStartingWorkerDispatch({ taskId, startOptions: {} }).dispatch + const dispatch = harness.db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId, + startOptions: {} + }).dispatch const capability = harness.db.prepareStartingWorkerAuthority({ dispatchId: dispatch.id, handle: WORKER_HANDLE, diff --git a/src/main/runtime/runtime-rpc-long-poll-transport.test.ts b/src/main/runtime/runtime-rpc-long-poll-transport.test.ts index e14b4561dcc..43279ea7931 100644 --- a/src/main/runtime/runtime-rpc-long-poll-transport.test.ts +++ b/src/main/runtime/runtime-rpc-long-poll-transport.test.ts @@ -14,6 +14,7 @@ import { waitFor, seedSupervisedAskWorkers } from './runtime-rpc-test-harness' +import { createRootDispatch } from './orchestration/db/root-dispatch-test-fixture' vi.mock('../git/worktree', () => { const worktrees = [ @@ -129,7 +130,7 @@ describe('OrcaRuntimeRpcServer', () => { coordinatorPaneKey: 'tab_coord:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' }) const task = db.createTask({ spec: 'Wait for an answer', runId: run.id }) - db.createDispatchContext(task.id, 'term_asker', askerPaneKey) + createRootDispatch(db, task.id, 'term_asker', askerPaneKey) const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, diff --git a/src/main/runtime/runtime-rpc-test-harness.ts b/src/main/runtime/runtime-rpc-test-harness.ts index 709585cb987..917ea73bd73 100644 --- a/src/main/runtime/runtime-rpc-test-harness.ts +++ b/src/main/runtime/runtime-rpc-test-harness.ts @@ -1,6 +1,7 @@ import { createConnection, type Socket } from 'node:net' import type { OrchestrationDb } from './orchestration/db' import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version' +import { createRootDispatch } from './orchestration/db/root-dispatch-test-fixture' export async function sendRequest( endpoint: string, @@ -112,6 +113,6 @@ export function seedSupervisedAskWorkers(db: OrchestrationDb, workerHandles: str }) for (const workerHandle of workerHandles) { const task = db.createTask({ spec: 'Wait for coordinator input', runId: run.id }) - db.createDispatchContext(task.id, workerHandle) + createRootDispatch(db, task.id, workerHandle) } } diff --git a/src/main/ssh/ssh-remote-orca-cli.test.ts b/src/main/ssh/ssh-remote-orca-cli.test.ts index e9cb35e352b..0b3695aec5c 100644 --- a/src/main/ssh/ssh-remote-orca-cli.test.ts +++ b/src/main/ssh/ssh-remote-orca-cli.test.ts @@ -15,6 +15,7 @@ import { OrchestrationDb } from '../runtime/orchestration/db' import { OrcaRuntimeService } from '../runtime/orca-runtime' import type { HostCliPassthroughOptions } from './ssh-remote-cli-host-passthrough' import { runRemoteOrcaCli } from './ssh-remote-orca-cli' +import { createRootDispatch } from '../runtime/orchestration/db/root-dispatch-test-fixture' // Why: pointing the passthrough at a missing CLI entry forces the legacy // in-process fallback, which is what these dispatch tests exercise. @@ -241,7 +242,7 @@ describe('runRemoteOrcaCli', () => { coordinatorPaneKey: 'tab_coord:leaf_coord' }) const task = db.createTask({ spec: 'remote work', runId: run.id }) - const dispatch = db.createDispatchContext(task.id, 'term_ssh', 'tab_owner:leaf_owner') + const dispatch = createRootDispatch(db, task.id, 'term_ssh', 'tab_owner:leaf_owner') vi.spyOn(runtime, 'getTerminalPaneKey').mockReturnValue('tab_foreign:leaf_foreign') try { @@ -299,7 +300,7 @@ describe('runRemoteOrcaCli', () => { coordinatorPaneKey: 'tab_coord:leaf_coord' }) const task = db.createTask({ spec: 'remote work', runId: run.id }) - const dispatch = db.createDispatchContext(task.id, 'term_ssh', 'tab_owner:leaf_owner') + const dispatch = createRootDispatch(db, task.id, 'term_ssh', 'tab_owner:leaf_owner') vi.spyOn(runtime, 'getTerminalPaneKey').mockReturnValue('tab_owner:leaf_owner') try { @@ -358,7 +359,12 @@ describe('runRemoteOrcaCli', () => { coordinatorPaneKey: 'tab_coord:leaf_coord' }) const task = db.createTask({ spec: 'remote work', runId: run.id }) - const started = db.createStartingWorkerDispatch({ taskId: task.id, startOptions: {} }) + const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) const capability = db.prepareStartingWorkerAuthority({ dispatchId: started.dispatch.id, handle: 'term_ssh', diff --git a/src/main/ssh/ssh-remote-orchestration-compatibility.test.ts b/src/main/ssh/ssh-remote-orchestration-compatibility.test.ts index 45e6eb57f41..17923f5911c 100644 --- a/src/main/ssh/ssh-remote-orchestration-compatibility.test.ts +++ b/src/main/ssh/ssh-remote-orchestration-compatibility.test.ts @@ -17,6 +17,7 @@ import type Database from '../sqlite/sync-database' import type { HostCliPassthroughOptions } from './ssh-remote-cli-host-passthrough' import { runRemoteOrcaCli } from './ssh-remote-orca-cli' import { acknowledgeRemoteOrcaCliPostOutput } from './ssh-remote-orchestration-post-output' +import { createRootDispatch } from '../runtime/orchestration/db/root-dispatch-test-fixture' const LEGACY_FALLBACK_OPTIONS: HostCliPassthroughOptions = { execPath: '/host/electron', @@ -57,7 +58,7 @@ function createLegacyRuntime() { runId: run.id, createdByTerminalHandle: COORDINATOR_HANDLE }) - const dispatch = db.createDispatchContext(task.id, WORKER_HANDLE, WORKER_PANE) + const dispatch = createRootDispatch(db, task.id, WORKER_HANDLE, WORKER_PANE) const sqlite = (db as unknown as { db: Database.Database }).db sqlite .prepare( diff --git a/src/shared/constants.ts b/src/shared/constants.ts index a6698d0108d..928e774eaa3 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -281,6 +281,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { artifactsEnabled: true, artifactSharingEnabled: false, agentSkillSharingEnabled: false, + nestedWorkerMaxDepth: 1, showArtifactsButton: false, showSkillsButton: false, showMobileButton: true, diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 9899a3ffde3..0ed2c72154c 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -230,6 +230,10 @@ export type GlobalSettings = { artifactSharingEnabled?: boolean /** Capability gate for agent/CLI skill publishing; manual reviewed publishing remains available. */ agentSkillSharingEnabled?: boolean + /** How deep dispatched workers may nest. 1 = workers cannot dispatch sub-workers. + * Renderer-writable only: omitted from the SettingsUpdate RPC schema so a worker + * cannot raise its own cap via `orca settings update`. */ + nestedWorkerMaxDepth?: number /** Only toggles the sidebar shortcut; Artifacts stay reachable from Settings. */ showArtifactsButton?: boolean /** Only toggles the sidebar shortcut; Skills stay reachable from Settings. */ diff --git a/src/shared/nested-worker-depth.test.ts b/src/shared/nested-worker-depth.test.ts new file mode 100644 index 00000000000..a91dd87ad00 --- /dev/null +++ b/src/shared/nested-worker-depth.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { + NESTED_WORKER_MAX_DEPTH_DEFAULT, + nestedWorkerDepthExceededMessage, + resolveNestedWorkerMaxDepth +} from './nested-worker-depth' + +describe('resolveNestedWorkerMaxDepth', () => { + it('defaults to 1 when unset', () => { + expect(resolveNestedWorkerMaxDepth(undefined)).toBe(1) + expect(resolveNestedWorkerMaxDepth(null)).toBe(1) + expect(resolveNestedWorkerMaxDepth({})).toBe(1) + }) + + it('accepts whole numbers at or above 1', () => { + expect(resolveNestedWorkerMaxDepth({ nestedWorkerMaxDepth: 1 })).toBe(1) + expect(resolveNestedWorkerMaxDepth({ nestedWorkerMaxDepth: 3 })).toBe(3) + }) + + // A malformed setting must not become a way to get unlimited nesting, so every + // rejected shape falls back to the default rather than disabling the cap. + it.each([ + ['a numeric string', '2'], + ['a boolean', true], + ['zero', 0], + ['negative', -1], + ['fractional', 1.5], + ['NaN', Number.NaN], + ['Infinity', Number.POSITIVE_INFINITY], + ['null', null] + ])('falls back to the default for %s', (_label, value) => { + expect(resolveNestedWorkerMaxDepth({ nestedWorkerMaxDepth: value as unknown as number })).toBe( + NESTED_WORKER_MAX_DEPTH_DEFAULT + ) + }) +}) + +describe('depth-exceeded message', () => { + it('names both depths and tells the worker to finish the task itself', () => { + const message = nestedWorkerDepthExceededMessage(2, 1) + expect(message).toContain('depth 2 (max 1)') + expect(message).toContain('Complete this task yourself') + }) +}) diff --git a/src/shared/nested-worker-depth.ts b/src/shared/nested-worker-depth.ts new file mode 100644 index 00000000000..927f9801c12 --- /dev/null +++ b/src/shared/nested-worker-depth.ts @@ -0,0 +1,42 @@ +import type { GlobalSettings } from './global-settings-types' + +/** + * How deep dispatched workers may nest. 1 means a coordinator dispatches + * workers and those workers may not dispatch further — the behaviour Orca + * documented but never actually enforced. + */ +export const NESTED_WORKER_MAX_DEPTH_DEFAULT = 1 + +/** Root coordinators are depth 0; the first generation of workers is depth 1. */ +export const ROOT_DISPATCH_DEPTH = 0 + +export const NESTED_WORKER_DEPTH_EXCEEDED_CODE = 'nested_worker_depth_exceeded' + +export function nestedWorkerDepthExceededMessage(childDepth: number, maxDepth: number): string { + // Why "complete this task yourself": a refusal alone leaves the worker looping + // on a capability it will never get. + return ( + `Sub-worker dispatch is not permitted at depth ${childDepth} (max ${maxDepth}). ` + + 'Complete this task yourself.' + ) +} + +export const NESTED_WORKER_DEPTH_EXCEEDED_NEXT_STEPS: readonly string[] = [ + 'Do the work in this terminal instead of dispatching a sub-worker.', + 'To allow deeper nesting, open Settings → Agents in the Orca desktop app and raise "Nested worker depth".' +] + +/** + * Clamp to a usable integer. Anything that is not a whole number >= 1 falls back + * to the default rather than disabling the fence: a malformed setting must not + * be a way to get unlimited nesting. + */ +export function resolveNestedWorkerMaxDepth( + settings: Pick | null | undefined +): number { + const raw = settings?.nestedWorkerMaxDepth + if (typeof raw !== 'number' || !Number.isInteger(raw) || raw < 1) { + return NESTED_WORKER_MAX_DEPTH_DEFAULT + } + return raw +} From 8d61cb8b771b7b658a5e1e2bb426c719644d09f3 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:29:46 -0700 Subject: [PATCH 009/152] fix(relay): survivable mobile pairing recovery + desktop assign rate gate (#16659) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mobile): retry the stored assignment when the director reports no newer move A director answering /v1/connect can only reply relay-moved with the stored assignment; it has no 'assignment unchanged' verb, and sticky assignments make equal-epoch replies the steady state. Treating every non-newer move as fatal made pairing recovery unwinnable for any transient cell dial failure (DRAINING, 1006), which bricked off-LAN pairing on Android 0.0.44. A non-newer move now confirms the stored assignment: the candidate re-dials it with a 250ms floor instead of abandoning the relay path. The move is never adopted or persisted, so the anti-rollback contract (requireStrictlyNewerEpoch for persisted moves) is unchanged. 4429 stays out of director recovery: each cell dial burns an invite attempt server-side and a director hop cannot relieve cell load. * fix(mobile): honor relay director Retry-After when pacing recovery Mobile /v1/resolve collapsed every non-OK status into a generic error and discarded Retry-After, so overloaded windows produced hammering instead of paced retries. RelayDirectorHttpError now carries status and retryAfterMs (clamped to 120s), and the reconnect controller floors its existing transport delay with it — no new timers or retry state. The Retry-After parser is extracted from the desktop relay client into src/shared and reused by both. * fix(mobile): attribute pairing log lines to their candidate path The pairing race interleaves the direct LAN and relay candidates into one PAIRING LOG pane; direct lines (WebSocket closed, Reconnecting 10.x.x.x:6768) carried no path label and repeatedly read as Relay retrying a private IP — misleading users and two investigations. The coordinator now wraps each candidate's sink with an idempotent Direct:/Relay: prefix at the one seam where both paths are known. * fix(relay): gate desktop /v1/assign at the per-host rate limit The director rate-limits /v1/assign per host at 5s, but every desktop retry path could fire immediately: both schedulers draw full jitter from [0, cap] (floor 0), first attempts after a drain are undelayed, the 400-fallbacks issue up to 3 assigns per round trip, and reconcile() cancels the armed Retry-After timer from ~8 refreshDemand callers. Production shows hosts permanently rejected at ~100-200 rejects per success. A shared per-host gate now lives inside requestRelayAssignment — the single assign call site — so every path books a >=5s (+jitter) slot. Retry-After raises the gate persistently, surviving the coordinator's timer cancellation. Concurrent callers serialize through a per-key chain. Callers with staleness fencing pass isCurrent; a superseded caller aborts after the wait instead of spending the host's slot. Internal 400-fallback retries stay one logical attempt and do not re-enter the gate. * refactor(mobile): rename the log-only assignment-echo predicate isCurrentAssignmentMove no longer gates control flow — every non-newer move retries the stored assignment — so the name overstated its role. * fix(relay): honor mid-wait raises and cap the assign gate's inline wait Review findings on the per-host assign gate: the deadline was read once before sleeping, so a sibling's Retry-After landing mid-wait was ignored (the exact storm the gate exists for), and the sleep was uncancellable — a booked five-minute Retry-After could park pairing IPC, which awaits reconcile inline, for its full duration. The wait now runs in 1s slices, re-reading the deadline and the caller's isCurrent fence each slice. Remaining waits beyond 15s fail fast as a RelayHttpError 429 carrying the remainder, so the existing schedulers pace with it while the gate keeps the deadline. Staleness aborts are classified non-retryable. Also from review: the broker's isCurrent wiring and the shared-gate default are now pinned by tests, the 4429 comment states the reservation-order rationale precisely, the mobile Retry-After ceiling is renamed to avoid colliding with the desktop's 5-minute one, and a past-HTTP-date header case is covered. * fix(relay): tag locally paced assigns and warn about frozen test clocks Review polish: the synthesized 429 for a beyond-cap local wait now carries a distinct message (relay_assignment_locally_paced_429) so log censuses can tell it from a real director 429, with the comment stating the invariant that makes the translation honest (local booking alone never exceeds ~5.5s). The gate's sleep option documents that test fakes must advance the clock — the slice loop re-reads it and never terminates against a frozen one. * fix(relay): fence superseded callers at the assign send boundary reserve() checks staleness while waiting, but a caller superseded after booking — or between the 400 field-fallback retries — could still spend one to two requests on an assignment nobody consumes. Re-check isCurrent at the top of sendRelayAssignment so the fallback recursion is fenced too. --- .../mobile-relay-reconnect-controller.test.ts | 36 +++ .../mobile-relay-reconnect-controller.ts | 10 +- .../mobile-relay-resume-director.test.ts | 38 ++- .../transport/mobile-relay-resume-director.ts | 26 +- .../transport/mobile-relay-retry-delays.ts | 8 +- .../src/transport/pairing-candidate-race.ts | 4 +- mobile/src/transport/pairing-log-path.test.ts | 39 +++ mobile/src/transport/pairing-log-path.ts | 27 ++ .../transport/pairing-relay-candidate.test.ts | 60 +++- .../src/transport/pairing-relay-candidate.ts | 27 +- .../pre-profile-pairing-coordinator.test.ts | 62 +++- .../pre-profile-pairing-coordinator.ts | 5 +- .../runtime/relay/relay-assign-rate-gate.ts | 134 +++++++++ .../runtime/relay/relay-http-client.test.ts | 269 +++++++++++++++++- src/main/runtime/relay/relay-http-client.ts | 79 ++++- src/main/runtime/relay/relay-origin-pool.ts | 1 + .../relay/relay-session-broker.test.ts | 11 +- .../runtime/relay/relay-session-broker.ts | 1 + src/shared/relay-retry-after-header.ts | 17 ++ 19 files changed, 808 insertions(+), 46 deletions(-) create mode 100644 mobile/src/transport/pairing-log-path.test.ts create mode 100644 mobile/src/transport/pairing-log-path.ts create mode 100644 src/main/runtime/relay/relay-assign-rate-gate.ts create mode 100644 src/shared/relay-retry-after-header.ts diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.test.ts b/mobile/src/transport/mobile-relay-reconnect-controller.test.ts index 36e4fa92037..ac6112f2c44 100644 --- a/mobile/src/transport/mobile-relay-reconnect-controller.test.ts +++ b/mobile/src/transport/mobile-relay-reconnect-controller.test.ts @@ -3,6 +3,7 @@ import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel' import { RelayOuterError } from './mobile-relay-e2ee-link' import { RelayReconnectController } from './mobile-relay-reconnect-controller' +import { RelayDirectorHttpError } from './mobile-relay-resume-director' import type { StableLogicalRpcClient } from './stable-logical-rpc-client' vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) @@ -336,6 +337,41 @@ describe('relay reconnect controller', () => { expect(onRetry).toHaveBeenCalledTimes(2) }) + it('paces the next dial by the director Retry-After instead of the local backoff', () => { + const onRetry = vi.fn() + const reconnect = createController(onRetry) + + reconnect.registerFailure(new RelayDirectorHttpError(503, 30_000)) + + expect(reconnect.retryDelayMs(0)).toBe(30_000) + vi.advanceTimersByTime(29_999) + expect(onRetry).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + expect(onRetry).toHaveBeenCalledOnce() + }) + + it('keeps the local backoff when the director sent no Retry-After', () => { + const onRetry = vi.fn() + const reconnect = createController(onRetry) + + reconnect.registerFailure(new RelayDirectorHttpError(500, null)) + + expect(reconnect.retryDelayMs(0)).toBe(250) + vi.advanceTimersByTime(250) + expect(onRetry).toHaveBeenCalledOnce() + }) + + it('never lets a short Retry-After shorten an escalated backoff', () => { + const reconnect = createController(vi.fn()) + + for (let failure = 0; failure < 6; failure++) { + reconnect.registerFailure(new RelayOuterError(4429), false) + } + reconnect.registerFailure(new RelayDirectorHttpError(503, 100), false) + + expect(reconnect.retryDelayMs(0)).toBe(15_000) + }) + it('uses grace only when the outer relay credential was rejected', () => { const reconnect = createController(vi.fn()) diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.ts b/mobile/src/transport/mobile-relay-reconnect-controller.ts index c0e201a4ee1..2d99e6b1d27 100644 --- a/mobile/src/transport/mobile-relay-reconnect-controller.ts +++ b/mobile/src/transport/mobile-relay-reconnect-controller.ts @@ -7,6 +7,7 @@ import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel' import { RelayOuterError } from './mobile-relay-e2ee-link' import { RELAY_STABLE_CONNECTION_MS, RelayRetryDelays } from './mobile-relay-retry-delays' +import { relayDirectorRetryAfterMs } from './mobile-relay-resume-director' import { RelayCredentialEligibility } from './relay-credential-eligibility' import { RelayPairingRejectionLatch } from './relay-pairing-rejection-latch' import { RelayRecoveryFailureCount } from './relay-recovery-failure-count' @@ -246,10 +247,9 @@ export class RelayReconnectController { // replacement dial is not revocation — banking it would fire a false re-pair alarm // the moment that healthy session drops for an unrelated transport error. this.pairingRejection.record(this.activeSession ? null : error) - const code = error instanceof RelayOuterError ? error.code : null const recovery = - code != null && isMobileRelayCloseCode(code) - ? mobileRelayRecoveryFor(code, 'phone-resume') + error instanceof RelayOuterError && isMobileRelayCloseCode(error.code) + ? mobileRelayRecoveryFor(error.code, 'phone-resume') : null if ( this.recoveryGate === 'fresh-credential' || @@ -264,10 +264,12 @@ export class RelayReconnectController { const failureCount = this.failureCount.recordAfterConnection(this.activeRelayConnectedAt, now) // Why: only a stable authenticated Relay resets the failure streak. this.activeRelayConnectedAt = null + // Why: a director Retry-After is the server pacing a bounded overload, and it + // only reaches this branch — a director HTTP error carries no relay close code. const delay = recovery?.kind === 'retry-after-host-offline' ? this.delays.hostOfflineDelayMs() - : this.delays.transportDelayMs(failureCount) + : this.delays.transportDelayMs(failureCount, relayDirectorRetryAfterMs(error)) this.nextAttemptAt = now + delay if (error instanceof MobileE2EEAuthenticationError) { // Why: an E2EE rejection is usually pairing revocation, but it also fires diff --git a/mobile/src/transport/mobile-relay-resume-director.test.ts b/mobile/src/transport/mobile-relay-resume-director.test.ts index 426ff89ba07..4bacb6759cb 100644 --- a/mobile/src/transport/mobile-relay-resume-director.test.ts +++ b/mobile/src/transport/mobile-relay-resume-director.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { resolveMobileRelayEndpoint } from './mobile-relay-resume-director' +import { RelayDirectorHttpError, resolveMobileRelayEndpoint } from './mobile-relay-resume-director' const relay = { v: 1 as const, @@ -63,4 +63,40 @@ describe('mobile relay resume director', () => { resolveMobileRelayEndpoint({ relay, resumeToken: 'A'.repeat(43), fetchImpl: oversized }) ).rejects.toThrow(/too large/) }) + + it('carries the bounded-overload Retry-After off a rejected resolve', async () => { + const resolveWith = (headers: Record) => + resolveMobileRelayEndpoint({ + relay, + resumeToken: 'A'.repeat(43), + fetchImpl: vi.fn(async () => new Response(null, { status: 503, headers })) + }) + + await expect(resolveWith({ 'retry-after': '30' })).rejects.toMatchObject({ + status: 503, + retryAfterMs: 30_000, + message: 'relay director resolve failed (503)' + }) + // An HTTP-date form resolves against the clock, and both forms clamp. + const httpDate = new Date(Date.now() + 10 * 60_000).toUTCString() + await expect(resolveWith({ 'retry-after': httpDate })).rejects.toMatchObject({ + retryAfterMs: 120_000 + }) + await expect(resolveWith({ 'retry-after': '999999' })).rejects.toMatchObject({ + retryAfterMs: 120_000 + }) + // A past date or unparseable header leaves the local backoff in charge. + const pastDate = new Date(Date.now() - 10 * 60_000).toUTCString() + await expect(resolveWith({ 'retry-after': pastDate })).rejects.toMatchObject({ + retryAfterMs: null + }) + await expect(resolveWith({ 'retry-after': 'soon-ish' })).rejects.toMatchObject({ + retryAfterMs: null + }) + await expect(resolveWith({})).rejects.toMatchObject({ + status: 503, + retryAfterMs: null + }) + await expect(resolveWith({})).rejects.toBeInstanceOf(RelayDirectorHttpError) + }) }) diff --git a/mobile/src/transport/mobile-relay-resume-director.ts b/mobile/src/transport/mobile-relay-resume-director.ts index 86fd283b9a5..56813133757 100644 --- a/mobile/src/transport/mobile-relay-resume-director.ts +++ b/mobile/src/transport/mobile-relay-resume-director.ts @@ -1,7 +1,28 @@ import { z } from 'zod' +import { parseRelayRetryAfterMs } from '../../../src/shared/relay-retry-after-header' +import { MOBILE_RELAY_RETRY_AFTER_MAX_MS } from './mobile-relay-retry-delays' import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract' const MAX_RESPONSE_BYTES = 16 * 1024 + +// Carries the director's own pacing so the reconnect cooldown can honor a +// bounded-overload 503 instead of re-dialing on the local backoff. +export class RelayDirectorHttpError extends Error { + readonly name = 'RelayDirectorHttpError' + + constructor( + readonly status: number, + readonly retryAfterMs: number | null + ) { + super(`relay director resolve failed (${status})`) + } +} + +// 0 when the director asked for nothing, so it only ever floors a local backoff. +export function relayDirectorRetryAfterMs(error: Error | null): number { + return error instanceof RelayDirectorHttpError ? (error.retryAfterMs ?? 0) : 0 +} + const ResolveResponseSchema = z .object({ v: z.literal(1), @@ -32,7 +53,10 @@ export async function resolveMobileRelayEndpoint(args: { signal: controller.signal }) if (!response.ok) { - throw new Error(`relay director resolve failed (${response.status})`) + throw new RelayDirectorHttpError( + response.status, + parseRelayRetryAfterMs(response.headers.get('retry-after'), MOBILE_RELAY_RETRY_AFTER_MAX_MS) + ) } const declaredLength = Number(response.headers.get('content-length') ?? 0) if (declaredLength > MAX_RESPONSE_BYTES) { diff --git a/mobile/src/transport/mobile-relay-retry-delays.ts b/mobile/src/transport/mobile-relay-retry-delays.ts index 6ff145acdb4..a1c68cc9a15 100644 --- a/mobile/src/transport/mobile-relay-retry-delays.ts +++ b/mobile/src/transport/mobile-relay-retry-delays.ts @@ -12,17 +12,21 @@ const RELAY_HOST_OFFLINE_RETRY_MAX_MS = 15_000 // forever one-minute beacon and a fleet-wide gating event cannot phase-align. const RELAY_GATE_REPROBE_BASE_MS = 60_000 const RELAY_GATE_REPROBE_CEILING_MS = 15 * 60_000 +// A director Retry-After only ever floors a delay, so cap what one header can +// park the phone for; anything longer is the gated reprobe cadence's job. +export const MOBILE_RELAY_RETRY_AFTER_MAX_MS = 120_000 // Every relay retry delay in one place: transport backoff, known-offline // polling, and the escalating gated reprobe cadence, all jittered. export class RelayRetryDelays { constructor(private readonly randomBytes: (length: number) => Uint8Array) {} - transportDelayMs(consecutiveFailures: number): number { + // floorMs: a server-supplied Retry-After, which paces the dial out but never in. + transportDelayMs(consecutiveFailures: number, floorMs = 0): number { const exponent = Math.max(0, consecutiveFailures - 1) const cap = Math.min(RELAY_BACKOFF_CEILING_MS, RELAY_BACKOFF_BASE_MS * 2 ** exponent) // Full jitter (uniform in [0, cap)), floored so retries never busy-loop. - return Math.max(RELAY_BACKOFF_MIN_MS, Math.floor(cap * this.jitterFraction())) + return Math.max(RELAY_BACKOFF_MIN_MS, floorMs, Math.floor(cap * this.jitterFraction())) } hostOfflineDelayMs(): number { diff --git a/mobile/src/transport/pairing-candidate-race.ts b/mobile/src/transport/pairing-candidate-race.ts index 3b754b6ea49..7b34c38b1e3 100644 --- a/mobile/src/transport/pairing-candidate-race.ts +++ b/mobile/src/transport/pairing-candidate-race.ts @@ -1,7 +1,9 @@ import type { PairingCandidateClient } from './mobile-relay-physical-client' +export type PairingCandidatePath = 'direct' | 'relay' + export type PairingCandidate = { - path: 'direct' | 'relay' + path: PairingCandidatePath client: PairingCandidateClient } diff --git a/mobile/src/transport/pairing-log-path.test.ts b/mobile/src/transport/pairing-log-path.test.ts new file mode 100644 index 00000000000..d0da2520b84 --- /dev/null +++ b/mobile/src/transport/pairing-log-path.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { attributePairingLogPath } from './pairing-log-path' +import type { ConnectionLogEntry } from './types' + +const entry: ConnectionLogEntry = { + id: 'log-1', + ts: 1, + level: 'warn', + message: 'Reconnecting (attempt 2)', + detail: '10.5.0.2:6768' +} + +describe('attributePairingLogPath', () => { + it('prefixes unlabelled lines with their pairing path', () => { + const entries: ConnectionLogEntry[] = [] + attributePairingLogPath('direct', (value) => entries.push(value))!(entry) + + expect(entries).toEqual([{ ...entry, message: 'Direct: Reconnecting (attempt 2)' }]) + }) + + it('leaves a line that already names its path unchanged', () => { + const entries: ConnectionLogEntry[] = [] + const relayEntry = { ...entry, message: 'Relay: cell dial failed' } + attributePairingLogPath('relay', (value) => entries.push(value))!(relayEntry) + + expect(entries).toEqual([relayEntry]) + }) + + it('labels relay lines that forgot their own prefix', () => { + const entries: ConnectionLogEntry[] = [] + attributePairingLogPath('relay', (value) => entries.push(value))!(entry) + + expect(entries[0]!.message).toBe('Relay: Reconnecting (attempt 2)') + }) + + it('stays undefined when there is no sink to attribute to', () => { + expect(attributePairingLogPath('direct', undefined)).toBeUndefined() + }) +}) diff --git a/mobile/src/transport/pairing-log-path.ts b/mobile/src/transport/pairing-log-path.ts new file mode 100644 index 00000000000..d88865db554 --- /dev/null +++ b/mobile/src/transport/pairing-log-path.ts @@ -0,0 +1,27 @@ +import type { PairingCandidatePath } from './pairing-candidate-race' +import type { ConnectionLogSink } from './types' + +const PATH_PREFIX: Record = { + direct: 'Direct: ', + relay: 'Relay: ' +} + +// Why: the direct and relay candidates race into one pairing log, and only the +// relay call sites prefixed themselves — so unlabelled direct lines +// ("Reconnecting (attempt 2)", detail 10.5.0.2:6768) read as the relay +// retrying a LAN address. Attributing at the candidate seam covers every line +// each path emits, including ones whose call site forgets to say which it is. +export function attributePairingLogPath( + path: PairingCandidatePath, + onLog: ConnectionLogSink | undefined +): ConnectionLogSink | undefined { + if (!onLog) { + return undefined + } + const prefix = PATH_PREFIX[path] + return (entry) => { + onLog( + entry.message.startsWith(prefix) ? entry : { ...entry, message: `${prefix}${entry.message}` } + ) + } +} diff --git a/mobile/src/transport/pairing-relay-candidate.test.ts b/mobile/src/transport/pairing-relay-candidate.test.ts index 5586ee7b801..dbab0bad2d5 100644 --- a/mobile/src/transport/pairing-relay-candidate.test.ts +++ b/mobile/src/transport/pairing-relay-candidate.test.ts @@ -164,8 +164,11 @@ describe('recovering pairing relay candidate', () => { ) }) - it('does not retry a same-epoch move to a different cell', async () => { + it('retries the stored assignment when the director reports a non-newer different cell', async () => { const stale = client(Promise.reject(new RelayOuterError(1006))) + const target = client(Promise.resolve(success())) + const persistMove = vi.fn() + const sleep = vi.fn(async () => {}) const resolveDirector = vi.fn(async (relay) => { throw new RelayDirectorMoveNotNewerError({ cellUrl: 'https://relay-c2.onorca.dev', @@ -175,6 +178,37 @@ describe('recovering pairing relay candidate', () => { }) }) let connects = 0 + const candidate = createRecoveringPairingRelayCandidate({ + journal, + connect: (relay) => { + expect(relay.cellUrl).toBe(journal.metadata.relay.cellUrl) + return connects++ === 0 ? stale : target + }, + resolveDirector, + persistMove, + now: () => 1, + random: () => 0, + sleep + }) + + await expect(candidate.sendRequest('status.get')).resolves.toEqual(success()) + expect(resolveDirector).toHaveBeenCalledOnce() + // Why: the non-newer move must never be adopted — the stored cell is redialed. + expect(persistMove).not.toHaveBeenCalled() + expect(sleep).toHaveBeenCalledWith(250) + }) + + it('gives up with the dial error once same-assignment retries exhaust the budget', async () => { + const stale = client(Promise.reject(new RelayOuterError(1006))) + const resolveDirector = vi.fn(async (relay) => { + throw new RelayDirectorMoveNotNewerError({ + cellUrl: 'https://relay-c2.onorca.dev', + assignmentEpoch: relay.assignmentEpoch - 1, + currentCellUrl: relay.cellUrl, + currentAssignmentEpoch: relay.assignmentEpoch + }) + }) + let connects = 0 const candidate = createRecoveringPairingRelayCandidate({ journal, connect: () => { @@ -188,8 +222,30 @@ describe('recovering pairing relay candidate', () => { sleep: async () => {} }) - await expect(candidate.sendRequest('status.get')).rejects.toThrow(/not strictly newer/) + await expect(candidate.sendRequest('status.get')).rejects.toEqual(new RelayOuterError(1006)) expect(resolveDirector).toHaveBeenCalledTimes(3) + expect(connects).toBe(4) + }) + + it('keeps a cell load refusal out of director recovery', async () => { + const limited = client(Promise.reject(new RelayOuterError(4429))) + const resolveDirector = vi.fn() + let connects = 0 + const candidate = createRecoveringPairingRelayCandidate({ + journal, + connect: () => { + connects++ + return limited + }, + resolveDirector, + persistMove: vi.fn(), + now: () => 1 + }) + + // Why: 4429 is rejected after the cell reserves the invite credential, so a + // retry burns an attempt — and a director hop cannot relieve cell load. + await expect(candidate.sendRequest('status.get')).rejects.toEqual(new RelayOuterError(4429)) + expect(resolveDirector).not.toHaveBeenCalled() expect(connects).toBe(1) }) diff --git a/mobile/src/transport/pairing-relay-candidate.ts b/mobile/src/transport/pairing-relay-candidate.ts index 5158bc1e26d..c4a0a0c91c5 100644 --- a/mobile/src/transport/pairing-relay-candidate.ts +++ b/mobile/src/transport/pairing-relay-candidate.ts @@ -49,9 +49,9 @@ export function createRecoveringPairingRelayCandidate(args: { const random = args.random ?? Math.random const sleep = args.sleep ?? ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))) - const backOff = async (attempt: number): Promise => { + const backOff = async (attempt: number, floorMs = 0): Promise => { const capMs = Math.min(2_000, 100 * 2 ** attempt) - const delayMs = Math.floor(random() * (capMs + 1)) + const delayMs = Math.max(floorMs, Math.floor(random() * (capMs + 1))) if (delayMs > 0) { log('info', `Relay: backing off ${delayMs}ms`) } @@ -89,14 +89,20 @@ export function createRecoveringPairingRelayCandidate(args: { return await client.sendRequest(method, params) } catch (error) { lastError = error - if (isCurrentAssignmentMove(error, relay)) { + if (error instanceof RelayDirectorMoveNotNewerError) { + // Why: the director has no "assignment unchanged" verb — a non-newer + // move IS that answer. The stored assignment stays authoritative (the + // move is never adopted or persisted), so the only correct action is + // to re-dial it with a real pause, not to abandon the relay path. log( 'info', - 'Relay: director kept current assignment', + directorEchoedCurrentAssignment(error, relay) + ? 'Relay: director kept current assignment' + : 'Relay: director has no newer assignment', redactSocketEndpoint(relay.cellUrl) ) client.close() - await backOff(attempt) + await backOff(attempt, NOT_NEWER_RETRY_FLOOR_MS) if (closed) { throw new Error('relay pairing client closed') } @@ -130,7 +136,11 @@ export function createRecoveringPairingRelayCandidate(args: { } } -function isCurrentAssignmentMove(error: unknown, relay: PairingRelay): boolean { +// Redialing the assignment the director just confirmed needs a real pause, not +// the near-zero full jitter that would hammer the cell that refused the dial. +const NOT_NEWER_RETRY_FLOOR_MS = 250 + +function directorEchoedCurrentAssignment(error: unknown, relay: PairingRelay): boolean { return ( error instanceof RelayDirectorMoveNotNewerError && error.assignmentEpoch === relay.assignmentEpoch && @@ -151,5 +161,10 @@ function isDirectorRecoverable(error: unknown): boolean { if (!(error instanceof RelayOuterError)) { return true } + // 4429 stays out: the cell rejects it after reserving the invite credential, + // so each retry burns an attempt toward cooldown/invalidation — and a + // director hop cannot relieve cell load anyway. The retained codes are + // rejected before reservation (4409/4503) or never reach the cell (1006), + // so their re-dials burn nothing. return error.code === 4409 || error.code === 4503 || error.code === 1006 } diff --git a/mobile/src/transport/pre-profile-pairing-coordinator.test.ts b/mobile/src/transport/pre-profile-pairing-coordinator.test.ts index e5951dbdc38..1729ae65dc7 100644 --- a/mobile/src/transport/pre-profile-pairing-coordinator.test.ts +++ b/mobile/src/transport/pre-profile-pairing-coordinator.test.ts @@ -4,7 +4,7 @@ import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' import { racePairingCandidates } from './pairing-candidate-race' import { startPreProfilePairing } from './pre-profile-pairing-coordinator' import type { ConnectionLogEntry, HostProfile, PairingOffer, RpcResponse } from './types' -import type { RpcClient } from './rpc-client' +import type { connect, RpcClient } from './rpc-client' vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) vi.mock('expo-crypto', () => ({ @@ -97,7 +97,9 @@ function dependencies(client: RpcClient, events: string[]) { new Error('relay unavailable') ) return { - connectDirect: vi.fn(() => (events.push('connect'), client)), + connectDirect: vi.fn( + (..._args: Parameters) => (events.push('connect'), client) + ), connectRelay: vi.fn(() => unavailableRelay), resolveInviteDirector: vi.fn(async () => { throw new Error('director unavailable') @@ -382,6 +384,62 @@ describe('pre-profile pairing coordinator', () => { expect(entries[2]).toMatchObject({ level: 'success', detail: 'winner: relay' }) }) + it('attributes each racing candidate so direct retries cannot read as relay', async () => { + const entries: ConnectionLogEntry[] = [] + const direct = fakeClient([]) + ;(direct.sendRequest as ReturnType).mockRejectedValue(new Error('LAN down')) + let journal: MobileRelayPairingJournal | null = null + const relay = relayProvisioningClient(() => journal!) + const deps = dependencies(direct, []) + deps.saveJournal.mockImplementation(async (value) => { + journal = value + }) + // Why: the reconnect lines the direct client emits carry the LAN address + // and no path label — the exact shape that was misread as relay retrying. + deps.connectDirect.mockImplementation((...args) => { + const options = args[3] + if (options && typeof options !== 'function') { + options.onLog?.({ + id: 'direct-reconnect', + ts: now, + level: 'warn', + message: 'Reconnecting (attempt 2)', + detail: '10.5.0.2:6768' + }) + } + return direct + }) + deps.connectRelay.mockImplementation((connectArgs) => { + connectArgs.onLog?.({ + id: 'relay-dial', + ts: now, + level: 'info', + message: 'Relay: dialing cell', + detail: 'relay-c1.onorca.dev' + }) + connectArgs.onLog?.({ id: 'relay-open', ts: now, level: 'info', message: 'Cell socket open' }) + return relay + }) + + const attempt = startPreProfilePairing({ + offer: relayOffer, + timeoutMs: 5_000, + connectOptions: { onLog: (entry) => entries.push(entry) }, + dependencies: deps + }) + await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` }) + + expect(entries.map((entry) => entry.message)).toEqual([ + 'Direct: Reconnecting (attempt 2)', + 'Relay: pairing candidate started', + // Already self-labelled: attribution must not stutter into 'Relay: Relay:'. + 'Relay: dialing cell', + 'Relay: Cell socket open', + 'Pairing path selected' + ]) + expect(entries[0]).toMatchObject({ level: 'warn', detail: '10.5.0.2:6768' }) + }) + it('cancels the disposable physical client without publishing a host', async () => { let resolveStatus!: (response: RpcResponse) => void const status = new Promise((resolve) => { diff --git a/mobile/src/transport/pre-profile-pairing-coordinator.ts b/mobile/src/transport/pre-profile-pairing-coordinator.ts index 7518e68e2b7..a9b04320471 100644 --- a/mobile/src/transport/pre-profile-pairing-coordinator.ts +++ b/mobile/src/transport/pre-profile-pairing-coordinator.ts @@ -26,6 +26,7 @@ import { type PairingCandidateClient } from './mobile-relay-physical-client' import { racePairingCandidates, type PairingCandidate } from './pairing-candidate-race' +import { attributePairingLogPath } from './pairing-log-path' import { resolvePairingInviteThroughDirector } from './mobile-relay-invite-director' import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate' import { createPairingRelayLogger } from './pairing-relay-log' @@ -155,7 +156,7 @@ async function runPairing( offer.endpoint, offer.deviceToken, offer.publicKeyB64, - connectOptions + { ...connectOptions, onLog: attributePairingLogPath('direct', connectOptions?.onLog) } ) clients.add(directClient) const candidates: PairingCandidate[] = [{ path: 'direct', client: directClient }] @@ -191,7 +192,7 @@ async function runPairing( await dependencies.updateJournal(journal.metadata.journalId, () => journal!.metadata) }, now: dependencies.now, - onLog: connectOptions?.onLog + onLog: attributePairingLogPath('relay', connectOptions?.onLog) }) clients.add(relayClient) candidates.push({ path: 'relay', client: relayClient }) diff --git a/src/main/runtime/relay/relay-assign-rate-gate.ts b/src/main/runtime/relay/relay-assign-rate-gate.ts new file mode 100644 index 00000000000..b887873bbbb --- /dev/null +++ b/src/main/runtime/relay/relay-assign-rate-gate.ts @@ -0,0 +1,134 @@ +// Why: the director rate-limits /v1/assign per host at 5s, but every desktop +// retry path (coordinator full-jitter, drain full-jitter, reconcile() cancelling +// the Retry-After timer) can fire immediately, so hosts sit permanently limited. +// The gate lives below all of them, at the single call site that issues assigns. +const ASSIGN_MIN_INTERVAL_MS = 5_000 +const ASSIGN_INTERVAL_JITTER_MS = 500 +// Sleep in slices so a raise landing mid-wait is honored and a superseded +// caller aborts promptly instead of holding its IPC path for the full wait. +const ASSIGN_WAIT_SLICE_MS = 1_000 +// Beyond this, fail fast with the remaining wait instead of parking the caller +// (pairing IPC awaits reconcile inline): a Retry-After can legitimately reach +// minutes, and the gate keeps the deadline for the scheduled retry. +const ASSIGN_MAX_INLINE_WAIT_MS = 15_000 + +export class RelayAssignAbortedError extends Error { + constructor() { + super('relay_assignment_aborted_stale') + } +} + +export class RelayAssignRateLimitedError extends Error { + constructor(readonly retryAfterMs: number) { + super('relay_assignment_rate_limited_locally') + } +} + +export type RelayAssignRateGateOptions = { + now?: () => number + // Test fakes MUST advance `now` when sleeping — the wait loop re-reads the + // clock each slice, so a no-op sleep against a frozen clock never terminates. + sleep?: (ms: number) => Promise + random?: () => number +} + +export function relayAssignRateKey(directorUrl: string, relayHostId: string): string { + return `${directorUrl} ${relayHostId}` +} + +export class RelayAssignRateGate { + private readonly now: () => number + private readonly sleep: (ms: number) => Promise + private readonly random: () => number + private readonly nextPermittedAt = new Map() + // Per-key promise chain: concurrent callers queue behind each other's booking + // instead of stampeding out of one shared sleep. + private readonly tails = new Map>() + + constructor(options: RelayAssignRateGateOptions = {}) { + this.now = options.now ?? Date.now + this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))) + this.random = options.random ?? Math.random + } + + get trackedKeyCount(): number { + return this.nextPermittedAt.size + } + + // Waits out the remaining interval for this host, then books the next slot. + // Resolves only when the caller may send; throws if isCurrent went false while waiting. + async reserve(key: string, isCurrent?: () => boolean): Promise { + const prior = this.tails.get(key) + let release = (): void => {} + const link = new Promise((resolve) => { + release = resolve + }) + const tail = prior ? prior.then(() => link) : link + this.tails.set(key, tail) + let stale = false + try { + await prior + // Re-read the deadline every slice: a sibling's Retry-After can raise it + // mid-wait, and a caller superseded mid-wait must not spend the slot. + for (;;) { + stale = isCurrent ? !isCurrent() : false + if (stale) { + break + } + const waitMs = (this.nextPermittedAt.get(key) ?? 0) - this.now() + if (waitMs <= 0) { + break + } + if (waitMs > ASSIGN_MAX_INLINE_WAIT_MS) { + this.pruneExpired() + throw new RelayAssignRateLimitedError(waitMs) + } + await this.sleep(Math.min(waitMs, ASSIGN_WAIT_SLICE_MS)) + } + if (!stale) { + this.book(key) + } + this.pruneExpired() + } finally { + release() + if (this.tails.get(key) === tail) { + this.tails.delete(key) + } + } + if (stale) { + throw new RelayAssignAbortedError() + } + } + + // Retry-After outlives the coordinator's armed timer, which reconcile() cancels. + noteRetryAfter(key: string, retryAfterMs: number): void { + if (retryAfterMs <= 0) { + return + } + const until = this.now() + retryAfterMs + if (until > (this.nextPermittedAt.get(key) ?? 0)) { + this.nextPermittedAt.set(key, until) + } + } + + private book(key: string): void { + const until = + this.now() + ASSIGN_MIN_INTERVAL_MS + Math.floor(this.random() * ASSIGN_INTERVAL_JITTER_MS) + if (until > (this.nextPermittedAt.get(key) ?? 0)) { + this.nextPermittedAt.set(key, until) + } + } + + private pruneExpired(): void { + const now = this.now() + for (const [key, until] of this.nextPermittedAt) { + if (until <= now) { + this.nextPermittedAt.delete(key) + } + } + } +} + +// Shared so every assign path — coordinator, drain recovery, any reconcile() +// bypass — books against the same per-host interval. +export const sharedRelayAssignRateGate = new RelayAssignRateGate() diff --git a/src/main/runtime/relay/relay-http-client.test.ts b/src/main/runtime/relay/relay-http-client.test.ts index c90592be69b..e71aa060408 100644 --- a/src/main/runtime/relay/relay-http-client.test.ts +++ b/src/main/runtime/relay/relay-http-client.test.ts @@ -1,9 +1,57 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import nacl from 'tweetnacl' import { cancelTrackingResponse } from '../../lib/unread-response-body.test-fixtures' -import { exchangeRelayAuthorization, requestRelayAssignment } from './relay-http-client' +import { + RelayAssignAbortedError, + RelayAssignRateGate, + sharedRelayAssignRateGate +} from './relay-assign-rate-gate' +import { + exchangeRelayAuthorization, + requestRelayAssignment, + shouldRetryRelayConnectionError +} from './relay-http-client' + +type AssignInput = Parameters[0] + +function assignSuccessResponse(): Response { + return Response.json({ + v: 1, + cellUrl: 'https://relay-c1.example', + assignmentEpoch: 4, + lease: 'lease-jwt' + }) +} + +// Advances only when the gate sleeps, so a wait is observable as an exact duration. +function fakeAssignClock(onSleep?: () => void) { + let now = 1_000_000 + const sleeps: number[] = [] + return { + sleeps, + advance: (ms: number): void => { + now += ms + }, + options: { + now: () => now, + random: () => 0, + sleep: async (ms: number): Promise => { + sleeps.push(ms) + now += ms + onSleep?.() + } + } + } +} describe('relay HTTP client', () => { + let gate = new RelayAssignRateGate() + beforeEach(() => { + gate = new RelayAssignRateGate() + }) + const assign = (input: Omit) => + requestRelayAssignment({ ...input, assignRateGate: gate }) + it('exchanges only the ordinary bearer for a host-bound relay token', async () => { const keypair = nacl.box.keyPair() const fetch = vi.fn(async () => @@ -42,7 +90,7 @@ describe('relay HTTP client', () => { }) ) await expect( - requestRelayAssignment({ + assign({ directorUrl: 'https://relay.example', relayToken: 'scoped-token', relayHostId: 'AbCdEf0123_-xyZ9', @@ -66,7 +114,7 @@ describe('relay HTTP client', () => { }) ) await expect( - requestRelayAssignment({ + assign({ directorUrl: 'https://relay.example', relayToken: 'scoped-token', relayHostId: 'AbCdEf0123_-xyZ9', @@ -95,7 +143,7 @@ describe('relay HTTP client', () => { ) await expect( - requestRelayAssignment({ + assign({ directorUrl: 'https://relay.example', relayToken: 'scoped-token', relayHostId: 'AbCdEf0123_-xyZ9', @@ -130,7 +178,7 @@ describe('relay HTTP client', () => { }) ) await expect( - requestRelayAssignment({ + assign({ directorUrl: 'https://relay.example', relayToken: 'scoped-token', relayHostId: 'AbCdEf0123_-xyZ9', @@ -154,7 +202,7 @@ describe('relay HTTP client', () => { ) await expect( - requestRelayAssignment({ + assign({ directorUrl: 'https://relay.example', relayToken: 'scoped-token', relayHostId: 'AbCdEf0123_-xyZ9', @@ -197,7 +245,7 @@ describe('relay HTTP client', () => { }) ) await expect( - requestRelayAssignment({ + assign({ directorUrl: 'https://relay.example', relayToken: 'scoped-token', relayHostId: 'AbCdEf0123_-xyZ9', @@ -227,7 +275,7 @@ describe('relay HTTP client', () => { }) ).rejects.toThrow() await expect( - requestRelayAssignment({ + assign({ directorUrl: 'https://relay.example', relayToken: 'scoped-token', relayHostId: 'AbCdEf0123_-xyZ9', @@ -243,7 +291,7 @@ describe('relay HTTP client', () => { ) await expect( - requestRelayAssignment({ + assign({ directorUrl: 'https://relay.example', relayToken: 'scoped-token', relayHostId: 'AbCdEf0123_-xyZ9', @@ -262,7 +310,7 @@ describe('relay HTTP client', () => { ) await expect( - requestRelayAssignment({ + assign({ directorUrl: 'https://relay.example', relayToken: 'scoped-token', relayHostId: 'AbCdEf0123_-xyZ9', @@ -271,3 +319,202 @@ describe('relay HTTP client', () => { ).rejects.toMatchObject({ retryAfterMs: 5 * 60_000 }) }) }) + +describe('relay assignment rate gate', () => { + const request = ( + input: Partial & Pick + ): Promise => + requestRelayAssignment({ + directorUrl: 'https://relay.example', + relayToken: 'scoped-token', + relayHostId: 'AbCdEf0123_-xyZ9', + ...input + }) + + it('holds a second assignment for the same host to the director interval', async () => { + const clock = fakeAssignClock() + const assignRateGate = new RelayAssignRateGate(clock.options) + const fetch = vi.fn(async () => assignSuccessResponse()) + + await request({ fetch, assignRateGate }) + expect(clock.sleeps).toEqual([]) + clock.advance(2_000) + await request({ fetch, assignRateGate }) + + expect(clock.sleeps).toEqual([1_000, 1_000, 1_000]) + expect(fetch).toHaveBeenCalledTimes(2) + }) + + it('does not hold assignments for a different host or a different director', async () => { + const clock = fakeAssignClock() + const assignRateGate = new RelayAssignRateGate(clock.options) + const fetch = vi.fn(async () => assignSuccessResponse()) + + await request({ fetch, assignRateGate }) + await request({ fetch, assignRateGate, relayHostId: 'OtherHost01234_x' }) + await request({ fetch, assignRateGate, directorUrl: 'https://relay-eu.example' }) + + expect(clock.sleeps).toEqual([]) + }) + + it('serializes concurrent callers behind one booking instead of stampeding', async () => { + const clock = fakeAssignClock() + const assignRateGate = new RelayAssignRateGate(clock.options) + const fetch = vi.fn(async () => assignSuccessResponse()) + + await Promise.all([ + request({ fetch, assignRateGate }), + request({ fetch, assignRateGate }), + request({ fetch, assignRateGate }) + ]) + + expect(clock.sleeps).toEqual(Array.from({ length: 10 }, () => 1_000)) + }) + + it('keeps a Retry-After hint past the retry timer the coordinator cancels', async () => { + const clock = fakeAssignClock() + const assignRateGate = new RelayAssignRateGate(clock.options) + const fetch = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 429, headers: { 'retry-after': '30' } })) + .mockResolvedValueOnce(assignSuccessResponse()) + + await expect(request({ fetch, assignRateGate })).rejects.toThrow('relay_assignment_failed_429') + // A reconcile() cancels the armed retry timer and resets attempts; only the + // gate still remembers what the director asked for. A wait beyond the + // inline cap fails fast with the remainder instead of parking the caller + // (pairing IPC awaits reconcile inline). + clock.advance(6_000) + await expect(request({ fetch, assignRateGate })).rejects.toMatchObject({ + statusCode: 429, + retryAfterMs: 24_000 + }) + expect(clock.sleeps).toEqual([]) + expect(fetch).toHaveBeenCalledTimes(1) + + clock.advance(25_000) + await request({ fetch, assignRateGate }) + expect(fetch).toHaveBeenCalledTimes(2) + }) + + it('honors a deadline raise that lands mid-wait', async () => { + const key = 'https://relay.example AbCdEf0123_-xyZ9' + let raised = false + const clock = fakeAssignClock(() => { + if (!raised) { + raised = true + assignRateGate.noteRetryAfter(key, 10_000) + } + }) + const assignRateGate = new RelayAssignRateGate(clock.options) + + await assignRateGate.reserve(key) + await assignRateGate.reserve(key) + + // First slice consumes 1s of the 5s interval; the raise then owes 10s more. + expect(clock.sleeps.reduce((sum, ms) => sum + ms, 0)).toBe(11_000) + }) + + it('routes ungated callers through the shared process-wide gate', async () => { + const reserve = vi.spyOn(sharedRelayAssignRateGate, 'reserve').mockResolvedValueOnce() + const fetch = vi.fn(async () => assignSuccessResponse()) + + await requestRelayAssignment({ + directorUrl: 'https://relay.example', + relayToken: 'scoped-token', + relayHostId: 'AbCdEf0123_-xyZ9', + fetch + }) + + expect(reserve).toHaveBeenCalledWith('https://relay.example AbCdEf0123_-xyZ9', undefined) + reserve.mockRestore() + }) + + it('classifies a staleness abort as non-retryable', () => { + expect(shouldRetryRelayConnectionError(new RelayAssignAbortedError())).toBe(false) + expect(shouldRetryRelayConnectionError(new Error('socket hang up'))).toBe(true) + }) + + it('does not re-wait for the field-fallback retries of one attempt', async () => { + const clock = fakeAssignClock() + const assignRateGate = new RelayAssignRateGate(clock.options) + const fetch = vi + .fn() + .mockResolvedValueOnce(Response.json({ error: 'invalid_request' }, { status: 400 })) + .mockResolvedValueOnce(Response.json({ error: 'invalid_request' }, { status: 400 })) + .mockResolvedValueOnce(assignSuccessResponse()) + + await request({ fetch, assignRateGate, reconnect: true, preferredRegion: 'asia-east2' }) + + expect(fetch).toHaveBeenCalledTimes(3) + expect(clock.sleeps).toEqual([]) + }) + + it('aborts before sending when the caller is superseded after reservation', async () => { + const clock = fakeAssignClock() + const assignRateGate = new RelayAssignRateGate(clock.options) + const fetch = vi.fn(async () => assignSuccessResponse()) + // First reads happen inside reserve(); the last read guards the send. + const currency = [true, false] + + await expect( + request({ fetch, assignRateGate, isCurrent: () => currency.shift() ?? false }) + ).rejects.toBeInstanceOf(RelayAssignAbortedError) + + expect(fetch).not.toHaveBeenCalled() + }) + + it('stops field-fallback retries when the caller is superseded mid-attempt', async () => { + const clock = fakeAssignClock() + const assignRateGate = new RelayAssignRateGate(clock.options) + let current = true + const fetch = vi.fn(async () => { + current = false + return Response.json({ error: 'invalid_request' }, { status: 400 }) + }) + + await expect( + request({ + fetch, + assignRateGate, + reconnect: true, + preferredRegion: 'asia-east2', + isCurrent: () => current + }) + ).rejects.toBeInstanceOf(RelayAssignAbortedError) + + expect(fetch).toHaveBeenCalledTimes(1) + }) + + it('aborts without assigning when the caller is superseded during the wait', async () => { + let current = true + const clock = fakeAssignClock(() => { + current = false + }) + const assignRateGate = new RelayAssignRateGate(clock.options) + const fetch = vi.fn(async () => assignSuccessResponse()) + + await request({ fetch, assignRateGate }) + await expect( + request({ fetch, assignRateGate, isCurrent: () => current }) + ).rejects.toBeInstanceOf(RelayAssignAbortedError) + + expect(clock.sleeps).toEqual([1_000]) + expect(fetch).toHaveBeenCalledTimes(1) + }) + + it('prunes hosts whose interval has elapsed so the map stays bounded', async () => { + const clock = fakeAssignClock() + const assignRateGate = new RelayAssignRateGate(clock.options) + + await assignRateGate.reserve('https://relay.example host-a') + await assignRateGate.reserve('https://relay.example host-b') + await assignRateGate.reserve('https://relay.example host-c') + expect(assignRateGate.trackedKeyCount).toBe(3) + + clock.advance(6_000) + await assignRateGate.reserve('https://relay.example host-d') + + expect(assignRateGate.trackedKeyCount).toBe(1) + }) +}) diff --git a/src/main/runtime/relay/relay-http-client.ts b/src/main/runtime/relay/relay-http-client.ts index c1ffba04eb2..b31fe2ff9da 100644 --- a/src/main/runtime/relay/relay-http-client.ts +++ b/src/main/runtime/relay/relay-http-client.ts @@ -2,6 +2,14 @@ import { createHash } from 'node:crypto' import { z } from 'zod' import type { E2EEKeypair } from '../e2ee-keypair' import { cancelUnreadResponseBody } from '../../lib/unread-response-body' +import { parseRelayRetryAfterMs } from '../../../shared/relay-retry-after-header' +import { + RelayAssignAbortedError, + RelayAssignRateLimitedError, + relayAssignRateKey, + sharedRelayAssignRateGate, + type RelayAssignRateGate +} from './relay-assign-rate-gate' import type { RelayRegion } from './relay-region-preference' const RELAY_HTTP_REQUEST_DEADLINE_MS = 15_000 @@ -42,19 +50,25 @@ export class RelayHttpError extends Error { } } -function relayRetryAfterMs(value: string | null, nowMs = Date.now()): number | null { - if (!value) { - return null +// Distinct message so log censuses can tell a locally-enforced wait from a real +// director 429 — one server 429 would otherwise print several identical lines. +export class RelayAssignLocallyPacedError extends RelayHttpError { + constructor(retryAfterMs: number) { + super('assignment', 429, retryAfterMs) + this.message = 'relay_assignment_locally_paced_429' } - const seconds = Number(value) - const delayMs = Number.isFinite(seconds) ? seconds * 1_000 : Date.parse(value) - nowMs - if (!Number.isFinite(delayMs) || delayMs <= 0) { - return null - } - return Math.min(RELAY_RETRY_AFTER_MAX_MS, Math.ceil(delayMs)) +} + +function relayRetryAfterMs(value: string | null): number | null { + return parseRelayRetryAfterMs(value, RELAY_RETRY_AFTER_MAX_MS) } export function shouldRetryRelayConnectionError(error: unknown): boolean { + // A staleness abort means the caller was superseded — retrying it would + // spend rate-gate slots on work whose owner is gone. + if (error instanceof RelayAssignAbortedError) { + return false + } if (!(error instanceof RelayHttpError)) { return true } @@ -113,7 +127,7 @@ export async function exchangeRelayAuthorization(input: { return parsed.data } -export async function requestRelayAssignment(input: { +type RelayAssignmentRequest = { directorUrl: string relayToken: string relayHostId: string @@ -121,10 +135,46 @@ export async function requestRelayAssignment(input: { preferredRegion?: RelayRegion fetch?: typeof globalThis.fetch requestDeadlineMs?: number -}): Promise { + // Fencing for the throttle wait: a superseded caller aborts instead of assigning. + isCurrent?: () => boolean + assignRateGate?: RelayAssignRateGate +} + +export async function requestRelayAssignment( + input: RelayAssignmentRequest +): Promise { if (!isAllowedRelayOrigin(input.directorUrl)) { throw new RelayHttpError('assignment', 400) } + const gate = input.assignRateGate ?? sharedRelayAssignRateGate + const rateKey = relayAssignRateKey(input.directorUrl, input.relayHostId) + try { + await gate.reserve(rateKey, input.isCurrent) + } catch (error) { + if (error instanceof RelayAssignRateLimitedError) { + // Surface a long local wait as the 429 it stands in for, so the existing + // schedulers pace with retryAfterMs instead of parking the caller inline. + // Reachable only while a director Retry-After beyond the inline cap is + // still in force — local booking alone never exceeds ~5.5s. + throw new RelayAssignLocallyPacedError(error.retryAfterMs) + } + throw error + } + return await sendRelayAssignment(input, gate, rateKey) +} + +// The field-fallback retries below are one logical attempt against one booked +// slot; re-entering the gate would stall rolled-back-director compatibility 5s. +async function sendRelayAssignment( + input: RelayAssignmentRequest, + gate: RelayAssignRateGate, + rateKey: string +): Promise { + // A caller superseded after reserving — or between field-fallback retries — + // must not spend more requests on an assignment nobody will consume. + if (input.isCurrent && !input.isCurrent()) { + throw new RelayAssignAbortedError() + } const response = await (input.fetch ?? globalThis.fetch)(`${input.directorUrl}/v1/assign`, { method: 'POST', headers: { @@ -143,15 +193,18 @@ export async function requestRelayAssignment(input: { }) if (!response.ok) { const retryAfterMs = relayRetryAfterMs(response.headers.get('retry-after')) + if (retryAfterMs !== null) { + gate.noteRetryAfter(rateKey, retryAfterMs) + } await cancelUnreadResponseBody(response) if (input.preferredRegion && response.status === 400) { // A rolled-back director rejects the regional hint; preserve the // reconnect lane while retrying without only that field. - return await requestRelayAssignment({ ...input, preferredRegion: undefined }) + return await sendRelayAssignment({ ...input, preferredRegion: undefined }, gate, rateKey) } if (input.reconnect && response.status === 400) { // A rolled-back director rejects unknown fields; retry once unhinted. - return await requestRelayAssignment({ ...input, reconnect: false }) + return await sendRelayAssignment({ ...input, reconnect: false }, gate, rateKey) } throw new RelayHttpError('assignment', response.status, retryAfterMs) } diff --git a/src/main/runtime/relay/relay-origin-pool.ts b/src/main/runtime/relay/relay-origin-pool.ts index 13f02a94075..a95d8f21340 100644 --- a/src/main/runtime/relay/relay-origin-pool.ts +++ b/src/main/runtime/relay/relay-origin-pool.ts @@ -171,6 +171,7 @@ export class RelayOriginPool { // verifies this and admits through its reconnect fast lane. reconnect: true, preferredRegion, + isCurrent: () => this.isCurrent(), fetch: this.options.fetch }) this.assertCurrent() diff --git a/src/main/runtime/relay/relay-session-broker.test.ts b/src/main/runtime/relay/relay-session-broker.test.ts index bea6e072273..6f27b4f2e14 100644 --- a/src/main/runtime/relay/relay-session-broker.test.ts +++ b/src/main/runtime/relay/relay-session-broker.test.ts @@ -223,8 +223,17 @@ describe('RelaySessionBroker lifecycle ownership', () => { ) expect(fakes.assign).toHaveBeenNthCalledWith( 1, - expect.objectContaining({ preferredRegion: 'asia-east2', reconnect: true }) + expect.objectContaining({ + preferredRegion: 'asia-east2', + reconnect: true, + // Why: the rate-gate wait relies on this fencing to abort superseded + // callers; dropping the wiring must fail here, not only in the field. + isCurrent: expect.any(Function) + }) ) + const wiredIsCurrent = (fakes.assign.mock.calls[0]![0] as { isCurrent: () => boolean }) + .isCurrent + expect(wiredIsCurrent()).toBe(true) fakes.controls[0]!.options.onConnectionOpen({ connId: 'old-basis', connTicket: 'T'.repeat(43), diff --git a/src/main/runtime/relay/relay-session-broker.ts b/src/main/runtime/relay/relay-session-broker.ts index e04640a5b2e..e12e09119a0 100644 --- a/src/main/runtime/relay/relay-session-broker.ts +++ b/src/main/runtime/relay/relay-session-broker.ts @@ -217,6 +217,7 @@ export class RelaySessionBroker { // through to the placement lane. reconnect: true, preferredRegion, + isCurrent: () => this.isCurrent(), fetch: this.options.fetch }) this.assertCurrent() diff --git a/src/shared/relay-retry-after-header.ts b/src/shared/relay-retry-after-header.ts new file mode 100644 index 00000000000..039c59e5512 --- /dev/null +++ b/src/shared/relay-retry-after-header.ts @@ -0,0 +1,17 @@ +// Relay director Retry-After: delta-seconds or HTTP-date. Null means the server +// asked for no pacing, so the caller keeps its own backoff. +export function parseRelayRetryAfterMs( + value: string | null | undefined, + maxMs: number, + nowMs = Date.now() +): number | null { + if (!value) { + return null + } + const seconds = Number(value) + const delayMs = Number.isFinite(seconds) ? seconds * 1_000 : Date.parse(value) - nowMs + if (!Number.isFinite(delayMs) || delayMs <= 0) { + return null + } + return Math.min(maxMs, Math.ceil(delayMs)) +} From fda213a4e8a7415e84e75f54f27ddfd926f14c5f Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:03:46 -0700 Subject: [PATCH 010/152] Improve automations table layout and column sizing (#16667) * Improve automations table layout and column sizing - Wrap table containers with min-width constraint for horizontal scrolling - Adjust grid column widths for better visual balance - Simplify automation draft building with helper function - Remove unused validation checks and imports * Make automation list first column sticky - Keep automation name visible when scrolling horizontally - Adjust header z-index to layer above sticky cells * Remove unused canCreateAutomation prop from test --- .../AutomationListExternalRows.tsx | 10 +++- .../automations/AutomationListLocalRows.tsx | 10 +++- .../automations/AutomationListTableHeader.tsx | 16 ++++++- .../automations/AutomationsListPanel.test.tsx | 2 +- .../automations/AutomationsListPanel.tsx | 4 +- .../automations/AutomationsPage.tsx | 14 ------ .../automations/AutomationsPageSkeleton.tsx | 47 ++++++++++++------- .../automations/automations-table-layout.ts | 2 +- src/renderer/src/lib/list-table-layout.ts | 17 ++++++- 9 files changed, 78 insertions(+), 44 deletions(-) diff --git a/src/renderer/src/components/automations/AutomationListExternalRows.tsx b/src/renderer/src/components/automations/AutomationListExternalRows.tsx index 12e8f3c0c85..976a93b2433 100644 --- a/src/renderer/src/components/automations/AutomationListExternalRows.tsx +++ b/src/renderer/src/components/automations/AutomationListExternalRows.tsx @@ -32,7 +32,11 @@ import { import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display' import { getExternalAutomationActionDisabledMessage } from './external-automation-source-availability' import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout' -import { LIST_TABLE_ROW_CLASS, LIST_TABLE_ROW_SELECTED_CLASS } from '@/lib/list-table-layout' +import { + LIST_TABLE_ROW_CLASS, + LIST_TABLE_ROW_SELECTED_CLASS, + LIST_TABLE_STICKY_ROW_CELL_CLASS +} from '@/lib/list-table-layout' import { isPortaledRowMenuClick, isRowActivationKey } from '@/lib/list-row-interaction' import { getExternalAutomationLastRunSnapshot } from './automation-list-last-run' import { AutomationListLastRunCell } from './AutomationListLastRunCell' @@ -121,7 +125,9 @@ export function AutomationListExternalRows({ isSelected && LIST_TABLE_ROW_SELECTED_CLASS )} > - {entry.job.name} + + {entry.job.name} + {scheduleLabel} diff --git a/src/renderer/src/components/automations/AutomationListLocalRows.tsx b/src/renderer/src/components/automations/AutomationListLocalRows.tsx index d0aef5d899b..292eb545b4d 100644 --- a/src/renderer/src/components/automations/AutomationListLocalRows.tsx +++ b/src/renderer/src/components/automations/AutomationListLocalRows.tsx @@ -49,7 +49,11 @@ import { type AutomationUsageSummary } from './automation-usage-model' import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout' -import { LIST_TABLE_ROW_CLASS, LIST_TABLE_ROW_SELECTED_CLASS } from '@/lib/list-table-layout' +import { + LIST_TABLE_ROW_CLASS, + LIST_TABLE_ROW_SELECTED_CLASS, + LIST_TABLE_STICKY_ROW_CELL_CLASS +} from '@/lib/list-table-layout' import { isPortaledRowMenuClick, isRowActivationKey } from '@/lib/list-row-interaction' import { AutomationListStatusCell } from './AutomationListStatusCell' import { translate } from '@/i18n/i18n' @@ -246,7 +250,9 @@ export function AutomationListLocalRows({ isSelected && LIST_TABLE_ROW_SELECTED_CLASS )} > - {automation.name} + + {automation.name} + {scheduleLabel} diff --git a/src/renderer/src/components/automations/AutomationListTableHeader.tsx b/src/renderer/src/components/automations/AutomationListTableHeader.tsx index d76c749927d..dcbd107fcbc 100644 --- a/src/renderer/src/components/automations/AutomationListTableHeader.tsx +++ b/src/renderer/src/components/automations/AutomationListTableHeader.tsx @@ -1,7 +1,10 @@ import React from 'react' import { translate } from '@/i18n/i18n' import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout' -import { LIST_TABLE_HEADER_CLASS } from '@/lib/list-table-layout' +import { + LIST_TABLE_HEADER_CLASS, + LIST_TABLE_STICKY_HEADER_CELL_CLASS +} from '@/lib/list-table-layout' export function AutomationListTableHeader(): React.JSX.Element { const labels = [ @@ -17,7 +20,16 @@ export function AutomationListTableHeader(): React.JSX.Element { return (
{labels.map(([key, fallback], index) => ( - + {translate(key, fallback)} ))} diff --git a/src/renderer/src/components/automations/AutomationsListPanel.test.tsx b/src/renderer/src/components/automations/AutomationsListPanel.test.tsx index 3ccaefabfab..21ca21f495e 100644 --- a/src/renderer/src/components/automations/AutomationsListPanel.test.tsx +++ b/src/renderer/src/components/automations/AutomationsListPanel.test.tsx @@ -71,6 +71,7 @@ function renderPanel( searchActive: query !== '' }} hostCatalog={HOST_CATALOG} + canCreateAutomation={true} externalManagersUncheckedNotice={uncheckedNotice} onSelectHost={() => undefined} onRecoverHost={() => undefined} @@ -98,7 +99,6 @@ function renderPanel( requestExternalAction={() => undefined} openEditExternalDialog={() => undefined} openCreateDialog={() => undefined} - canCreateAutomation onOpenDetail={() => undefined} onRefresh={() => undefined} isRefreshing={false} diff --git a/src/renderer/src/components/automations/AutomationsListPanel.tsx b/src/renderer/src/components/automations/AutomationsListPanel.tsx index 93a829b8257..a2986dd9afd 100644 --- a/src/renderer/src/components/automations/AutomationsListPanel.tsx +++ b/src/renderer/src/components/automations/AutomationsListPanel.tsx @@ -360,7 +360,7 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS className={cn('scrollbar-sleek min-h-0 flex-1 overflow-auto', LIST_TABLE_CONTAINER_CLASS)} > {hasFilteredListItems ? ( - <> +
@@ -380,7 +380,7 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS onEdit={openEditExternalDialog} />
- +
) : ( - + + + @@ -122,24 +129,28 @@ export function AutomationsPageSkeleton(): React.JSX.Element {
-
- - - - - - - - - -
-
- {TABLE_ROW_SKELETONS.map(({ id, ...row }) => ( - - ))} +
+
+ + + + + + + + + + + +
+
+ {TABLE_ROW_SKELETONS.map(({ id, ...row }) => ( + + ))} +
diff --git a/src/renderer/src/components/automations/automations-table-layout.ts b/src/renderer/src/components/automations/automations-table-layout.ts index 718a090e85b..f286276bfbd 100644 --- a/src/renderer/src/components/automations/automations-table-layout.ts +++ b/src/renderer/src/components/automations/automations-table-layout.ts @@ -1,3 +1,3 @@ // Name | Schedule | Project | Host | Next run | Last run | Status | Agent | Actions export const AUTOMATIONS_TABLE_GRID_CLASS = - 'grid grid-cols-[minmax(0,1.4fr)_minmax(6.5rem,9.5rem)_minmax(4.5rem,8rem)_minmax(4.5rem,7.5rem)_minmax(8rem,1fr)_minmax(7.5rem,10rem)_minmax(4.5rem,5.5rem)_2.5rem_2.5rem]' + 'grid grid-cols-[minmax(11rem,1.8fr)_minmax(6.5rem,9.5rem)_minmax(4.5rem,7.5rem)_minmax(4.5rem,7rem)_minmax(7.5rem,9.5rem)_minmax(7rem,9.5rem)_minmax(4.5rem,5.5rem)_2.5rem_2.5rem]' diff --git a/src/renderer/src/lib/list-table-layout.ts b/src/renderer/src/lib/list-table-layout.ts index 374f3deb174..c40607ee944 100644 --- a/src/renderer/src/lib/list-table-layout.ts +++ b/src/renderer/src/lib/list-table-layout.ts @@ -5,11 +5,24 @@ */ export const LIST_TABLE_CONTAINER_CLASS = 'rounded-md border border-border/50 bg-muted/20' +// Why: z-30 must beat the rows' sticky first cells (z-20) so the header still covers them. export const LIST_TABLE_HEADER_CLASS = - 'sticky top-0 z-10 h-8 items-center gap-3 border-b border-border/50 bg-muted/25 px-3 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground' + 'sticky top-0 z-30 h-8 items-center gap-3 border-b border-border/50 bg-muted/25 px-3 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground' // Why: keep keyboard-selected rows clear of the sticky table header. export const LIST_TABLE_ROW_CLASS = - 'w-full min-h-11 scroll-mt-8 cursor-pointer items-center gap-3 px-3 py-3 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50' + 'group/list-table-row w-full min-h-11 scroll-mt-8 cursor-pointer items-center gap-3 px-3 py-3 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50' export const LIST_TABLE_ROW_SELECTED_CLASS = 'bg-accent text-accent-foreground' + +// Why: sticky cells must paint opaquely, so they carry the container tint flattened +// against the page instead of the alpha wash the scrolled columns would show through. +// The before:/after: strips fill the row's px-3 gutter and the gap-3 column gap, which +// the cell itself cannot reach, so scrolled columns slide fully out of sight. +const LIST_TABLE_STICKY_CELL_BASE_CLASS = + 'sticky left-3 flex min-w-0 self-stretch items-center before:absolute before:-left-3 before:top-0 before:bottom-0 before:w-3 before:bg-inherit after:absolute after:-right-3 after:top-0 after:bottom-0 after:w-3 after:bg-inherit' + +export const LIST_TABLE_STICKY_HEADER_CELL_CLASS = `${LIST_TABLE_STICKY_CELL_BASE_CLASS} z-10 bg-[color-mix(in_srgb,var(--muted)_40%,var(--background))]` + +// Why: hover/selection ride variants, not props, so the frozen cell tracks the row's own wash. +export const LIST_TABLE_STICKY_ROW_CELL_CLASS = `${LIST_TABLE_STICKY_CELL_BASE_CLASS} z-20 bg-[color-mix(in_srgb,var(--muted)_20%,var(--background))] transition-colors group-hover/list-table-row:bg-accent group-data-[current=true]/list-table-row:bg-accent` From c1a7748267e701e8251cd18ec95aa38b7c5c4254 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:08:58 -0700 Subject: [PATCH 011/152] fix(agent-hooks): stop the Windows hook launcher spelling the AV-denied flag triple (#16576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(agent-hooks): stop the hook launcher spelling the AV-denied flag triple Orca's Windows agent-hook launcher ran powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden \ -EncodedCommand That exact combination is the textbook "hidden encoded PowerShell" malware shape, and endpoint security denies it at process creation whatever the payload decodes to -- even `exit 0`. Every injected hook then failed with `powershell.exe: Permission denied` (exit 126 from bash's execve, EACCES) on every turn, for both Claude Code and Codex, with no AV exclusion that re-enabled it. Dropping any one of the three flags clears the signature. `-ExecutionPolicy Bypass` is the one that can move: it sets the Process scope, and so does `Set-ExecutionPolicy -Scope Process`, which now rides inside the encoded payload. `-EncodedCommand` is never policy-gated, so the bypass always gets to run before the managed script does -- which is what keeps Copilot's .ps1 hook working under a Restricted or AllSigned machine policy. The hidden window and the encoding are unchanged, so nothing regresses for #14815, #14818 or #6078. Closes #16003 * fix(agent-hooks): ship the launcher shape #16003 actually measured as allowed The previous revision of this branch dropped only `-ExecutionPolicy Bypass` and kept `-WindowStyle Hidden -EncodedCommand`, on the reasoning that "dropping any one of the three flags clears the signature". That sentence is not in the bisect. The reporter ran exactly four command lines on the affected Kaspersky/Windows 11 host: -NoProfile -WindowStyle Hidden -Command 'exit 0' -> 0 -NoProfile -EncodedCommand -> 0 -NoProfile -ExecutionPolicy Bypass -Command 'exit 0' -> 0 -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand -> 126 Every passing row drops two flags. No row drops exactly one, so the shape the branch was about to ship had never been executed on the machine that reports the bug -- and it is `-WindowStyle Hidden -EncodedCommand`, which is the "hidden encoded PowerShell" pair the denial is named for in our own comment. Shipping it would have closed #16003 while leaving every hook on that host dying at CreateProcess, with no tracking left open. So emit the measured-passing encoded row instead: `-NoProfile -EncodedCommand`. Of the two flags there was a choice between, `-EncodedCommand` is the one that carries correctness -- it is what keeps paths and switches intact across cmd.exe and MSYS (#6078, #14815). `-WindowStyle Hidden` costs at most a console flash, and only where the parent has no console to inherit. Second, the relocated bypass now runs inside try/catch. Under a MachinePolicy or UserPolicy GPO scope, `Set-ExecutionPolicy -Scope Process` reports that the process scope did not take. `-ErrorAction SilentlyContinue` covers only the non-terminating half of that; the command-line switch it replaces was silent either way. This file already documents that non-stdout PowerShell streams corrupt consumers merging our output into JSON stdout, so a per-invocation ErrorRecord on stderr is a regression we should not trade for the switch. Refs #16003 * fix(agent-hooks): keep the hook console hidden while dropping the AV-denied flag Round 2 of this PR widened the fix from "stop spelling -ExecutionPolicy Bypass" to "stop spelling it and -WindowStyle Hidden", on the reasoning that the #16003 reporter never measured a shape that drops exactly one flag, so keeping the hidden+encoded pair would be extrapolation. That trades a reproduced regression for an unmeasured one. Window suppression is the shipped fix for #14815 and its four duplicates (#14828, #15117, #15447, #15767): a hook launched from a parent with no console gets a fresh console per event, which takes foreground and eats whatever the user is typing into Orca, and never closes at all on the stdin-blocking path hook-stdin-contract.ts exists to guard. That fires on every prompt, tool call and stop of every managed agent. The AV denial, by contrast, is measured only for the full triple; that the remaining pair still trips it is a hypothesis. Between a certain regression and a possible one, keep the certainty. So the flag that leaves the command line is the policy bypass alone — the only one of the three with an exact in-payload equivalent, hence the only one that can move without losing behaviour. If the pair turns out to be denied too, the answer is a different shape that still hides the window. * fix(agent-hooks): silence progress before the policy bypass can autoload (#16621) Hardware-measured on Windows 11 while exercising #16576. Set-ExecutionPolicy autoloads Microsoft.PowerShell.Security, and that module's "Preparing modules for first use." progress record is written before any later assignment can suppress it. Running the bypass first therefore defeated the silencer that runs immediately after it: bypass-first stderr = 616 bytes, first merged line '#< CLIXML' silencer-first stderr = 0 bytes, first merged line '{"decision":"approve"}' That is precisely the corruption HOOK_PROGRESS_SILENCER's own comment warns about -- redirected progress becoming CLIXML that can corrupt merged JSON -- so the PR reintroduced the hazard it documents, one line below documenting it. Both existing tests asserted the broken order, so they enforced the bug rather than catching it. Reordered them and added one that pins the ordering itself rather than the literal string, since the string will drift again. --- src/main/agent-hooks/installer-utils.test.ts | 17 +++- .../agent-hooks/runtime-home-hook-command.ts | 2 +- .../windows-powershell-hook-launcher.test.ts | 96 +++++++++++++++++++ .../windows-powershell-hook-launcher.ts | 60 ++++++++++-- src/main/claude/hook-service.test.ts | 4 +- .../hook-service-managed-install.test.ts | 2 +- src/main/command-code/hook-service.test.ts | 2 +- src/main/cursor/hook-service.test.ts | 2 +- src/main/droid/hook-service.test.ts | 2 +- src/main/gemini/hook-service.test.ts | 2 +- src/main/grok/hook-service.test.ts | 2 +- 11 files changed, 174 insertions(+), 17 deletions(-) create mode 100644 src/main/agent-hooks/windows-powershell-hook-launcher.test.ts diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index 64b47046ec1..8515f415fd2 100644 --- a/src/main/agent-hooks/installer-utils.test.ts +++ b/src/main/agent-hooks/installer-utils.test.ts @@ -604,7 +604,7 @@ describe('wrapPosixHookCommand', () => { }) const qualifiedWindowsPowerShellCommand = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ function decodeWindowsHookCommand(command: string): string { const encodedCommand = command.match(/ -EncodedCommand (\S+)$/)?.[1] @@ -614,8 +614,10 @@ function decodeWindowsHookCommand(command: string): string { function expectedDecodedWindowsHookCommand(scriptPath: string): string { const quoted = `'${scriptPath.replaceAll("'", "''")}'` + // Why: the execution-policy bypass rides in the payload, not on the command + // line, so the launcher cannot spell the AV-blocked flag triple (#16003). // Why: PowerShell progress CLIXML corrupts consumers that merge stderr into JSON stdout. - return `$ProgressPreference='SilentlyContinue'; if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0` + return `$ProgressPreference='SilentlyContinue'; try { Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force -ErrorAction SilentlyContinue } catch {}; if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0` } describe('wrapWindowsHookCommand', () => { @@ -742,6 +744,17 @@ describe('wrapRuntimeHomeHookCommand', () => { } ) + it('hides the console on the Git Bash branch too, and still avoids the denied triple', () => { + // Why: this branch launches PowerShell from bash, where the parent has no + // console to inherit — Windows allocates a fresh one per hook event unless + // the switch says otherwise (#14815), and the AV verdict on the flag triple + // applies to the exact same string (#16003). + const command = wrapRuntimeHomeHookCommand('claude-hook') + + expect(command).toContain('powershell.exe" -NoProfile -WindowStyle Hidden -EncodedCommand ') + expect(command).not.toMatch(/-ExecutionPolicy/i) + }) + it('rejects a script base name that could inject shell syntax', () => { expect(() => wrapRuntimeHomeHookCommand('claude-hook; echo injected')).toThrow( 'Invalid managed script base name' diff --git a/src/main/agent-hooks/runtime-home-hook-command.ts b/src/main/agent-hooks/runtime-home-hook-command.ts index 321c8d4adef..3a6f0d20725 100644 --- a/src/main/agent-hooks/runtime-home-hook-command.ts +++ b/src/main/agent-hooks/runtime-home-hook-command.ts @@ -24,7 +24,7 @@ export function wrapRuntimeHomeHookCommand( const powershellFallback = options.neutralJsonWhenMissing ? "; Write-Output '{}'" : '' const powershellCommand = `$homePath = $env:HOME -replace '^/([A-Za-z])/', '$1:/'; $scriptPath = Join-Path $homePath '.orca\\agent-hooks\\${scriptBaseName}.cmd'; if (Test-Path -LiteralPath $scriptPath -PathType Leaf) { & $scriptPath; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null${powershellFallback}; exit 0` const encodedCommand = encodeWindowsPowerShellHookCommand(powershellCommand) - // Why: the Git Bash and native Windows launchers must suppress windows identically (#14815). + // Why: the Git Bash and native Windows launchers must spell the same switches — window suppression (#14815) and an AV verdict on the shape (#16003) both hit either path. const powershellInvocation = `${powershell} ${WINDOWS_POWERSHELL_HOOK_SWITCHES} -EncodedCommand ${encodedCommand}` const encodedWindowsBranch = `if [ -f ${powershell} ]; then ${powershellInvocation}; else ${missingScriptFallback}; fi` const windowsBranch = `if [ -f ${windowsScript} ]; then case "\${HOME-}" in ${WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE}) ${encodedWindowsBranch} ;; *) ${windowsScript} ;; esac; else ${missingScriptFallback}; fi` diff --git a/src/main/agent-hooks/windows-powershell-hook-launcher.test.ts b/src/main/agent-hooks/windows-powershell-hook-launcher.test.ts new file mode 100644 index 00000000000..e58fd25c960 --- /dev/null +++ b/src/main/agent-hooks/windows-powershell-hook-launcher.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import { + encodeWindowsPowerShellHookCommand, + WINDOWS_POWERSHELL_HOOK_SWITCHES, + wrapWindowsPowerShellEncodedCommand +} from './windows-powershell-hook-launcher' + +function decodePayload(command: string): string { + const encoded = command.match(/ -EncodedCommand (\S+)$/)?.[1] + expect(encoded).toBeTruthy() + return Buffer.from(encoded!, 'base64').toString('utf16le') +} + +/* + * Two reproduced Windows failures constrain this one string, in opposite + * directions: + * + * #16003 — endpoint security (Kaspersky Premium, Windows 11) denies process + * creation for `-ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand` + * whatever the payload decodes to, and no exclusion re-enabled it. The triple + * must stop being spelled. + * + * #14815 (+ #14828, #15117, #15447, #15767) — without `-WindowStyle Hidden` + * every hook event allocates a console that takes foreground and eats the + * user's keystrokes, and strands a visible window outright when the hook blocks + * on stdin. Window suppression must stay. + * + * Both hold only if the flag that leaves the command line is the policy bypass, + * which is the one with an exact in-payload equivalent. + */ +describe('windows PowerShell hook launcher', () => { + it('never spells the denied flag triple on the command line', () => { + const command = wrapWindowsPowerShellEncodedCommand('exit 0') + + expect(WINDOWS_POWERSHELL_HOOK_SWITCHES).not.toMatch(/-ExecutionPolicy/i) + expect(command.replace(/ -EncodedCommand \S+$/, '')).not.toMatch(/-ExecutionPolicy/i) + }) + + it('keeps hiding the console window on every hook event (#14815)', () => { + // Dropping this flag is not a cosmetic flash: the console takes foreground + // and swallows what the user is typing into Orca (#14828), and never closes + // at all when the hook blocks reading stdin (#14815). + const command = wrapWindowsPowerShellEncodedCommand('exit 0') + + expect(WINDOWS_POWERSHELL_HOOK_SWITCHES).toBe('-NoProfile -WindowStyle Hidden') + expect(command).toMatch(/ -NoProfile -WindowStyle Hidden -EncodedCommand [A-Za-z0-9+/=]+$/) + }) + + it('keeps the execution-policy bypass, in the payload where AV cannot read it', () => { + // Why it must survive somewhere: Copilot's managed hook is a .ps1, which a + // Restricted or AllSigned machine policy refuses to run without a bypass. + // Process scope is exactly what the switch used to set. + expect(decodePayload(wrapWindowsPowerShellEncodedCommand('exit 0'))).toContain( + 'Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force -ErrorAction SilentlyContinue' + ) + }) + + it('swallows a terminating execution-policy failure, not just a non-terminating one', () => { + // A GPO MachinePolicy/UserPolicy scope makes the cmdlet complain that the + // process scope did not take. -ErrorAction covers only the non-terminating + // half; the switch this replaced printed nothing either way, and an + // ErrorRecord on stderr corrupts consumers that merge our streams into JSON. + const decoded = decodePayload(wrapWindowsPowerShellEncodedCommand('exit 0')) + + expect(decoded).toMatch(/try \{[^}]*Set-ExecutionPolicy[^}]*\} catch \{\}/) + }) + + it('applies the bypass before the caller command and keeps progress silenced', () => { + const decoded = Buffer.from( + encodeWindowsPowerShellHookCommand('& $scriptPath'), + 'base64' + ).toString('utf16le') + + expect(decoded).toBe( + "$ProgressPreference='SilentlyContinue'; try { Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force -ErrorAction SilentlyContinue } catch {}; & $scriptPath" + ) + }) + + it('silences progress before anything that can autoload a module', () => { + // Set-ExecutionPolicy pulls in Microsoft.PowerShell.Security, and its + // "Preparing modules for first use." progress record is written before a + // later assignment can suppress it. Measured on Windows 11: bypass-first put + // 616 bytes of on stderr and made "#< CLIXML" the + // first merged line -- the exact corruption HOOK_PROGRESS_SILENCER exists to + // stop. Silencer-first measured 0 bytes. + const decoded = Buffer.from( + encodeWindowsPowerShellHookCommand('& $scriptPath'), + 'base64' + ).toString('utf16le') + + expect(decoded.indexOf("$ProgressPreference='SilentlyContinue'")).toBeGreaterThanOrEqual(0) + expect(decoded.indexOf("$ProgressPreference='SilentlyContinue'")).toBeLessThan( + decoded.indexOf('Set-ExecutionPolicy') + ) + }) +}) diff --git a/src/main/agent-hooks/windows-powershell-hook-launcher.ts b/src/main/agent-hooks/windows-powershell-hook-launcher.ts index e3974ef8ff1..aeec04380db 100644 --- a/src/main/agent-hooks/windows-powershell-hook-launcher.ts +++ b/src/main/agent-hooks/windows-powershell-hook-launcher.ts @@ -1,4 +1,4 @@ -// Why: centralizing the launcher keeps window suppression consistent across installers (#14815). +// Why: centralizing the launcher keeps every installer on one command shape; #14815 and #16003 both turned on which shape it is. // Why: an absolute forward-slash path avoids PATH hijacking and survives cmd.exe and Git Bash. export function getWindowsSystem32Path(relativePath: string): string { @@ -10,16 +10,64 @@ export function getWindowsPowerShellExecutablePath(): string { return getWindowsSystem32Path('WindowsPowerShell/v1.0/powershell.exe') } -// Why: unlike conhost, hidden PowerShell relays hook output and exit status (#14818). -export const WINDOWS_POWERSHELL_HOOK_SWITCHES = - '-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden' +/** + * Switches for the PowerShell that relays hook output and exit status + * (#14818 — conhost does neither). + * + * `-WindowStyle Hidden` stays. It is the shipped fix for #14815 and its four + * duplicates (#14828, #15117, #15447, #15767): without it Windows allocates a + * console per hook event, which steals foreground from whatever the user is + * typing into, and strands a permanently visible window whenever a hook blocks + * reading stdin (see hook-stdin-contract.ts). Those are reported, reproduced + * user-facing failures on every hook event of every managed agent. + * + * `-ExecutionPolicy Bypass` is the flag that leaves the command line, because + * it is the only one of the three with an exact in-payload equivalent (below): + * it costs nothing to move. #16003 measured `-NoProfile -ExecutionPolicy Bypass + * -WindowStyle Hidden -EncodedCommand` as denied at CreateProcess (exit 126, + * Kaspersky Premium on Windows 11) no matter what the payload decodes to, so + * the triple has to stop being spelled; dropping the policy flag breaks it. + * + * What is not established: that the remaining pair clears that AV signature — + * the reporter measured no two-flag encoded shape. If it turns out not to, the + * answer is another launcher shape that still suppresses the window, not + * trading a reproduced regression for an unmeasured hope. + */ +export const WINDOWS_POWERSHELL_HOOK_SWITCHES = '-NoProfile -WindowStyle Hidden' -// Why: redirected PowerShell progress becomes CLIXML that can corrupt merged JSON output. +// Why: redirected PowerShell progress becomes CLIXML that can corrupt merged JSON +// output. It must be the FIRST statement: Set-ExecutionPolicy autoloads +// Microsoft.PowerShell.Security, whose "Preparing modules for first use." +// progress record is emitted before any later assignment can suppress it. +// Measured on Windows 11: bypass-first put 616 bytes of +// on stderr and made "#< CLIXML" the first merged line; silencer-first, 0 bytes. const HOOK_PROGRESS_SILENCER = "$ProgressPreference='SilentlyContinue'; " +/** + * Process-scope stand-in for the `-ExecutionPolicy Bypass` switch (#16003). + * + * Equivalent by construction: the switch sets the Process scope too, and both + * lose to a Group Policy scope. `-EncodedCommand` itself is never policy-gated, + * so this always gets to run; it is what lets the managed `.ps1` hooks (Copilot) + * execute under a Restricted or AllSigned machine policy. + * + * try/catch as well as `-ErrorAction SilentlyContinue`: under a MachinePolicy or + * UserPolicy GPO the cmdlet reports that the process scope did not take, and + * `-ErrorAction` only governs the non-terminating half of that. The switch this + * replaces printed nothing at all in the same situation, and an ErrorRecord on + * stderr is a live corruption risk for the consumers that merge our streams into + * JSON stdout (see the progress silencer above). A hook must still answer its + * agent when the policy is locked down. + */ +const HOOK_EXECUTION_POLICY_BYPASS = + 'try { Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force -ErrorAction SilentlyContinue } catch {}; ' + // Why: encoding shields paths and switches from cmd.exe and MSYS rewriting (#6078, #14815). export function encodeWindowsPowerShellHookCommand(command: string): string { - return Buffer.from(`${HOOK_PROGRESS_SILENCER}${command}`, 'utf16le').toString('base64') + return Buffer.from( + `${HOOK_PROGRESS_SILENCER}${HOOK_EXECUTION_POLICY_BYPASS}${command}`, + 'utf16le' + ).toString('base64') } export function wrapWindowsPowerShellEncodedCommand(command: string): string { diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index 2137bcda780..315ee6dd228 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -41,7 +41,7 @@ describe('getWindowsManagedLifecycleHook', () => { expect(hook.args).toBeUndefined() expect(hook.command).toMatch( - /\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden / + /\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand / ) expect(hook.command).not.toContain(scriptPath) // Why: Git Bash/MSYS mangles backslash paths and slash-prefixed switches. @@ -385,7 +385,7 @@ describe('ClaudeHookService.install', () => { const hook = settings.hooks[eventName]?.[0]?.hooks?.[0] expect(hook?.args).toBeUndefined() expect(hook?.command).toMatch( - /\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden / + /\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand / ) expect(hook?.command).not.toContain(scriptPath) diff --git a/src/main/codex/hook-service-managed-install.test.ts b/src/main/codex/hook-service-managed-install.test.ts index 22f6bf698c5..72852e45b55 100644 --- a/src/main/codex/hook-service-managed-install.test.ts +++ b/src/main/codex/hook-service-managed-install.test.ts @@ -30,7 +30,7 @@ vi.mock('os', async (importOriginal) => { import { CodexHookService } from './hook-service' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ const homes = setupCodexHookHomes(homedirMock, getPathMock) diff --git a/src/main/command-code/hook-service.test.ts b/src/main/command-code/hook-service.test.ts index ce59e20b056..f5b0f83af62 100644 --- a/src/main/command-code/hook-service.test.ts +++ b/src/main/command-code/hook-service.test.ts @@ -21,7 +21,7 @@ vi.mock('os', async () => { import { CommandCodeHookService } from './hook-service' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ describe('CommandCodeHookService', () => { let homeDir: string diff --git a/src/main/cursor/hook-service.test.ts b/src/main/cursor/hook-service.test.ts index b723d947db3..a260b74938f 100644 --- a/src/main/cursor/hook-service.test.ts +++ b/src/main/cursor/hook-service.test.ts @@ -31,7 +31,7 @@ const CURSOR_EVENTS = [ const CURSOR_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'cursor-hook.cmd' : 'cursor-hook.sh' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ describe('CursorHookService', () => { let homeDir: string diff --git a/src/main/droid/hook-service.test.ts b/src/main/droid/hook-service.test.ts index 0a65facab21..bd145fdc1de 100644 --- a/src/main/droid/hook-service.test.ts +++ b/src/main/droid/hook-service.test.ts @@ -25,7 +25,7 @@ vi.mock('os', async () => { import { DroidHookService } from './hook-service' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ describe('DroidHookService', () => { let homeDir: string diff --git a/src/main/gemini/hook-service.test.ts b/src/main/gemini/hook-service.test.ts index fc5a302a7d1..a835a6278f8 100644 --- a/src/main/gemini/hook-service.test.ts +++ b/src/main/gemini/hook-service.test.ts @@ -26,7 +26,7 @@ vi.mock('os', async (importOriginal) => { import { GeminiHookService } from './hook-service' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ describe('GeminiHookService', () => { let homeDir: string diff --git a/src/main/grok/hook-service.test.ts b/src/main/grok/hook-service.test.ts index 89bd53f22c0..4b1625614ac 100644 --- a/src/main/grok/hook-service.test.ts +++ b/src/main/grok/hook-service.test.ts @@ -33,7 +33,7 @@ import { POSIX_HOOK_STDIN_READER } from '../agent-hooks/hook-stdin-contract' const GROK_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ // Why (#14828): Windows registers the bare script path when it is cmd-safe and only falls back // to the encoded launcher for a profile path that is not (#6078). windows-hook-launcher-chain From aab6464a6a35791bee23c14fdd3ba56eb0b9c4a1 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:09:04 -0700 Subject: [PATCH 012/152] feat(editor): implement Shift+Tab to unindent lists and code blocks (#16677) Add keyboard handlers for Shift+Tab that unindent code block lines and lift nested list items. Properly handles mixed bullet/task nesting by retyping items to match their enclosing list. Provides symmetric control over indentation to complement Tab's indent behavior. --- .../editor/rich-markdown-code-block-indent.ts | 47 ++++ .../editor/rich-markdown-key-handler.ts | 18 +- .../editor/rich-markdown-list-indent.ts | 112 ++++++++ .../rich-markdown-tab-key-handler.test.ts | 259 +++++++++++++++++- 4 files changed, 427 insertions(+), 9 deletions(-) create mode 100644 src/renderer/src/components/editor/rich-markdown-code-block-indent.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-list-indent.ts diff --git a/src/renderer/src/components/editor/rich-markdown-code-block-indent.ts b/src/renderer/src/components/editor/rich-markdown-code-block-indent.ts new file mode 100644 index 00000000000..33272dae462 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-code-block-indent.ts @@ -0,0 +1,47 @@ +import type { Editor } from '@tiptap/react' + +export const RICH_MARKDOWN_CODE_BLOCK_INDENT = ' ' + +const LEADING_INDENT = /^(\t| {1,2})/ + +function findCodeBlockAtCursor(editor: Editor): { text: string; start: number } | null { + const { $from } = editor.state.selection + for (let depth = $from.depth; depth > 0; depth--) { + const node = $from.node(depth) + if (node.type.name === 'codeBlock') { + return { text: node.textContent, start: $from.start(depth) } + } + } + return null +} + +/** + * Shift+Tab inside a code block: strip one indent step from every line the + * selection touches, mirroring Tab's insert. + */ +export function outdentRichMarkdownCodeBlock(editor: Editor): boolean { + const codeBlock = findCodeBlockAtCursor(editor) + if (!codeBlock) { + return false + } + + const { from, to } = editor.state.selection + const tr = editor.state.tr + let lineStart = codeBlock.start + + for (const line of codeBlock.text.split('\n')) { + const lineEnd = lineStart + line.length + const isTouched = lineEnd >= from && lineStart <= to + const indent = isTouched ? LEADING_INDENT.exec(line) : null + if (indent) { + tr.delete(tr.mapping.map(lineStart), tr.mapping.map(lineStart + indent[0].length)) + } + lineStart = lineEnd + 1 + } + + if (!tr.docChanged) { + return false + } + editor.view.dispatch(tr) + return true +} diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.ts index 358d236e1eb..69ecebf8364 100644 --- a/src/renderer/src/components/editor/rich-markdown-key-handler.ts +++ b/src/renderer/src/components/editor/rich-markdown-key-handler.ts @@ -21,6 +21,14 @@ import { deleteAdjacentEmptyParagraph } from './rich-markdown-empty-paragraph-de import { handleRichMarkdownTableBackspace } from './rich-markdown-table-row-delete' import { handleRichMarkdownTableEnter } from './rich-markdown-table-enter' import { handleRichMarkdownTableTab } from './rich-markdown-table-tab' +import { + indentRichMarkdownListItem, + outdentRichMarkdownListItem +} from './rich-markdown-list-indent' +import { + outdentRichMarkdownCodeBlock, + RICH_MARKDOWN_CODE_BLOCK_INDENT +} from './rich-markdown-code-block-indent' import { handleRichMarkdownCitationKey } from './rich-markdown-citation-keyboard' import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context' import { handleRichMarkdownLinkShortcut } from './rich-markdown-link-shortcut' @@ -202,22 +210,20 @@ export function createRichMarkdownKeyHandler( } if (event.shiftKey) { - if (!ed.commands.liftListItem('listItem')) { - ed.commands.liftListItem('taskItem') + if (!outdentRichMarkdownCodeBlock(ed)) { + outdentRichMarkdownListItem(ed) } return true } if (ed.isActive('codeBlock')) { - ed.commands.insertContent(' ') + ed.commands.insertContent(RICH_MARKDOWN_CODE_BLOCK_INDENT) return true } // Why: sinkListItem succeeds when the item has a previous sibling; // otherwise it no-ops. Either way we consume Tab to prevent focus escape. - if (!ed.commands.sinkListItem('listItem')) { - ed.commands.sinkListItem('taskItem') - } + indentRichMarkdownListItem(ed) return true } diff --git a/src/renderer/src/components/editor/rich-markdown-list-indent.ts b/src/renderer/src/components/editor/rich-markdown-list-indent.ts new file mode 100644 index 00000000000..24b0377572b --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-list-indent.ts @@ -0,0 +1,112 @@ +import type { Editor } from '@tiptap/react' +import { Fragment } from '@tiptap/pm/model' +import { TextSelection } from '@tiptap/pm/state' + +type ListItemTypeName = 'listItem' | 'taskItem' + +const LIST_TYPE_FOR_ITEM: Record = { + listItem: 'bulletList', + taskItem: 'taskList' +} + +function isListItemType(name: string): name is ListItemTypeName { + return name === 'listItem' || name === 'taskItem' +} + +type ListItemContext = { + /** Closest list item at the cursor. */ + itemType: ListItemTypeName + itemDepth: number + /** List item enclosing that one, when the cursor sits in a nested list. */ + parentItemType: ListItemTypeName | null +} + +/** + * Why: bullet and task lists nest into each other, so trying `listItem` first + * and falling back to `taskItem` picks the wrong node — the command walks up to + * any ancestor of that type and acts on *it*, mangling the surrounding list. + */ +function resolveListItemContext(editor: Editor): ListItemContext | null { + const { $from } = editor.state.selection + let found: { itemType: ListItemTypeName; itemDepth: number } | null = null + for (let depth = $from.depth; depth > 0; depth--) { + const name = $from.node(depth).type.name + if (!isListItemType(name)) { + continue + } + if (!found) { + found = { itemType: name, itemDepth: depth } + continue + } + return { ...found, parentItemType: name } + } + return found ? { ...found, parentItemType: null } : null +} + +export function indentRichMarkdownListItem(editor: Editor): boolean { + return editor.commands.sinkListItem(resolveListItemContext(editor)?.itemType ?? 'listItem') +} + +export function outdentRichMarkdownListItem(editor: Editor): boolean { + const context = resolveListItemContext(editor) + if (!context) { + return false + } + // Why: the schema forbids a listItem sibling inside a taskList (and vice + // versa), so a mixed nest cannot lift as-is — prosemirror-schema-list would + // silently lift the item *out* of its list, dropping its marker entirely. + // Retype the item to match the list it is about to join, then lift normally. + if (context.parentItemType && context.parentItemType !== context.itemType) { + splitListBeforeCursorItem(editor) + retypeListAtCursor(editor, context.parentItemType) + return editor.commands.liftListItem(context.parentItemType) + } + return editor.commands.liftListItem(context.itemType) +} + +/** + * Why: retyping is a whole-list operation, so peel the cursor's item (and the + * siblings that will follow it out) into their own list first — items staying + * behind must keep their original marker. + */ +function splitListBeforeCursorItem(editor: Editor): void { + const context = resolveListItemContext(editor) + if (!context) { + return + } + const { state } = editor + const { $from } = state.selection + if ($from.index(context.itemDepth - 1) === 0) { + return + } + editor.view.dispatch(state.tr.split($from.before(context.itemDepth), 1)) +} + +function retypeListAtCursor(editor: Editor, target: ListItemTypeName): void { + const context = resolveListItemContext(editor) + if (!context) { + return + } + const { state } = editor + const { from, to } = state.selection + const listPos = state.selection.$from.before(context.itemDepth - 1) + const list = state.doc.nodeAt(listPos) + if (!list) { + return + } + + // Why: retyping the list and its items in separate steps would leave an + // intermediate doc the schema rejects, so swap the whole subtree at once. + const itemType = state.schema.nodes[target] + const items = list.children.map((item) => itemType.create(null, item.content, item.marks)) + const retyped = state.schema.nodes[LIST_TYPE_FOR_ITEM[target]].create( + null, + Fragment.from(items), + list.marks + ) + const tr = state.tr.replaceWith(listPos, listPos + list.nodeSize, retyped) + // Why: replaceWith treats the old subtree as deleted, collapsing the cursor to + // the range edge — the retype is size-preserving, so restore it verbatim. + tr.setSelection(TextSelection.create(tr.doc, from, to)) + editor.view.dispatch(tr) +} diff --git a/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts b/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts index de6b9934d27..7904f2d673a 100644 --- a/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts @@ -4,6 +4,8 @@ import StarterKit from '@tiptap/starter-kit' import TaskList from '@tiptap/extension-task-list' import TaskItem from '@tiptap/extension-task-item' import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests' +import { createRichMarkdownExtensions } from './rich-markdown-extensions' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' import { createRichMarkdownKeyHandler, type KeyHandlerContext } from './rich-markdown-key-handler' function createEditor(content: object): Editor { @@ -21,6 +23,62 @@ function createEditor(content: object): Editor { }) } +/** + * Why: mixed bullet/task nesting and code-block indentation only reproduce + * against the live extension set, not the trimmed StarterKit one above. + */ +function createMarkdownEditor(markdown: string): Editor { + return new Editor({ + element: null, + extensions: createRichMarkdownExtensions({ + codec: createRichMarkdownEditorCodec() + }), + content: markdown, + contentType: 'markdown' + }) +} + +/** + * Why: pasted list HTML is the path the reported bug came in through — the + * editor has no plain-text markdown paste transform. The DOM-less test env + * cannot parse HTML, so assert against the node shapes that paste produces. + */ +function createNodeEditor(content: object): Editor { + return new Editor({ + element: null, + extensions: createRichMarkdownExtensions({ + codec: createRichMarkdownEditorCodec() + }), + content + }) +} + +function para(text: string): object { + return { type: 'paragraph', content: [{ type: 'text', text }] } +} + +function bullets(...items: object[][]): object { + return { + type: 'bulletList', + content: items.map((content) => ({ type: 'listItem', content })) + } +} + +function tasks(...items: object[][]): object { + return { + type: 'taskList', + content: items.map((content) => ({ + type: 'taskItem', + attrs: { checked: false }, + content + })) + } +} + +function doc(...content: object[]): object { + return { type: 'doc', content } +} + function textPosition(editor: Editor, text: string): number { let position: number | null = null editor.state.doc.descendants((node, pos) => { @@ -39,6 +97,24 @@ function textPosition(editor: Editor, text: string): number { return position } +function codeBlockPosition(editor: Editor, text: string): number { + let position: number | null = null + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'codeBlock' && node.textContent.includes(text)) { + position = pos + 1 + node.textContent.indexOf(text) + return false + } + + return true + }) + + if (position === null) { + throw new Error(`Expected code block text: ${text}`) + } + + return position +} + function keyEvent( key: string, overrides: Partial = {} @@ -129,7 +205,12 @@ function parentAndFixesDocument(): object { content: [ { type: 'listItem', - content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Parent' }] }] + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Parent' }] + } + ] }, { type: 'listItem', @@ -155,12 +236,22 @@ function taskListDocument(): object { { type: 'taskItem', attrs: { checked: false }, - content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Task A' }] }] + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Task A' }] + } + ] }, { type: 'taskItem', attrs: { checked: false }, - content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Task B' }] }] + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Task B' }] + } + ] } ] } @@ -181,6 +272,7 @@ describe('rich markdown Tab key handler', () => { flush: vi.fn(() => calls.push('flush')) } }, + state: { selection: { $from: { depth: 0 } } }, commands: { sinkListItem: vi.fn((type) => { calls.push(`sink:${String(type)}`) @@ -254,10 +346,126 @@ describe('rich markdown Tab key handler', () => { } }) + it('outdents a bullet nested inside a task item into the enclosing task list', () => { + const editor = createMarkdownEditor('- [ ] Task A\n - Bullet B') + + try { + editor.commands.setTextSelection(textPosition(editor, 'Bullet B')) + const event = keyEvent('Tab', { shiftKey: true }) + + expect(createRichMarkdownKeyHandler(createContext(editor))(null, event)).toBe(true) + expect(editor.getMarkdown()).toBe('- [ ] Task A\n- [ ] Bullet B') + } finally { + editor.destroy() + } + }) + + it('outdents a task nested inside a bullet item into the enclosing bullet list', () => { + const editor = createMarkdownEditor('- Bullet A\n - [ ] Task B') + + try { + editor.commands.setTextSelection(textPosition(editor, 'Task B')) + const event = keyEvent('Tab', { shiftKey: true }) + + expect(createRichMarkdownKeyHandler(createContext(editor))(null, event)).toBe(true) + expect(editor.getMarkdown()).toBe('- Bullet A\n- Task B') + } finally { + editor.destroy() + } + }) + + it('keeps sibling bullets that stay behind when outdenting out of a task item', () => { + const editor = createMarkdownEditor('- [ ] Task A\n - Bullet X\n - Bullet Y') + + try { + editor.commands.setTextSelection(textPosition(editor, 'Bullet Y')) + const event = keyEvent('Tab', { shiftKey: true }) + + expect(createRichMarkdownKeyHandler(createContext(editor))(null, event)).toBe(true) + expect(editor.getMarkdown()).toBe('- [ ] Task A\n - Bullet X\n- [ ] Bullet Y') + } finally { + editor.destroy() + } + }) + + it('outdents one level into the enclosing task list in a mixed nest', () => { + const editor = createMarkdownEditor('- A\n - [ ] B\n - C') + + try { + editor.commands.setTextSelection(textPosition(editor, 'C')) + const event = keyEvent('Tab', { shiftKey: true }) + + expect(createRichMarkdownKeyHandler(createContext(editor))(null, event)).toBe(true) + expect(editor.getMarkdown()).toBe('- A\n - [ ] B\n - [ ] C') + } finally { + editor.destroy() + } + }) + + it('outdents a nested bullet list item on Shift-Tab', () => { + const editor = createMarkdownEditor('- Alpha\n - Beta') + + try { + editor.commands.setTextSelection(textPosition(editor, 'Beta')) + const event = keyEvent('Tab', { shiftKey: true }) + + expect(createRichMarkdownKeyHandler(createContext(editor))(null, event)).toBe(true) + expect(editor.getMarkdown()).toBe('- Alpha\n- Beta') + } finally { + editor.destroy() + } + }) + + it('removes one indent step from a code block line on Shift-Tab', () => { + const editor = createMarkdownEditor('```js\n const x = 1\n```') + + try { + editor.commands.setTextSelection(codeBlockPosition(editor, 'const x')) + const event = keyEvent('Tab', { shiftKey: true }) + + expect(createRichMarkdownKeyHandler(createContext(editor))(null, event)).toBe(true) + expect(editor.getMarkdown()).toBe('```js\n const x = 1\n```') + } finally { + editor.destroy() + } + }) + + it('outdents every code block line the selection touches', () => { + const editor = createMarkdownEditor('```js\n a\n b\n c\n```') + + try { + editor.commands.setTextSelection({ + from: codeBlockPosition(editor, 'a'), + to: codeBlockPosition(editor, 'b') + 1 + }) + const event = keyEvent('Tab', { shiftKey: true }) + + expect(createRichMarkdownKeyHandler(createContext(editor))(null, event)).toBe(true) + expect(editor.getMarkdown()).toBe('```js\na\nb\n c\n```') + } finally { + editor.destroy() + } + }) + + it('leaves an unindented code block untouched on Shift-Tab', () => { + const editor = createMarkdownEditor('```js\nconst x = 1\n```') + + try { + editor.commands.setTextSelection(codeBlockPosition(editor, 'const x')) + const event = keyEvent('Tab', { shiftKey: true }) + + expect(createRichMarkdownKeyHandler(createContext(editor))(null, event)).toBe(true) + expect(editor.getMarkdown()).toBe('```js\nconst x = 1\n```') + } finally { + editor.destroy() + } + }) + it('inserts spaces for Tab in code blocks', () => { const insertContent = vi.fn() const editor = { view: { composing: false }, + state: { selection: { $from: { depth: 0 } } }, commands: { sinkListItem: vi.fn(), liftListItem: vi.fn(), @@ -270,4 +478,49 @@ describe('rich markdown Tab key handler', () => { expect(createRichMarkdownKeyHandler(createContext(editor))(null, event)).toBe(true) expect(insertContent).toHaveBeenCalledWith(' ') }) + + it('outdents a nested bullet that arrived as a pasted list node tree', () => { + const editor = createNodeEditor(doc(bullets([para('One'), bullets([para('Two')])]))) + + try { + editor.commands.setTextSelection(textPosition(editor, 'Two')) + const event = keyEvent('Tab', { shiftKey: true }) + + expect(createRichMarkdownKeyHandler(createContext(editor))(null, event)).toBe(true) + expect(editor.getMarkdown()).toBe('- One\n- Two') + } finally { + editor.destroy() + } + }) + + it('outdents a bullet nested under a pasted task item', () => { + const editor = createNodeEditor(doc(tasks([para('Task A'), bullets([para('Bullet B')])]))) + + try { + editor.commands.setTextSelection(textPosition(editor, 'Bullet B')) + const event = keyEvent('Tab', { shiftKey: true }) + + expect(createRichMarkdownKeyHandler(createContext(editor))(null, event)).toBe(true) + expect(editor.getMarkdown()).toBe('- [ ] Task A\n- [ ] Bullet B') + } finally { + editor.destroy() + } + }) + + it('keeps pasted sibling bullets behind when outdenting out of a task item', () => { + const editor = createNodeEditor( + doc(tasks([para('Task A'), bullets([para('Bullet X')], [para('Bullet Y')])])) + ) + + try { + editor.commands.setTextSelection(textPosition(editor, 'Bullet Y')) + const event = keyEvent('Tab', { shiftKey: true }) + + expect(createRichMarkdownKeyHandler(createContext(editor))(null, event)).toBe(true) + expect(editor.getMarkdown()).toBe('- [ ] Task A\n - Bullet X\n- [ ] Bullet Y') + } finally { + editor.destroy() + } + }) + }) From 87ede54eb807d06697c45651b43712d167d3ea04 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:31:32 -0700 Subject: [PATCH 013/152] Show all automation destination hosts, disable ineligible ones (#16665) * Show all automation destination hosts, disable ineligible ones Previously, filtering to only eligible hosts hid all connected hosts on pre-host-scoping Orca servers. Now all offered hosts appear in the picker; ineligible ones are disabled with a message naming which servers need updating to support them. * Show all automation destination hosts, keep create available when all ar - Gate the create button on what the picker offers, not on readiness: with every offered host ineligible (e.g. all pre-host-scoping servers), the dialog is where the repair is stated, so the button must still open it. - Fix StrictMode double-mount lifecycle: disposed controller revived by effect, unsubscribe before dispose to prevent event leaks under simulated unmount. - Validate create destination early, before hooks load and trust prompt, so the user never answers a trust dialog for a destination that would reject. - Dedupe capability probes per authority incarnation: concurrent callers share one in-flight status.get; confirmed capabilities never re-probed. - Drop cache payload at retirement so revived hosts refetch instead of showing stale rows. * Add TTL-based capability probe caching and fix automation dialog target Extract capability probing to a separate module with improved caching strategy: confirmations now expire after 60 seconds and the cache is bounded to 32 entries, enabling in-place runtime replacements to invalidate old confirmations. For uncaptured automation owners, resolve the dialog target to the same host the save addresses rather than relying on ambient context, preventing stale host references. Optionally await external managers after mutations to ensure row re-reads reflect recent writes. * Fix automation tests after rebase * Refactor capability probe to fence fencing checks from in-flight probes Fencing checks need fresh probes since in-flight probes may predate in-place runtime replacements. Extract shared probe deduplication into `sharedCapabilityProbe()` and unconditional probe start into `startCapabilityProbe()`, then route based on cache preference. --- .../AutomationCreateDestinationField.test.tsx | 41 ++++- .../AutomationCreateDestinationField.tsx | 27 +++- ...utomationsPage.create-destination.test.tsx | 85 +++++++++- ...sPage.strict-mode-save-visibility.test.tsx | 150 +++++++++++++++++ .../automations/AutomationsPage.test.tsx | 13 ++ .../automations/AutomationsPage.tsx | 152 ++++++++++++------ .../automation-capability-probe.ts | 135 ++++++++++++++++ .../automation-create-admission.ts | 22 --- .../automation-create-destination.test.ts | 72 +++++++++ .../automation-create-destination.ts | 30 ++++ .../automation-external-target-match.test.ts | 36 +---- .../automation-external-target-match.ts | 9 -- .../automation-host-cache-controller.ts | 11 +- .../automations/automation-host-cache.test.ts | 12 ++ .../automations/automation-host-cache.ts | 7 + .../automation-host-client.test.ts | 24 ++- .../automations/automation-host-client.ts | 18 +-- .../automation-host-diagnostics.test.ts | 17 +- .../automation-host-diagnostics.ts | 5 +- .../automation-host-scale-gates.test.ts | 45 ++++-- .../automation-scoped-list-client.test.ts | 111 ++++++++++++- .../automation-scoped-list-client.ts | 78 ++------- .../automation-target-availability.test.ts | 16 -- .../automations-page-runtime-fixtures.ts | 13 ++ .../automations-page-test-harness.tsx | 30 ++-- .../use-automation-host-catalog.ts | 39 ++++- src/renderer/src/i18n/locales/en.json | 4 +- 27 files changed, 915 insertions(+), 287 deletions(-) create mode 100644 src/renderer/src/components/automations/AutomationsPage.strict-mode-save-visibility.test.tsx create mode 100644 src/renderer/src/components/automations/automation-capability-probe.ts delete mode 100644 src/renderer/src/components/automations/automation-create-admission.ts diff --git a/src/renderer/src/components/automations/AutomationCreateDestinationField.test.tsx b/src/renderer/src/components/automations/AutomationCreateDestinationField.test.tsx index 1df0a7519bf..e32e6500d5f 100644 --- a/src/renderer/src/components/automations/AutomationCreateDestinationField.test.tsx +++ b/src/renderer/src/components/automations/AutomationCreateDestinationField.test.tsx @@ -42,9 +42,29 @@ const ENTRY: AutomationHostCatalogEntry = { querySupport: 'scoped' } -function control(projects: Repo[]): AutomationCreateDestinationControl { +const LEGACY_ENTRY: AutomationHostCatalogEntry = { + stableRef: { authority: { kind: 'runtime', environmentId: 'r1' }, selector: { kind: 'self' } }, + owner: { + authority: { kind: 'runtime', environmentId: 'r1', pairingRevision: 1 }, + selector: { kind: 'self' } + }, + stableKey: 'host:runtime:r1:self', + label: 'legacy-box', + authorityLabel: 'legacy-box', + kind: 'self', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'connected', + querySupport: 'legacy-unscoped', + scopeGap: 'authority-unscoped' +} + +function control( + projects: Repo[], + entries: AutomationHostCatalogEntry[] = [ENTRY] +): AutomationCreateDestinationControl { return { - entries: [ENTRY], + entries, resolution: { status: 'ready', authority: OWNER.authority, @@ -56,11 +76,11 @@ function control(projects: Repo[]): AutomationCreateDestinationControl { } } -function render(projects: Repo[]): void { +function render(projects: Repo[], entries?: AutomationHostCatalogEntry[]): void { act(() => { root.render( - + ) }) @@ -83,4 +103,17 @@ describe('AutomationCreateDestinationField', () => { expect(emptyNote()).toBeNull() expect(container.textContent).toContain('Local Mac') }) + + it('names the hosts a server update would let create, instead of hiding them', () => { + render([{ id: 'repo-1' } as Repo], [ENTRY, LEGACY_ENTRY]) + + const note = container.querySelector('[data-testid="automation-create-update-required"]') + expect(note?.textContent).toContain('legacy-box') + }) + + it('says nothing about updates when every offered host is eligible', () => { + render([{ id: 'repo-1' } as Repo]) + + expect(container.querySelector('[data-testid="automation-create-update-required"]')).toBeNull() + }) }) diff --git a/src/renderer/src/components/automations/AutomationCreateDestinationField.tsx b/src/renderer/src/components/automations/AutomationCreateDestinationField.tsx index 8ba017b0ff7..43a4f2cc08c 100644 --- a/src/renderer/src/components/automations/AutomationCreateDestinationField.tsx +++ b/src/renderer/src/components/automations/AutomationCreateDestinationField.tsx @@ -11,7 +11,11 @@ import { import { translate } from '@/i18n/i18n' import { AutomationHostLabel, AutomationHostStatusBadges } from './AutomationHostBadges' import { Field } from './automation-page-parts' -import { automationCreateHostEligible } from './automation-create-destination' +import { + automationCreateHostEligible, + automationCreateHostOffered, + automationCreateUpdateRequiredAuthorityLabels +} from './automation-create-destination' import { groupAutomationHostEntriesByAuthority } from './automation-host-picker-groups' import type { AutomationCreateDestinationControl } from './use-automation-create-destination' @@ -32,11 +36,12 @@ export function AutomationCreateDestinationField({ labelClassName?: string }): React.JSX.Element { const selected = control.resolution.status === 'ready' ? control.resolution.entry : null - // Offered and resolved by the same predicate, so the list never contains a - // host that selecting would refuse (e.g. a view-only entry). + // Ineligible hosts stay listed but disabled: hiding them read as the host + // being gone, and it hid every connected host on a pre-host-scoping server. const groups = groupAutomationHostEntriesByAuthority( - control.entries.filter(automationCreateHostEligible) + control.entries.filter(automationCreateHostOffered) ) + const updateRequiredAuthorities = automationCreateUpdateRequiredAuthorityLabels(control.entries) const label = translate('auto.components.automations.createDestination.label', 'Create on') return ( @@ -58,6 +63,7 @@ export function AutomationCreateDestinationField({ @@ -91,6 +97,19 @@ export function AutomationCreateDestinationField({ )}

)} + {updateRequiredAuthorities.length > 0 ? ( + // A disabled row's tooltip is unreachable, so the repair is stated here. +

+ {translate( + 'auto.components.automations.createDestination.updateRequired', + 'Update the Orca server on {hosts} to create automations there.' + // Replacer fn: a literal replacement would expand `$` patterns in host labels. + ).replace('{hosts}', () => updateRequiredAuthorities.join(', '))} +

+ ) : null} ) } diff --git a/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx index d13ab127e21..da04a8cd58f 100644 --- a/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx @@ -11,7 +11,7 @@ */ import { act } from 'react' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { Automation } from '../../../../shared/automations-types' import { hostStableKey } from '../../../../shared/automation-owner-key' import { unscopedAutomationListRows, type AutomationListRow } from './automation-list-row-identity' @@ -131,7 +131,8 @@ describe('AutomationsPage create destination', () => { expect(api.automations.create).not.toHaveBeenCalled() expect(runtimeCreateCalls()).toHaveLength(1) expect(runtimeCreateCalls()[0]?.[2]).toMatchObject({ - repo: RUNTIME_REPO_ID, + repo: `id:${RUNTIME_REPO_ID}`, + workspace: `id:${RUNTIME_WORKSPACE_ID}`, destination: { selector: { kind: 'self' } } }) }) @@ -152,7 +153,7 @@ describe('AutomationsPage create destination', () => { expect(api.automations.create).not.toHaveBeenCalled() expect(runtimeCreateCalls()).toHaveLength(0) - expect(mocks.toastError).toHaveBeenCalled() + expect(mocks.editorDialog?.notice?.message).toBeTruthy() expect(mocks.editorDialog?.open).toBe(true) }) @@ -169,7 +170,7 @@ describe('AutomationsPage create destination', () => { // A repo with no connection ID is not evidence of local: this one is the // runtime's, and the desktop's Self host cannot hold an automation for it. expect(api.automations.create).not.toHaveBeenCalled() - expect(mocks.toastError).toHaveBeenCalled() + expect(mocks.editorDialog?.notice?.message).toBeTruthy() expect(mocks.editorDialog?.open).toBe(true) }) @@ -368,3 +369,79 @@ describe('AutomationsPage edit fencing', () => { expect(mocks.editorDialog?.notice?.message).toBeTruthy() }) }) + +describe('AutomationsPage edit dialog projects', () => { + it('offers the edited row’s own host projects, not the create destination’s', async () => { + const automation = makeAutomation({ + id: 'a-runtime', + projectId: RUNTIME_REPO_ID, + workspaceId: RUNTIME_WORKSPACE_ID + }) + // The ambient list is the desktop's and never held this record, so an + // id lookup there answers with nothing and the row's owner is all there is. + api.automations.list.mockResolvedValue([]) + scopedList([]) + runtimeHost([automation], []) + addRuntimeProject() + + await renderPage() + await settleHostQueries() + await act(async () => { + void mocks.listPanel?.openEditDialog(listedRow(automation.id)) + }) + + expect(mocks.editorDialog?.isEditing).toBe(true) + expect(mocks.editorDialog?.repos?.map((repo) => repo.id)).toEqual([RUNTIME_REPO_ID]) + }) +}) + +describe('AutomationsPage create admission', () => { + it('keeps the create button available while every offered host is ineligible', async () => { + // No host has answered, so nothing resolves ready — but the dialog is where + // an ineligible host's repair is stated, so the button must still open it. + api.automations.listScoped.mockRejectedValue(new Error('offline')) + api.automations.list.mockResolvedValue([]) + + await renderPage() + await settleHostQueries() + + expect(mocks.listPanel?.canCreateAutomation).toBe(true) + }) + + it('refuses a mismatched create destination before the hooks trust prompt', async () => { + // A runtime-owned project under the sole desktop destination: refused, and + // refused before the user is asked to trust that project's setup hooks. + api.automations.list.mockResolvedValue([]) + scopedList([]) + addRuntimeProject() + // A setup hook whose default policy is run-by-default, so the old save + // order would have raised the trust prompt before refusing the create. + const { checkRuntimeHooks } = await import('@/runtime/runtime-hooks-client') + vi.mocked(checkRuntimeHooks).mockResolvedValue({ + status: 'ok', + hooks: { scripts: { setup: 'pnpm install' } } + } as never) + + await renderPage() + await settleHostQueries() + await act(async () => { + mocks.listPanel?.openCreateDialog() + }) + await act(async () => { + mocks.editorDialog?.onDraftChange((current) => ({ + ...(current as Record), + name: 'Sweep', + prompt: 'Do the sweep', + projectId: RUNTIME_REPO_ID, + workspaceMode: 'new_per_run', + workspaceId: '' + })) + }) + await save() + + expect(mocks.editorDialog?.notice?.message).toBeTruthy() + const { ensureHooksConfirmed } = await import('@/lib/ensure-hooks-confirmed') + expect(vi.mocked(ensureHooksConfirmed)).not.toHaveBeenCalled() + expect(api.automations.create).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/automations/AutomationsPage.strict-mode-save-visibility.test.tsx b/src/renderer/src/components/automations/AutomationsPage.strict-mode-save-visibility.test.tsx new file mode 100644 index 00000000000..e1387e88631 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationsPage.strict-mode-save-visibility.test.tsx @@ -0,0 +1,150 @@ +// @vitest-environment happy-dom + +/** + * Save visibility under a StrictMode mount, which is how the dev app runs. + * + * StrictMode's simulated unmount disposes the host query controller after its + * first effect cycle. A controller that stays disposed drops every later + * invalidation — the list keeps its initial rows and a create never appears + * until the app is reloaded. These pin the revive: after the double mount, a + * write and the authority's change event must still refetch the written host. + */ + +import { act } from 'react' +import { describe, expect, it } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import { hostStableKey } from '../../../../shared/automation-owner-key' +import { AUTOMATIONS_CHANGED_EVENT } from '@/lib/automations-changed-window-event' +import { + addRuntimeProject, + api, + installAutomationsPageHarness, + mocks, + renderPage, + rows, + runtimeHost, + RUNTIME_ID, + RUNTIME_REPO_ID, + RUNTIME_WORKSPACE_ID, + scopedList, + settleHostQueries +} from './automations-page-test-harness' +import { makeAutomation, REPO_ID, WORKSPACE_ID } from './automations-page-fixtures' + +installAutomationsPageHarness() + +const CREATED = makeAutomation({ id: 'a-new', name: 'Sweep' }) + +const RUNTIME_SELF_KEY = hostStableKey({ + authority: { kind: 'runtime', environmentId: RUNTIME_ID }, + selector: { kind: 'self' } +}) + +function desktopStoreHolds(automations: Automation[]): void { + api.automations.list.mockResolvedValue(automations) + scopedList(automations) +} + +async function createSweep(): Promise { + await act(async () => { + mocks.listPanel?.openCreateDialog() + }) + await act(async () => { + mocks.editorDialog?.onDraftChange((current) => ({ + ...(current as Record), + name: 'Sweep', + prompt: 'Do the sweep', + projectId: REPO_ID, + workspaceMode: 'existing', + workspaceId: WORKSPACE_ID + })) + }) + await act(async () => { + await mocks.editorDialog?.onSave() + }) + await settleHostQueries() +} + +describe('AutomationsPage save visibility under StrictMode', () => { + it('lists a newly created automation without a reload', async () => { + desktopStoreHolds([]) + api.automations.create.mockResolvedValue(CREATED) + + const { container } = await renderPage({ strict: true }) + await settleHostQueries() + expect(rows(container, 'automation-row')).toEqual([]) + + desktopStoreHolds([CREATED]) + await createSweep() + + expect(api.automations.create).toHaveBeenCalledTimes(1) + expect(rows(container, 'automation-row')).toEqual(['Sweep']) + }) + + it('still refetches when the authority publishes its change event', async () => { + desktopStoreHolds([]) + + const { container } = await renderPage({ strict: true }) + await settleHostQueries() + expect(rows(container, 'automation-row')).toEqual([]) + + // A write that lands elsewhere (CLI, another window) only reaches this page + // through the event; a disposed controller would drop it on the floor. + desktopStoreHolds([CREATED]) + await act(async () => { + window.dispatchEvent( + new CustomEvent(AUTOMATIONS_CHANGED_EVENT, { + detail: { reason: 'definition', selector: { kind: 'self' } } + }) + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(rows(container, 'automation-row')).toEqual(['Sweep']) + }) + + it('lists a create on a runtime-host destination without a reload', async () => { + api.automations.list.mockResolvedValue([]) + scopedList([]) + runtimeHost([], []) + addRuntimeProject() + const previous = mocks.callRuntimeRpc.getMockImplementation() + mocks.callRuntimeRpc.mockImplementation( + async (target: unknown, method: string, params: unknown, options: unknown) => { + if (method === 'automation.create') { + // The runtime store now holds the row; later list reads must say so. + mocks.state.runtimeAnswers = { automations: [CREATED], runs: [] } + return { automation: CREATED } + } + return await previous?.(target, method, params, options) + } + ) + + const { container } = await renderPage({ strict: true }) + await settleHostQueries() + expect(rows(container, 'automation-row')).toEqual([]) + + await act(async () => { + mocks.listPanel?.openCreateDialog() + }) + await act(async () => { + mocks.editorDialog?.createDestination?.onSelect(RUNTIME_SELF_KEY) + }) + await act(async () => { + mocks.editorDialog?.onDraftChange((current) => ({ + ...(current as Record), + name: 'Sweep', + prompt: 'Do the sweep', + projectId: RUNTIME_REPO_ID, + workspaceMode: 'existing', + workspaceId: RUNTIME_WORKSPACE_ID + })) + }) + await act(async () => { + await mocks.editorDialog?.onSave() + }) + await settleHostQueries() + + expect(rows(container, 'automation-row')).toEqual(['Sweep']) + }) +}) diff --git a/src/renderer/src/components/automations/AutomationsPage.test.tsx b/src/renderer/src/components/automations/AutomationsPage.test.tsx index 1efff6d98f8..768d3250d80 100644 --- a/src/renderer/src/components/automations/AutomationsPage.test.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.test.tsx @@ -626,3 +626,16 @@ describe('AutomationsPage owner conflicts', () => { expect(mocks.listPanel?.isActionEnabled(listedRow(automation.id), 'delete')).toBe(true) }) }) + +describe('AutomationsPage refresh independence', () => { + // The external-manager probe is per host and can hang on a dead provider; the + // automation list must settle without waiting for it. + it('settles the list even when the external manager probe never answers', async () => { + api.automations.listExternalManagerForOwner.mockReturnValue(new Promise(() => undefined)) + + const { container } = await renderPage() + + expect(rows(container, 'automation-row')).toEqual(['Nightly']) + expect((mocks.listPanel as { isRefreshing?: boolean } | null)?.isRefreshing).toBe(false) + }) +}) diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 0180ad2ac52..7c908d41af1 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -61,7 +61,6 @@ import { } from './automation-setup-decision' import type { AutomationTemplate } from './automation-templates' import { getAutomationTargetAvailability } from './automation-target-availability' -import { getAutomationCreateAvailability } from './automation-create-admission' import { buildAutomationRunContextForRepo } from './automation-run-context' import { repoMatchesExternalAutomationTarget } from './automation-external-target-match' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' @@ -140,6 +139,7 @@ import { groupReposByAutomationAuthority } from './automation-authority-identity' import { + automationCreateHostOffered, automationCreateHostStableKey, resolveAutomationCreateDestination, revalidateAutomationCreateDestination, @@ -680,9 +680,9 @@ export default function AutomationsPage(): React.JSX.Element { // The row's own host, from its captured owner. A page-level target cannot // speak for a list spanning authorities, and the legacy arm below it is only // ever reached by rows the desktop's unscoped list produced. - const automationHostTargetFor = useCallback( - (row: AutomationListRow): AutomationHostTarget | null => { - const owner = capturedAutomationOwner(capturedAutomationOwners, row.key).owner + const automationHostTargetForRowKey = useCallback( + (rowKey: string | null): AutomationHostTarget | null => { + const owner = capturedAutomationOwner(capturedAutomationOwners, rowKey).owner if (owner?.authority.kind === 'runtime') { return { kind: 'environment', environmentId: owner.authority.environmentId } } @@ -690,6 +690,10 @@ export default function AutomationsPage(): React.JSX.Element { }, [automationHostTarget, capturedAutomationOwners] ) + const automationHostTargetFor = useCallback( + (row: AutomationListRow): AutomationHostTarget | null => automationHostTargetForRowKey(row.key), + [automationHostTargetForRowKey] + ) const automationDispatchContext = useMemo( () => ({ capturedOwners: capturedAutomationOwners, authority: automationAuthority }), [automationAuthority, capturedAutomationOwners] @@ -981,17 +985,41 @@ export default function AutomationsPage(): React.JSX.Element { } }, [activeWorktreeId, editorProjects, repoMap, worktreeMap, worktreesByRepo]) - const canCreateAutomation = getAutomationCreateAvailability({ - automationHostTarget: getAutomationListTarget(settings), - runtimeStatusByEnvironmentId - }).canRunNow - const editingAutomation = editingAutomationId - ? (automations.find((automation) => automation.id === editingAutomationId) ?? null) + // Gated on what the picker offers, not on eligibility: with every offered + // host ineligible (e.g. all pre-host-scoping servers), the dialog is where + // the repair is stated, so the button must still open it. + const canCreateAutomation = hostCatalog.entries.some(automationCreateHostOffered) + // The edited row's own captured owner names the host, not the ambient list + // target: a remote row need not appear in `automations` at all, and looking it + // up by id there would answer with whichever authority the page last listed. + // An uncaptured row resolves to the same host its save addresses. + const editingRow = editingRowKey + ? (visibleRows.find((row) => row.key === editingRowKey) ?? null) : null - const automationDialogTarget = editingAutomation - ? getAutomationOwnerTarget(editingAutomation, automationHostTarget) - : getAutomationListTarget(settings) + const editingRowCapturedOwner = capturedAutomationOwner( + capturedAutomationOwners, + editingRowKey + ).owner + const automationDialogTarget = ((): AutomationHostTarget => { + if (editingAutomationId === null) { + return getAutomationListTarget(settings) + } + // Uncaptured: the host the legacy save addresses, so a runtime row the + // desktop list produced stops offering desktop projects. + if (editingRow && !editingRowCapturedOwner) { + return getAutomationOwnerTarget(editingRow.automation, automationHostTarget) + } + return ( + automationHostTargetForRowKey(editingRowKey) ?? + getAutomationTargetFromHostId(editingRow?.automation.runContext?.hostId) + ) + })() const isOrcaForm = createTarget === 'orca' && editingExternalTarget === null + const dialogRepos = isOrcaForm + ? editingAutomationId !== null + ? getAutomationCreateRepos(repos, automationDialogTarget) + : editorProjects + : getAutomationCreateRepos(repos, { kind: 'local' }) const destinationForProject = useCallback( (projectId: string): AutomationCreateDestination | null => { @@ -1015,40 +1043,47 @@ export default function AutomationsPage(): React.JSX.Element { const reloadExternalManagers = scopedExternal.reload - const refresh = useCallback(async () => { - setIsLoading(true) - const pendingNavigation = useAppStore.getState().pendingAutomationRunNavigation - // The desktop unless navigation named a host: this arm exists for rows the - // per-host reads have not answered for, and the client's own authority is - // the only one it can address without guessing which server is meant. - const automationHostTarget: AutomationHostTarget = pendingNavigation - ? getAutomationTargetFromHostId(pendingNavigation.hostId) - : { kind: 'local' } - const authorityKey = automationAuthorityCatalogKey( - automationHostTarget.kind === 'environment' - ? { kind: 'runtime', environmentId: automationHostTarget.environmentId } - : { kind: 'desktop' } - ) - try { - const [nextAutomations] = await Promise.all([ - listAutomationsForTarget(automationHostTarget), - // Managers are per host and failures are per provider, so this settles - // on its own and must not be able to fail the automation list with it. - reloadExternalManagers().catch(() => undefined) - ]) - // Selection and run history are deliberately not written here: this call - // addressed one authority, and the selected row may belong to another. - setAutomations(nextAutomations) - setAutomationHostTargetKey(getAutomationHostTargetKey(automationHostTarget)) - setFailedAuthorityKeys((current) => withoutKey(current, authorityKey)) - } catch { - // Why not a toast and not a rethrow: the list keeps whatever it had, and - // the host's own status row is where the failure and its Retry belong. - setFailedAuthorityKeys((current) => new Set(current).add(authorityKey)) - } finally { - setIsLoading(false) - } - }, [reloadExternalManagers]) + const refresh = useCallback( + async (options?: { awaitExternalManagers?: boolean }) => { + setIsLoading(true) + const pendingNavigation = useAppStore.getState().pendingAutomationRunNavigation + // The desktop unless navigation named a host: this arm exists for rows the + // per-host reads have not answered for, and the client's own authority is + // the only one it can address without guessing which server is meant. + const automationHostTarget: AutomationHostTarget = pendingNavigation + ? getAutomationTargetFromHostId(pendingNavigation.hostId) + : { kind: 'local' } + const authorityKey = automationAuthorityCatalogKey( + automationHostTarget.kind === 'environment' + ? { kind: 'runtime', environmentId: automationHostTarget.environmentId } + : { kind: 'desktop' } + ) + // Managers are per host and failures are per provider: the probe settles + // into its own state on its own time, and must neither fail the automation + // list nor keep the list loading while a slow provider answers. An explicit + // external mutation opts in below, so the row set it re-reads reflects the + // write before its success toast lands. + const managersSettled = reloadExternalManagers().catch(() => undefined) + try { + const nextAutomations = await listAutomationsForTarget(automationHostTarget) + // Selection and run history are deliberately not written here: this call + // addressed one authority, and the selected row may belong to another. + setAutomations(nextAutomations) + setAutomationHostTargetKey(getAutomationHostTargetKey(automationHostTarget)) + setFailedAuthorityKeys((current) => withoutKey(current, authorityKey)) + } catch { + // Why not a toast and not a rethrow: the list keeps whatever it had, and + // the host's own status row is where the failure and its Retry belong. + setFailedAuthorityKeys((current) => new Set(current).add(authorityKey)) + } finally { + setIsLoading(false) + } + if (options?.awaitExternalManagers) { + await managersSettled + } + }, + [reloadExternalManagers] + ) useEffect(() => { if (!pendingAutomationRunNavigation || isLoading) { @@ -1514,7 +1549,7 @@ export default function AutomationsPage(): React.JSX.Element { if (!editingExternalTarget) { useAppStore.getState().recordFeatureInteraction('automation-created') } - await refresh() + await refresh({ awaitExternalManagers: true }) setCreateOpen(false) setEditingExternalTarget(null) // Same helper and same captured scope the row's key was built from, so the @@ -1537,7 +1572,11 @@ export default function AutomationsPage(): React.JSX.Element { ) return } - if (isOrcaForm && !editorProjects.some((repo) => repo.id === draft.projectId)) { + if ( + editingAutomationId !== null && + isOrcaForm && + !dialogRepos.some((repo) => repo.id === draft.projectId) + ) { toast.error( translate( 'auto.components.automations.AutomationsPage.destinationProjectUnavailable', @@ -1546,6 +1585,17 @@ export default function AutomationsPage(): React.JSX.Element { ) return } + // Refused here, before the side-effectful steps below (hooks load, trust + // prompt): the user must not answer a trust dialog for a create that the + // destination was always going to reject. `createDraftAutomation` checks + // again after those awaits, which is the fence that actually gates the send. + if (editingAutomationId === null) { + const earlyDestination = createDestination.check(draft.projectId) + if (!earlyDestination.ok) { + setEditorNotice(earlyDestination.notice) + return + } + } const now = Date.now() const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone const rrule = @@ -1962,7 +2012,7 @@ export default function AutomationsPage(): React.JSX.Element { if (action === 'run') { useAppStore.getState().recordFeatureInteraction('automation-run') } - await refresh() + await refresh({ awaitExternalManagers: true }) // Why: full-page detail keeps selection when the deleted external was open; // without this, detail can fall through to an unrelated local automation. if (action === 'delete') { @@ -2224,7 +2274,7 @@ export default function AutomationsPage(): React.JSX.Element { canSave={canSaveDraft} isEditingExternal={editingExternalTarget !== null} createTarget={createTarget} - repos={editorProjects} + repos={dialogRepos} projectHostSetups={projectHostSetups} automationYamlHooksByRepoKey={automationYamlHooksByRepoKey} getAutomationHooksCacheKey={getAutomationHooksCacheKey} diff --git a/src/renderer/src/components/automations/automation-capability-probe.ts b/src/renderer/src/components/automations/automation-capability-probe.ts new file mode 100644 index 00000000000..38cf3c69326 --- /dev/null +++ b/src/renderer/src/components/automations/automation-capability-probe.ts @@ -0,0 +1,135 @@ +import { getRuntimeEnvironmentStatus } from '@/runtime/runtime-rpc-client' +import type { AutomationAuthorityRef } from '../../../../shared/automation-owner-ref' +import { + AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, + AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE, + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, + AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE, + type RuntimeCapability +} from '../../../../shared/protocol-version' +import { automationAuthorityCatalogKey } from './automation-host-catalog-types' +import { automationHostDiagnostics } from './automation-host-diagnostics' + +export const REQUEST_TIMEOUT_MS = 15_000 + +export class AutomationHostScopeUnsupportedError extends Error { + readonly code = 'unsupported_host_scope' + + constructor(message: string) { + super(message) + this.name = 'AutomationHostScopeUnsupportedError' + } +} + +export const AUTHORITY_CAPABILITY_CONFIRMATION_TTL_MS = 60_000 +export const AUTHORITY_CAPABILITY_CONFIRMATION_MAX = 32 +const confirmedAuthorityCapabilities = new Map< + string, + { capabilities: Set; confirmedAt: number } +>() +const inFlightCapabilityProbes = new Map>() + +function capabilityProbeKey(authority: AutomationAuthorityRef & { kind: 'runtime' }): string { + return `${authority.environmentId}:${authority.pairingRevision}` +} + +function rememberAuthorityCapabilities( + key: string, + capabilities: Set, + confirmedAt: number +): void { + confirmedAuthorityCapabilities.delete(key) + confirmedAuthorityCapabilities.set(key, { capabilities, confirmedAt }) + while (confirmedAuthorityCapabilities.size > AUTHORITY_CAPABILITY_CONFIRMATION_MAX) { + const oldest = confirmedAuthorityCapabilities.keys().next() + if (oldest.done) { + break + } + confirmedAuthorityCapabilities.delete(oldest.value) + } +} + +/** Test seam: capability state is module-level and must not leak between tests. */ +export function resetAutomationCapabilityProbes(): void { + confirmedAuthorityCapabilities.clear() + inFlightCapabilityProbes.clear() +} + +export async function assertAuthorityCapability( + authority: AutomationAuthorityRef, + capability: RuntimeCapability, + message: string, + options: { cacheConfirmation?: boolean } = {} +): Promise { + if (authority.kind !== 'runtime') { + return + } + const useCache = options.cacheConfirmation !== false + const key = capabilityProbeKey(authority) + if (useCache) { + const confirmation = confirmedAuthorityCapabilities.get(key) + if ( + confirmation && + Date.now() - confirmation.confirmedAt >= AUTHORITY_CAPABILITY_CONFIRMATION_TTL_MS + ) { + confirmedAuthorityCapabilities.delete(key) + } else if (confirmation?.capabilities.has(capability)) { + return + } + } + // Fencing checks never join an in-flight probe: it may predate an in-place runtime replacement. + const status = useCache + ? await sharedCapabilityProbe(authority, key) + : await startCapabilityProbe(authority) + if (useCache && status.capabilities?.length) { + rememberAuthorityCapabilities(key, new Set(status.capabilities), Date.now()) + } + if (!status.capabilities?.includes(capability)) { + throw new AutomationHostScopeUnsupportedError(message) + } +} + +function startCapabilityProbe( + authority: AutomationAuthorityRef & { kind: 'runtime' } +): Promise<{ capabilities?: string[] }> { + automationHostDiagnostics.recordCapabilityProbe({ + authorityKey: automationAuthorityCatalogKey(authority) + }) + return getRuntimeEnvironmentStatus(authority.environmentId, REQUEST_TIMEOUT_MS) +} + +function sharedCapabilityProbe( + authority: AutomationAuthorityRef & { kind: 'runtime' }, + key: string +): Promise<{ capabilities?: string[] }> { + const existing = inFlightCapabilityProbes.get(key) + if (existing) { + return existing + } + const started = startCapabilityProbe(authority) + inFlightCapabilityProbes.set(key, started) + void started + .catch(() => undefined) + .finally(() => { + if (inFlightCapabilityProbes.get(key) === started) { + inFlightCapabilityProbes.delete(key) + } + }) + return started +} + +export async function assertOwnerFencingSupported( + authority: AutomationAuthorityRef +): Promise { + await assertAuthorityCapability( + authority, + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, + AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE, + { cacheConfirmation: false } + ) +} + +export { + AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, + AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE +} diff --git a/src/renderer/src/components/automations/automation-create-admission.ts b/src/renderer/src/components/automations/automation-create-admission.ts deleted file mode 100644 index b69a597663d..00000000000 --- a/src/renderer/src/components/automations/automation-create-admission.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { RuntimeStatus } from '../../../../shared/runtime-types' -import type { AutomationHostTarget } from './automation-host-client' -import { - getRuntimeAutomationAvailability, - type AutomationTargetAvailability -} from './automation-target-availability' - -export function getAutomationCreateAvailability(args: { - automationHostTarget: AutomationHostTarget - runtimeStatusByEnvironmentId?: ReadonlyMap< - string, - { status: RuntimeStatus | null; checkedAt: number } - > -}): AutomationTargetAvailability { - if (args.automationHostTarget.kind === 'local') { - return { canRunNow: true, reason: 'available', message: null } - } - return getRuntimeAutomationAvailability( - args.automationHostTarget.environmentId, - args.runtimeStatusByEnvironmentId - ) -} diff --git a/src/renderer/src/components/automations/automation-create-destination.test.ts b/src/renderer/src/components/automations/automation-create-destination.test.ts index bcf20e61c39..faf0905ee37 100644 --- a/src/renderer/src/components/automations/automation-create-destination.test.ts +++ b/src/renderer/src/components/automations/automation-create-destination.test.ts @@ -4,8 +4,10 @@ import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' import { + automationCreateHostOffered, automationCreateHostStableKey, automationCreateProjectMismatch, + automationCreateUpdateRequiredAuthorityLabels, preselectAutomationCreateHost, resolveAutomationCreateDestination, revalidateAutomationCreateDestination, @@ -116,6 +118,76 @@ describe('create destination revalidation', () => { }) }) +describe('offered create hosts', () => { + it('offers an ineligible host rather than hiding it', () => { + // The regression this pins: a connected host on a pre-host-scoping server + // is ineligible, and hiding it removed every connected host from the picker. + expect( + automationCreateHostOffered( + entry({ + querySupport: 'legacy-unscoped', + scopeGap: 'authority-unscoped' + }) + ) + ).toBe(true) + expect(automationCreateHostOffered(entry({ owner: null, catalogState: 'unhydrated' }))).toBe( + true + ) + }) + + it('hides only rows that can never become destinations', () => { + expect(automationCreateHostOffered(entry({ kind: 'orphan' }))).toBe(false) + expect(automationCreateHostOffered(entry({ owner: null, catalogState: 'removed' }))).toBe(false) + }) + + it('names the authorities a server update would repair, once each', () => { + const legacySelf = entry({ + stableKey: 'runtime:r1:self', + authorityLabel: 'legacy-box', + querySupport: 'legacy-unscoped', + scopeGap: 'authority-unscoped' + }) + const legacySshChild = entry({ + stableKey: 'runtime:r1:ssh:box', + kind: 'ssh', + authorityLabel: 'legacy-box', + querySupport: 'legacy-unscoped', + scopeGap: 'authority-unscoped' + }) + const incompatible = entry({ + stableKey: 'runtime:r2:self', + authorityLabel: 'older-box', + querySupport: 'incompatible' + }) + expect( + automationCreateUpdateRequiredAuthorityLabels([ + entry(), + legacySelf, + legacySshChild, + incompatible + ]) + ).toEqual(['legacy-box', 'older-box']) + }) + + it('does not blame the server for hosts another repair or none would fix', () => { + // Unverified since disconnect: the repair is a reconnect, not an update. + const unverified = entry({ + stableKey: 'desktop:ssh:cold', + kind: 'ssh', + querySupport: 'legacy-unscoped', + scopeGap: 'target-unverified' + }) + // Scoped but not yet hydrated: disabled, and no repair to name. + const unhydrated = entry({ + stableKey: 'desktop:ssh:warm', + kind: 'ssh', + owner: null, + catalogState: 'unhydrated' + }) + expect(automationCreateUpdateRequiredAuthorityLabels([unverified, unhydrated])).toEqual([]) + }) +}) + describe('sole create host', () => { const hydrated: AutomationCatalogHydrationEvidence = { runtimeCatalogSettled: true, diff --git a/src/renderer/src/components/automations/automation-create-destination.ts b/src/renderer/src/components/automations/automation-create-destination.ts index 8eff3749059..97a35d78a81 100644 --- a/src/renderer/src/components/automations/automation-create-destination.ts +++ b/src/renderer/src/components/automations/automation-create-destination.ts @@ -29,6 +29,7 @@ import { repoConnectionIdIn, type AutomationAuthorityRepoTables } from './automation-authority-identity' +import { automationHostRecoveryActions } from './automation-host-status-descriptors' export type AutomationCreateDestinationChoiceReason = /** No host is selected and nothing may be assumed. */ @@ -61,6 +62,35 @@ export function automationCreateHostEligible( return entry.kind !== 'orphan' && entry.owner !== null && entry.querySupport === 'scoped' } +/** + * What the picker lists: a superset of eligibility on purpose. An ineligible + * host renders disabled with its status stated, because a host the rest of the + * app still shows must not silently vanish here — omission reads as the host + * being gone. Only rows that can never become destinations stay hidden: the + * orphan bucket and removed targets. + */ +export function automationCreateHostOffered(entry: AutomationHostCatalogEntry): boolean { + return entry.kind !== 'orphan' && entry.catalogState !== 'removed' +} + +/** + * The authorities whose ineligibility a server update would repair, named so + * the field can say which machines need updating. A disabled row cannot explain + * itself — its tooltip sits behind pointer-events: none — and not every + * disabled host is repaired this way (an unverified target needs a reconnect), + * so the hint names exactly the update-repairable ones. + */ +export function automationCreateUpdateRequiredAuthorityLabels( + entries: readonly AutomationHostCatalogEntry[] +): string[] { + const labels = entries + .filter(automationCreateHostOffered) + .filter((entry) => !automationCreateHostEligible(entry)) + .filter((entry) => automationHostRecoveryActions(entry).authority === 'update-server') + .map((entry) => entry.authorityLabel) + return [...new Set(labels)] +} + export function resolveAutomationCreateDestination( entry: AutomationHostCatalogEntry | null | undefined ): AutomationCreateDestinationResolution { diff --git a/src/renderer/src/components/automations/automation-external-target-match.test.ts b/src/renderer/src/components/automations/automation-external-target-match.test.ts index 71a7ff8fd4b..25cdaa65eb8 100644 --- a/src/renderer/src/components/automations/automation-external-target-match.test.ts +++ b/src/renderer/src/components/automations/automation-external-target-match.test.ts @@ -2,10 +2,7 @@ import { describe, expect, it } from 'vitest' import type { ExternalAutomationTarget } from '../../../../shared/automations-types' import { toSshExecutionHostId } from '../../../../shared/execution-host' import type { Repo } from '../../../../shared/repo-types' -import { - repoMatchesExternalAutomationTarget, - getExternalAutomationTargetForRepo -} from './automation-external-target-match' +import { repoMatchesExternalAutomationTarget } from './automation-external-target-match' function repo( overrides: Partial> = {} @@ -51,34 +48,3 @@ describe('repoMatchesExternalAutomationTarget', () => { ).toBe(false) }) }) - -describe('getExternalAutomationTargetForRepo', () => { - it('prefers an explicit executionHostId over a disagreeing connectionId', () => { - expect( - getExternalAutomationTargetForRepo( - repo({ connectionId: 'conn-1', executionHostId: toSshExecutionHostId('conn-2') }) - ) - ).toEqual({ type: 'ssh', connectionId: 'conn-2' }) - }) - - it('derives the ssh target from connectionId when executionHostId is unset', () => { - expect(getExternalAutomationTargetForRepo(repo({ connectionId: 'conn-1' }))).toEqual({ - type: 'ssh', - connectionId: 'conn-1' - }) - }) - - it('round-trips connection ids that need host-id encoding', () => { - expect(getExternalAutomationTargetForRepo(repo({ connectionId: 'user@host:22' }))).toEqual({ - type: 'ssh', - connectionId: 'user@host:22' - }) - }) - - it('returns the local target for a plain local repo and for non-ssh hosts', () => { - expect(getExternalAutomationTargetForRepo(repo())).toEqual({ type: 'local' }) - expect(getExternalAutomationTargetForRepo(repo({ executionHostId: 'runtime:env-1' }))).toEqual({ - type: 'local' - }) - }) -}) diff --git a/src/renderer/src/components/automations/automation-external-target-match.ts b/src/renderer/src/components/automations/automation-external-target-match.ts index 417453d8550..f544c324eb1 100644 --- a/src/renderer/src/components/automations/automation-external-target-match.ts +++ b/src/renderer/src/components/automations/automation-external-target-match.ts @@ -18,12 +18,3 @@ export function repoMatchesExternalAutomationTarget( } return repoHostId === toSshExecutionHostId(target.connectionId) } - -// Why: the manager target must name the repo's authoritative execution host, -// not a possibly-stale connectionId. -export function getExternalAutomationTargetForRepo( - repo: Pick -): ExternalAutomationTarget { - const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) - return parsed?.kind === 'ssh' ? { type: 'ssh', connectionId: parsed.targetId } : { type: 'local' } -} diff --git a/src/renderer/src/components/automations/automation-host-cache-controller.ts b/src/renderer/src/components/automations/automation-host-cache-controller.ts index 5e50c41a317..fda1e267ab6 100644 --- a/src/renderer/src/components/automations/automation-host-cache-controller.ts +++ b/src/renderer/src/components/automations/automation-host-cache-controller.ts @@ -71,6 +71,8 @@ export type AutomationHostQueryController = { handleAuthorityEvent: (event: AutomationAuthorityChangeEvent) => void /** Latest orphan count the authority reported, so the next catalog can show its orphan entry. */ authorityOrphanCount: (authority: StableAutomationAuthorityRef) => number | null + /** True once disposed. A disposed controller drops every event and refresh, so a mounted owner must replace it. */ + isDisposed: () => boolean dispose: () => void } @@ -130,6 +132,7 @@ export function createAutomationHostQueryController( }) let latest: AutomationHostCatalog | null = null let selected: string | null = null + let disposed = false const targetsFor = ( catalog: AutomationHostCatalog, @@ -161,8 +164,10 @@ export function createAutomationHostQueryController( } }) - // Subscribed here rather than by the page: an unsubscribed controller silently - // stops refreshing on writes, which looks like stale data, not a missing wire. + // Subscribed here by default: an unsubscribed controller silently stops + // refreshing on writes, which looks like stale data, not a missing wire. A + // caller that opts out with `eventTarget: null` (the React hook, whose + // StrictMode-safe lifecycle owns the subscription) takes that duty on itself. const eventTarget = options.eventTarget === undefined ? (globalThis.window ?? null) : options.eventTarget const unsubscribe = eventTarget @@ -211,7 +216,9 @@ export function createAutomationHostQueryController( } return newest?.orphanCount ?? null }, + isDisposed: () => disposed, dispose: () => { + disposed = true unsubscribe?.() invalidation.dispose() scheduler.dispose() diff --git a/src/renderer/src/components/automations/automation-host-cache.test.ts b/src/renderer/src/components/automations/automation-host-cache.test.ts index b68f3144607..f1d87df29f5 100644 --- a/src/renderer/src/components/automations/automation-host-cache.test.ts +++ b/src/renderer/src/components/automations/automation-host-cache.test.ts @@ -157,6 +157,18 @@ describe('automation host cache invalidation scope', () => { expect(cache.commit(desktopFence, { rows: [row('c')] })).toBe(true) }) + it('drops the payload at retirement, so a revived host refetches over showing old rows', () => { + const cache = createCache() + cache.commit(cache.beginRequest(RUNTIME_SELF), { rows: [row('b')], orphanCount: 3 }) + cache.evict(hostStableKey(RUNTIME_SELF)) + cache.beginRequest(RUNTIME_SELF) + const revived = cache.getByKey(hostStableKey(RUNTIME_SELF)) + expect(revived?.data).toEqual([]) + expect(revived?.fetchedAt).toBeNull() + expect(revived?.orphanCount).toBeNull() + expect(cache.freshness(RUNTIME_SELF)).toBe('missing') + }) + it('evicts a departed entry and caps the retired pool', () => { const cache = createCache({ retiredLimit: 1 }) cache.commit(cache.beginRequest(DESKTOP_SELF), { rows: [row('a')] }) diff --git a/src/renderer/src/components/automations/automation-host-cache.ts b/src/renderer/src/components/automations/automation-host-cache.ts index d8b3da1525b..2cf3e31ef0c 100644 --- a/src/renderer/src/components/automations/automation-host-cache.ts +++ b/src/renderer/src/components/automations/automation-host-cache.ts @@ -277,6 +277,13 @@ export function createAutomationHostCache( return } bump(record) + // Retirement exists to fence, not to display: the generations stay, the + // payload goes, or the retired pool holds up to 256 stale row arrays. A + // revived host reads as missing and refetches instead of showing old rows. + record.data = [] + record.fetchedAt = null + record.error = null + record.orphanCount = null records.delete(stableKey) retired.set(stableKey, record) // Insertion order is LRU order here: reviving re-inserts at the end. diff --git a/src/renderer/src/components/automations/automation-host-client.test.ts b/src/renderer/src/components/automations/automation-host-client.test.ts index 266fbe62f92..a8a3eea7da1 100644 --- a/src/renderer/src/components/automations/automation-host-client.test.ts +++ b/src/renderer/src/components/automations/automation-host-client.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import type { Automation, AutomationCreateInput } from '../../../../shared/automations-types' import { - createAutomationForTarget, listAutomationRunsForTarget, listAutomationsForTarget, runAutomationNowForTarget, + toRuntimeAutomationCreateInput, updateAutomationForTarget } from './automation-host-client' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' @@ -127,7 +127,7 @@ describe('automation host client', () => { ) }) - it('uses an exact machine selector for an existing runtime workspace', async () => { + it('encodes exact machine selectors for the create wire input', () => { const automation = makeAutomation({ workspaceMode: 'existing', workspaceId: 'repo-1::/srv/orca' @@ -146,19 +146,15 @@ describe('automation host client', () => { rrule: automation.rrule, dtstart: automation.dtstart } - vi.mocked(callRuntimeRpc).mockResolvedValueOnce({ automation }) - await createAutomationForTarget(input) - - expect(callRuntimeRpc).toHaveBeenCalledWith( - { kind: 'environment', environmentId: 'gpu' }, - 'automation.create', - expect.objectContaining({ - repo: 'id:repo-1', - workspace: 'id:repo-1::/srv/orca' - }), - { timeoutMs: 15_000 } - ) + expect(toRuntimeAutomationCreateInput(input)).toMatchObject({ + repo: 'id:repo-1', + workspace: 'id:repo-1::/srv/orca' + }) + // A per-run workspace states no workspace selector at all. + expect( + toRuntimeAutomationCreateInput({ ...input, workspaceMode: 'new_per_run', workspaceId: null }) + ).toMatchObject({ repo: 'id:repo-1', workspace: undefined }) }) it('updates and manually runs SSH-host automations through the remote server that listed them', async () => { diff --git a/src/renderer/src/components/automations/automation-host-client.ts b/src/renderer/src/components/automations/automation-host-client.ts index 52c2d85e341..2b7c3b7c102 100644 --- a/src/renderer/src/components/automations/automation-host-client.ts +++ b/src/renderer/src/components/automations/automation-host-client.ts @@ -66,11 +66,8 @@ export function getAutomationOwnerTarget( return getAutomationTargetFromHostId(automation.runContext?.hostId) } -export function getAutomationCreateTarget(input: AutomationCreateInput): AutomationHostTarget { - return getAutomationTargetFromHostId(input.runContext?.hostId) -} - -function toRuntimeAutomationCreateInput( +/** Renames the desktop input's target fields to the wire contract every authority speaks. */ +export function toRuntimeAutomationCreateInput( input: AutomationCreateInput ): RuntimeAutomationCreateInput { const { projectId, workspaceId, ...rest } = input @@ -125,17 +122,6 @@ export async function listAutomationRunsForTarget( return result.runs } -export async function createAutomationForTarget(input: AutomationCreateInput): Promise { - const target = getAutomationCreateTarget(input) - const result = await callRuntimeRpc<{ automation: Automation }>( - target, - 'automation.create', - toRuntimeAutomationCreateInput(input), - { timeoutMs: 15_000 } - ) - return result.automation -} - export async function updateAutomationForTarget( automation: Automation, updates: AutomationUpdateInput, diff --git a/src/renderer/src/components/automations/automation-host-diagnostics.test.ts b/src/renderer/src/components/automations/automation-host-diagnostics.test.ts index cc49fee491c..3c13a79ec85 100644 --- a/src/renderer/src/components/automations/automation-host-diagnostics.test.ts +++ b/src/renderer/src/components/automations/automation-host-diagnostics.test.ts @@ -345,15 +345,18 @@ describe('automation host request counters', () => { }) describe('unpooled capability probes', () => { - beforeEach(() => { + beforeEach(async () => { callRuntimeRpc.mockReset() getRuntimeEnvironmentStatus.mockReset() automationHostDiagnostics.reset() + // Confirmed capabilities are module-level and must not leak between tests. + ;(await import('./automation-scoped-list-client')).resetAutomationCapabilityProbes() }) - // The probe rides outside the four-slot pool and always re-fetches, so an - // instrument blind to it would report half the relay traffic a refresh costs. - it('counts the status.get probe every runtime-scoped list rides on', async () => { + // The probe rides outside the four-slot pool, so an instrument blind to it + // would under-report relay traffic. It dedupes per authority incarnation: + // both hosts of this authority ride on one status.get. + it('counts the status.get probe runtime-scoped lists ride on', async () => { const { cache } = harness() getRuntimeEnvironmentStatus.mockResolvedValue({ capabilities: [AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY] @@ -380,9 +383,9 @@ describe('unpooled capability probes', () => { const authority = automationHostDiagnostics.snapshot().byAuthority[authorityKeyOf(RUNTIME_AUTHORITY)] - expect(getRuntimeEnvironmentStatus).toHaveBeenCalledTimes(2) - // Two pooled list calls, and two round trips the pool never sees. - expect(authority).toMatchObject({ requests: 2, capabilityProbes: 2 }) + expect(getRuntimeEnvironmentStatus).toHaveBeenCalledTimes(1) + // Two pooled list calls, and the one shared round trip the pool never sees. + expect(authority).toMatchObject({ requests: 2, capabilityProbes: 1 }) }) }) diff --git a/src/renderer/src/components/automations/automation-host-diagnostics.ts b/src/renderer/src/components/automations/automation-host-diagnostics.ts index 7428cf7d21a..b514c9c7986 100644 --- a/src/renderer/src/components/automations/automation-host-diagnostics.ts +++ b/src/renderer/src/components/automations/automation-host-diagnostics.ts @@ -25,8 +25,9 @@ export type AutomationHostKeyCounters = { legacyRequests: number /** * Unpooled `status.get` capability probes, which ride outside the four-slot - * pool and always re-fetch. Relay traffic is `requests + capabilityProbes`; - * the in-flight ceiling is stated over `requests` alone. + * pool; probes dedupe per authority incarnation, so this counts what was + * actually sent. Relay traffic is `requests + capabilityProbes`; the + * in-flight ceiling is stated over `requests` alone. */ capabilityProbes: number /** Callers that joined a request already in flight, keyed like `requests`. */ diff --git a/src/renderer/src/components/automations/automation-host-scale-gates.test.ts b/src/renderer/src/components/automations/automation-host-scale-gates.test.ts index e85ed6d1ae5..5bec80e92fa 100644 --- a/src/renderer/src/components/automations/automation-host-scale-gates.test.ts +++ b/src/renderer/src/components/automations/automation-host-scale-gates.test.ts @@ -34,6 +34,7 @@ import { type AutomationHostFetchTarget } from './automation-host-scheduler' import { AUTOMATION_HOST_REQUEST_CONCURRENCY } from './automation-host-scheduler-queue' +import { resetAutomationCapabilityProbes } from './automation-scoped-list-client' const callRuntimeRpc = vi.fn() const getRuntimeEnvironmentStatus = vi.fn() @@ -254,7 +255,10 @@ function watchLongTasks(): () => number { } } -function harness(): { cache: AutomationHostCache; refresh: () => Promise } { +function harness(): { + cache: AutomationHostCache + refresh: (options?: { force?: boolean }) => Promise +} { const cache = createAutomationHostCache({ catalogGeneration: () => 0, connectionGeneration: () => 0 @@ -265,7 +269,7 @@ function harness(): { cache: AutomationHostCache; refresh: () => Promise } isVisible: () => true, scheduleRetry: () => () => {} }) - return { cache, refresh: () => scheduler.refresh(ALL_REFS.map(targetFor)) } + return { cache, refresh: (options) => scheduler.refresh(ALL_REFS.map(targetFor), options) } } function snapshot(): AutomationHostDiagnosticsSnapshot { @@ -278,6 +282,8 @@ function authorityCounters(snap: AutomationHostDiagnosticsSnapshot, index: numbe beforeEach(() => { automationHostDiagnostics.reset() + // Confirmed capabilities are module-level and must not leak between tests. + resetAutomationCapabilityProbes() wire = { inFlight: 0, maxInFlight: 0, @@ -384,28 +390,41 @@ describe('one refresh of 50 hosts carrying 1,000 automations', () => { }) describe('relay cost the request pool cannot see', () => { - // The probe is deliberate and unpooled, so the honest number for a 50-host - // refresh is requests plus probes — not the pooled count on its own. - it('counts one capability probe for every scoped list, and none for a legacy one', async () => { + // The probe is deliberate and unpooled, but it dedupes per authority + // incarnation: concurrent callers share the in-flight status.get and a + // confirmed capability is never re-asked, so a 50-host refresh probes each + // scoped authority once — not once per host. + it('counts one capability probe per scoped authority, and none for a legacy one', async () => { const { refresh } = harness() await refresh() const snap = snapshot() const scopedEntries = SCOPED_AUTHORITIES * ENTRIES_PER_AUTHORITY - expect(wire.probes).toBe(scopedEntries) - expect(snap.totals.capabilityProbes).toBe(scopedEntries) + expect(wire.probes).toBe(SCOPED_AUTHORITIES) + expect(snap.totals.capabilityProbes).toBe(SCOPED_AUTHORITIES) for (let index = 0; index < AUTHORITY_COUNT; index += 1) { - expect(authorityCounters(snap, index).capabilityProbes).toBe( - isLegacyAuthority(index) ? 0 : ENTRIES_PER_AUTHORITY - ) + expect(authorityCounters(snap, index).capabilityProbes).toBe(isLegacyAuthority(index) ? 0 : 1) } - // Scoped hosts cost two round trips each, so the pooled count alone reports - // barely half the traffic this refresh actually put on the relay. expect(snap.totals.requests + snap.totals.capabilityProbes).toBe( - scopedEntries * 2 + (AUTHORITY_COUNT - SCOPED_AUTHORITIES) + scopedEntries + SCOPED_AUTHORITIES + (AUTHORITY_COUNT - SCOPED_AUTHORITIES) ) }) + + // The cache is per incarnation, so even a forced second refresh — which + // re-fetches every list — costs no probes at all. + it('does not probe again on a later refresh of the same incarnations', async () => { + const { refresh } = harness() + + await refresh() + const probesAfterFirst = wire.probes + const scopedCallsAfterFirst = wire.scopedCalls + await refresh({ force: true }) + + expect(probesAfterFirst).toBe(SCOPED_AUTHORITIES) + expect(wire.probes).toBe(probesAfterFirst) + expect(wire.scopedCalls).toBeGreaterThan(scopedCallsAfterFirst) + }) }) describe('the commit fence under real concurrency', () => { diff --git a/src/renderer/src/components/automations/automation-scoped-list-client.test.ts b/src/renderer/src/components/automations/automation-scoped-list-client.test.ts index c5e2efe1b07..e1bf8873e97 100644 --- a/src/renderer/src/components/automations/automation-scoped-list-client.test.ts +++ b/src/renderer/src/components/automations/automation-scoped-list-client.test.ts @@ -31,9 +31,11 @@ const ALL_CAPABILITIES = { ] } -beforeEach(() => { +beforeEach(async () => { callRuntimeRpc.mockReset() getRuntimeEnvironmentStatus.mockReset() + // Confirmed capabilities are module-level and must not leak between tests. + ;(await client()).resetAutomationCapabilityProbes() }) async function client() { @@ -133,6 +135,113 @@ describe('listScopedAutomations', () => { }) }) +describe('capability probe dedupe', () => { + it('shares one in-flight probe across concurrent calls to the same incarnation', async () => { + const { listScopedAutomations } = await client() + getRuntimeEnvironmentStatus.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + return ALL_CAPABILITIES + }) + callRuntimeRpc.mockResolvedValue({ automations: [], items: [], orphanCount: 0 }) + await Promise.all([ + listScopedAutomations(RUNTIME, { kind: 'self' }), + listScopedAutomations(RUNTIME, { kind: 'orphan' }) + ]) + expect(getRuntimeEnvironmentStatus).toHaveBeenCalledTimes(1) + }) + + it('never re-asks about a capability the incarnation already confirmed', async () => { + const { listScopedAutomations, updateAutomationForOwner } = await client() + getRuntimeEnvironmentStatus.mockResolvedValue(ALL_CAPABILITIES) + callRuntimeRpc.mockResolvedValue({ + automations: [], + items: [], + orphanCount: 0, + automation: { id: 'a1' } + }) + await listScopedAutomations(RUNTIME, { kind: 'self' }) + await listScopedAutomations(RUNTIME, { kind: 'orphan' }) + // A different capability confirmed by the same status answer is also cached. + await updateAutomationForOwner(SSH_OWNER, 'a1', { enabled: false }) + // Mutations intentionally re-probe: owner fencing must not trust a positive + // answer after an in-place runtime replacement under the same pairing. + expect(getRuntimeEnvironmentStatus).toHaveBeenCalledTimes(2) + }) + + it('re-asks after a re-pair rather than trusting the old incarnation', async () => { + const { listScopedAutomations } = await client() + getRuntimeEnvironmentStatus.mockResolvedValue(ALL_CAPABILITIES) + callRuntimeRpc.mockResolvedValue({ automations: [], items: [], orphanCount: 0 }) + await listScopedAutomations(RUNTIME, { kind: 'self' }) + await listScopedAutomations({ ...RUNTIME, pairingRevision: 5 }, { kind: 'self' }) + expect(getRuntimeEnvironmentStatus).toHaveBeenCalledTimes(2) + }) + + it('does not cache an absence, so an upgraded server recovers without a re-pair', async () => { + const { listScopedAutomations, AutomationHostScopeUnsupportedError } = await client() + getRuntimeEnvironmentStatus.mockResolvedValueOnce({ capabilities: [] }) + await expect(listScopedAutomations(RUNTIME, { kind: 'self' })).rejects.toBeInstanceOf( + AutomationHostScopeUnsupportedError + ) + getRuntimeEnvironmentStatus.mockResolvedValue(ALL_CAPABILITIES) + callRuntimeRpc.mockResolvedValue({ automations: [], items: [], orphanCount: 0 }) + await expect(listScopedAutomations(RUNTIME, { kind: 'self' })).resolves.toBeTruthy() + expect(getRuntimeEnvironmentStatus).toHaveBeenCalledTimes(2) + }) + + // The fence depends on this: a server downgraded in place ignores + // `expectedOwner`, so a confirmation must not outlive its observation window. + it('re-asks once a confirmation ages out', async () => { + vi.useFakeTimers() + try { + const { listScopedAutomations, AUTHORITY_CAPABILITY_CONFIRMATION_TTL_MS } = await client() + getRuntimeEnvironmentStatus.mockResolvedValue(ALL_CAPABILITIES) + callRuntimeRpc.mockResolvedValue({ automations: [], items: [], orphanCount: 0 }) + await listScopedAutomations(RUNTIME, { kind: 'self' }) + vi.advanceTimersByTime(AUTHORITY_CAPABILITY_CONFIRMATION_TTL_MS) + await listScopedAutomations(RUNTIME, { kind: 'self' }) + expect(getRuntimeEnvironmentStatus).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it('does not cache a failed probe, so the next call retries the host', async () => { + const { listScopedAutomations } = await client() + getRuntimeEnvironmentStatus.mockRejectedValueOnce(new Error('runtime_unavailable')) + await expect(listScopedAutomations(RUNTIME, { kind: 'self' })).rejects.toThrow( + 'runtime_unavailable' + ) + getRuntimeEnvironmentStatus.mockResolvedValue(ALL_CAPABILITIES) + callRuntimeRpc.mockResolvedValue({ automations: [], items: [], orphanCount: 0 }) + await expect(listScopedAutomations(RUNTIME, { kind: 'self' })).resolves.toBeTruthy() + expect(getRuntimeEnvironmentStatus).toHaveBeenCalledTimes(2) + }) + + it('bounds confirmed capability entries across retired environment incarnations', async () => { + const { listScopedAutomations } = await client() + getRuntimeEnvironmentStatus.mockResolvedValue(ALL_CAPABILITIES) + callRuntimeRpc.mockResolvedValue({ automations: [], items: [], orphanCount: 0 }) + + for (let index = 0; index < 32; index += 1) { + await listScopedAutomations( + { kind: 'runtime', environmentId: `env-${index}`, pairingRevision: 1 }, + { kind: 'self' } + ) + } + await listScopedAutomations( + { kind: 'runtime', environmentId: 'env-overflow', pairingRevision: 1 }, + { kind: 'self' } + ) + await listScopedAutomations( + { kind: 'runtime', environmentId: 'env-0', pairingRevision: 1 }, + { kind: 'self' } + ) + + expect(getRuntimeEnvironmentStatus).toHaveBeenCalledTimes(34) + }) +}) + describe('owner-fenced mutations', () => { it('sends the captured owner with the mutation', async () => { const { updateAutomationForOwner } = await client() diff --git a/src/renderer/src/components/automations/automation-scoped-list-client.ts b/src/renderer/src/components/automations/automation-scoped-list-client.ts index 3f649dcbdc0..ec44ac74ece 100644 --- a/src/renderer/src/components/automations/automation-scoped-list-client.ts +++ b/src/renderer/src/components/automations/automation-scoped-list-client.ts @@ -9,11 +9,7 @@ * that would attribute other hosts' automations to the selected one. */ -import { - callRuntimeRpc, - getRuntimeEnvironmentStatus, - type RuntimeClientTarget -} from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import type { Automation, AutomationCreateInput, @@ -36,26 +32,24 @@ import type { AutomationOwnerPrecondition } from '../../../../shared/automation-owner-precondition' import { + toRuntimeAutomationCreateInput, + toRuntimeAutomationUpdateInput +} from './automation-host-client' +import { + assertAuthorityCapability, + assertOwnerFencingSupported, AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE, - AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, - AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE, - type RuntimeCapability -} from '../../../../shared/protocol-version' -import { automationAuthorityCatalogKey } from './automation-host-catalog-types' -import { toRuntimeAutomationUpdateInput } from './automation-host-client' -import { automationHostDiagnostics } from './automation-host-diagnostics' + AutomationHostScopeUnsupportedError, + REQUEST_TIMEOUT_MS +} from './automation-capability-probe' -const REQUEST_TIMEOUT_MS = 15_000 - -export class AutomationHostScopeUnsupportedError extends Error { - readonly code = 'unsupported_host_scope' - - constructor(message: string) { - super(message) - this.name = 'AutomationHostScopeUnsupportedError' - } -} +export { + AutomationHostScopeUnsupportedError, + AUTHORITY_CAPABILITY_CONFIRMATION_TTL_MS, + AUTHORITY_CAPABILITY_CONFIRMATION_MAX, + resetAutomationCapabilityProbes +} from './automation-capability-probe' export class AutomationListResponseError extends Error { readonly code = 'invalid_response' @@ -119,41 +113,6 @@ async function callAuthority( }) } -/** - * Fails closed on a missing capability, but only on a *known* absence: an - * unreachable authority must classify as unavailable and retry, not as an old - * server the user is told to upgrade. - * - * The probe is counted here because it is counted nowhere else: it deliberately - * re-fetches on every call and rides outside the scheduler's four-slot pool, so - * an instrument that saw only pooled work would report half the relay traffic a - * 50-host refresh actually costs. - */ -async function assertAuthorityCapability( - authority: AutomationAuthorityRef, - capability: RuntimeCapability, - message: string -): Promise { - if (authority.kind !== 'runtime') { - return - } - automationHostDiagnostics.recordCapabilityProbe({ - authorityKey: automationAuthorityCatalogKey(authority) - }) - const status = await getRuntimeEnvironmentStatus(authority.environmentId, REQUEST_TIMEOUT_MS) - if (!status.capabilities?.includes(capability)) { - throw new AutomationHostScopeUnsupportedError(message) - } -} - -async function assertOwnerFencingSupported(authority: AutomationAuthorityRef): Promise { - await assertAuthorityCapability( - authority, - AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, - AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE - ) -} - function validated(raw: unknown, selector: AutomationListScopeSelector): ScopedAutomationList { const validation = validateAutomationListResponse(raw, selector) if (!validation.ok) { @@ -328,11 +287,8 @@ export async function createAutomationForDestination( destination: AutomationDestination ): Promise { await assertOwnerFencingSupported(authority) - const { projectId, workspaceId, ...rest } = input const result = await callAuthority<{ automation: Automation }>(authority, 'automation.create', { - ...rest, - repo: projectId, - workspace: input.workspaceMode === 'existing' ? (workspaceId ?? undefined) : undefined, + ...toRuntimeAutomationCreateInput(input), destination }) return result.automation diff --git a/src/renderer/src/components/automations/automation-target-availability.test.ts b/src/renderer/src/components/automations/automation-target-availability.test.ts index 23264de4b53..2b94251144a 100644 --- a/src/renderer/src/components/automations/automation-target-availability.test.ts +++ b/src/renderer/src/components/automations/automation-target-availability.test.ts @@ -5,7 +5,6 @@ import type { ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import { getAutomationTargetAvailability } from './automation-target-availability' -import { getAutomationCreateAvailability } from './automation-create-admission' function makeAutomation(overrides: Partial = {}): Automation { return { @@ -88,21 +87,6 @@ function makeRuntimeStatus(overrides: Partial = {}): RuntimeStatu } describe('automation target availability', () => { - it('blocks create admission for a missing or degraded runtime', () => { - const target = { kind: 'environment' as const, environmentId: 'env-1' } - expect(getAutomationCreateAvailability({ automationHostTarget: target }).reason).toBe( - 'runtime-checking' - ) - expect( - getAutomationCreateAvailability({ - automationHostTarget: target, - runtimeStatusByEnvironmentId: new Map([ - ['env-1', { status: makeRuntimeStatus({ graphStatus: 'unavailable' }), checkedAt: 1 }] - ]) - }).reason - ).toBe('runtime-unavailable') - }) - it('allows local automations with an available existing workspace', () => { expect( getAutomationTargetAvailability({ diff --git a/src/renderer/src/components/automations/automations-page-runtime-fixtures.ts b/src/renderer/src/components/automations/automations-page-runtime-fixtures.ts index 6b71a2364e2..61d62d03f0f 100644 --- a/src/renderer/src/components/automations/automations-page-runtime-fixtures.ts +++ b/src/renderer/src/components/automations/automations-page-runtime-fixtures.ts @@ -1,3 +1,4 @@ +import type { Automation } from '../../../../shared/automations-types' import type { ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' @@ -5,6 +6,18 @@ import type { Worktree } from '../../../../shared/worktree/types' export const RUNTIME_REPO_ID = 'repo-2' export const RUNTIME_WORKSPACE_ID = 'workspace-2' +/** Builds the host-scoped response used by the page harness. */ +export function selfScopedList(automations: Automation[]): Record { + return { + automations, + items: automations.map((automation) => ({ + automationId: automation.id, + selector: { kind: 'self' } + })), + orphanCount: 0 + } +} + type RuntimeFixtureMocks = { state: Record repoMap: Map diff --git a/src/renderer/src/components/automations/automations-page-test-harness.tsx b/src/renderer/src/components/automations/automations-page-test-harness.tsx index c7d3b8ac158..815338927c6 100644 --- a/src/renderer/src/components/automations/automations-page-test-harness.tsx +++ b/src/renderer/src/components/automations/automations-page-test-harness.tsx @@ -12,7 +12,7 @@ * guarantees. */ -import { act, type ReactNode } from 'react' +import { act, createElement, StrictMode, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, type Mock, vi } from 'vitest' import type { Automation, AutomationRun } from '../../../../shared/automations-types' @@ -25,10 +25,12 @@ import type { AutomationHostCatalogView } from './use-automation-host-catalog' import type { AutomationCreateDestinationControl } from './use-automation-create-destination' import type { ExternalAutomationListEntry } from './external-automation-list-entries' import type { AutomationListRow } from './automation-list-row-identity' +import { resetAutomationCapabilityProbes } from './automation-scoped-list-client' import { addRuntimeProject as addRuntimeProjectFixture, RUNTIME_REPO_ID as RUNTIME_REPO_ID_FIXTURE, - RUNTIME_WORKSPACE_ID as RUNTIME_WORKSPACE_ID_FIXTURE + RUNTIME_WORKSPACE_ID as RUNTIME_WORKSPACE_ID_FIXTURE, + selfScopedList } from './automations-page-runtime-fixtures' export const RUNTIME_REPO_ID = RUNTIME_REPO_ID_FIXTURE @@ -69,6 +71,7 @@ export type ListPanelProps = { toggleAutomation: (row: AutomationListRow) => void requestDeleteAutomation: (row: AutomationListRow) => void openCreateDialog: () => void + canCreateAutomation: boolean } export type DetailPaneProps = { @@ -309,18 +312,6 @@ export const RUNTIME_SELF_FILTER = { } } -/** The scoped-list shape every self-owned host answers with. */ -function selfScopedList(automations: Automation[]): Record { - return { - automations, - items: automations.map((automation) => ({ - automationId: automation.id, - selector: { kind: 'self' } - })), - orphanCount: 0 - } -} - /** The local authority's automation RPC surface, served from the same programmable * `api.automations` mocks; an environment target answers from `runtimeHost()` state. */ async function answerAutomationRpc( @@ -379,7 +370,7 @@ export function scopedList(automations: Automation[]): void { const roots: Root[] = [] -export async function renderPage(): Promise<{ +export async function renderPage(options?: { strict?: boolean }): Promise<{ container: HTMLDivElement rerender: () => Promise }> { @@ -389,7 +380,12 @@ export async function renderPage(): Promise<{ roots.push(root) const rerender = async (): Promise => { await act(async () => { - root.render() + // Strict mounts double-invoke effects the way the dev app does, which is + // where a dispose-without-revive lifecycle bug becomes visible. + const page = options?.strict + ? createElement(StrictMode, null, createElement(AutomationsPage)) + : createElement(AutomationsPage) + root.render(page) }) } await rerender() @@ -434,6 +430,8 @@ export function installAutomationsPageHarness(): void { beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true vi.clearAllMocks() + // Confirmed capabilities are module-level and must not leak between tests. + resetAutomationCapabilityProbes() // A prior test's wholesale mockImplementation must not leak forward. mocks.callRuntimeRpc.mockReset() mocks.callRuntimeRpc.mockImplementation(answerAutomationRpc) diff --git a/src/renderer/src/components/automations/use-automation-host-catalog.ts b/src/renderer/src/components/automations/use-automation-host-catalog.ts index 3d358e44584..bd7cd8dbb67 100644 --- a/src/renderer/src/components/automations/use-automation-host-catalog.ts +++ b/src/renderer/src/components/automations/use-automation-host-catalog.ts @@ -5,6 +5,7 @@ import { getLocalExecutionHostLabel } from '../../../../shared/execution-host' import type { AutomationHostFilter } from '../../../../shared/automation-host-filter' import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref' import { buildAutomationHostCatalog } from './automation-host-catalog' +import { subscribeAutomationHostInvalidation } from './automation-host-invalidation-window-events' import { buildAutomationHostCatalogSource } from './automation-host-catalog-source' import { automationHostLoadCounts, @@ -100,20 +101,42 @@ export function useAutomationHostCatalog( const repoTables = useMemo(() => groupReposByAutomationAuthority(repos), [repos]) const repoTablesRef = useRef(repoTables) repoTablesRef.current = repoTables - const [controller] = useState(() => - createAutomationHostQueryController({ - // Scoped to the answering authority: another host's identically named repo - // is not evidence about this one, and its absence is not evidence either. - legacyPartitionContext: (authority) => - automationAuthorityPartitionContext(repoTablesRef.current, authority) - }) + const makeController = useCallback( + () => + createAutomationHostQueryController({ + // Scoped to the answering authority: another host's identically named repo + // is not evidence about this one, and its absence is not evidence either. + legacyPartitionContext: (authority) => + automationAuthorityPartitionContext(repoTablesRef.current, authority), + // Wired by the lifecycle effect below instead: a controller built during + // render must hold no window subscription, or StrictMode's doubled + // initializer leaks a live listener per mount. + eventTarget: null + }), + [] ) + const [controller, setController] = useState(makeController) + // Create-in-render, dispose-in-cleanup is asymmetric under StrictMode's + // simulated unmount: the cleanup disposes the only controller, and a disposed + // controller silently drops every write invalidation — the list then never + // shows a create until the app reloads. The effect owns the whole lifecycle: + // it replaces a disposed controller and unsubscribes before disposing. + useEffect(() => { + if (controller.isDisposed()) { + setController(makeController()) + return + } + const unsubscribe = subscribeAutomationHostInvalidation(controller.handleAuthorityEvent) + return () => { + unsubscribe() + controller.dispose() + } + }, [controller, makeController]) const [cacheVersion, setCacheVersion] = useState(0) useEffect(() => { return controller.cache.subscribe(() => setCacheVersion((version) => version + 1)) }, [controller]) - useEffect(() => () => controller.dispose(), [controller]) const orphanCount = useCallback( (authority: StableAutomationAuthorityRef) => controller.authorityOrphanCount(authority), diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a4743b65757..ac4dae956b3 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -15402,7 +15402,6 @@ "rowActions": "Automation actions", "tableLastRun": "Last run", "noListMatches": "No automations match.", - "destinationUnavailable": "The selected automation destination is not ready. Reconnect it and try again.", "destinationProjectUnavailable": "Choose a project owned by the selected automation destination." }, "AutomationsPageSkeleton": { @@ -15666,7 +15665,8 @@ "unavailable": "That host cannot hold a new automation yet. Choose another host to create this one on.", "stale": "{host} changed while this form was open. Choose it again before saving.", "projectMismatch": "That project is not on {host}. Choose a project on that host, or another host.", - "noProjects": "No projects are set up on {host}. Add one there, or choose another host." + "noProjects": "No projects are set up on {host}. Add one there, or choose another host.", + "updateRequired": "Update the Orca server on {hosts} to create automations there." } }, "agent": { From a624e7cd5df69f54910141c81afa88cbde89cd62 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:33:02 -0700 Subject: [PATCH 014/152] test(agent-status): inventory legacy pane identity surfaces (#16575) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agent-status): measure identity evidence before migrating any consumer PR 1 of the identity migration. It changes no displayed or routed identity — it only measures. Why measure first: the hierarchy shipped in #16148/#16157 has zero consumers, while ~31 sites still derive identity independently. Every migration decision after this is currently a guess, including the one that matters most — how often a real pane has no evidence at all. A live P0 reports "No Claude status shown", and this design trades toward showing nothing when uncertain, so the blank rate has to be a number before any surface moves. - `pane-agent-identity-evidence.ts` — one assembler that gathers a pane's evidence, so consumers stop each inventing their own ladder. - `pane-agent-identity-census.ts` — shadow-only counters keyed by host kind (native / wsl-host / wsl-distro / ssh / relay) and launch mode (typed / orca-launch / resume). Records a bitmask of which sources were present and whether the resolver returned null or ambiguous. No titles, prompts, paths, handles, or agent text. - `pane-agent-identity-inventory.test.ts` — a ratchet that fails when a legacy identity helper gains a new production caller, so the surface cannot grow while the migration runs. Three review findings are encoded rather than deferred: launch stays above run-key-less completed hooks (promoting the hook lets a stale record hijack a pane); OMP/Pi evidence is owner-normalized before assembly, since OMP emits Pi-compatible frames and a wrapper's hook would otherwise be read as the agent it wraps; and Windows-side `wsl.exe` is rejected as process evidence, because the host observes the distro wrapper rather than the agent inside it. The census cannot be completed from a worktree. It needs representative native, SSH, WSL and relay cohorts collected from real use, and that review is the gate on PR 3 — not this PR. * test(agent-status): keep identity migration inventory-only * test(agent-status): reuse reliable source scanner * test(agent-status): bound inventory scan work * test(agent-status): avoid inventory path false negatives * test(agent-status): refresh identity inventory after base repair * test(agent-status): correct inventory classifications * test(agent-status): correct action boundary inventory * test(agent-status): pin inventory occurrence counts * test(agent-status): fail closed on scanner desync --- src/renderer/src/lib/pane-agent-evidence.ts | 5 - .../pane-agent-identity-inventory.test.ts | 363 ++++++++++++++++++ .../pane-agent-identity-resolver.test.ts | 8 + 3 files changed, 371 insertions(+), 5 deletions(-) create mode 100644 src/shared/pane-agent-identity-inventory.test.ts diff --git a/src/renderer/src/lib/pane-agent-evidence.ts b/src/renderer/src/lib/pane-agent-evidence.ts index 8230bb51522..cfed24bfa33 100644 --- a/src/renderer/src/lib/pane-agent-evidence.ts +++ b/src/renderer/src/lib/pane-agent-evidence.ts @@ -115,8 +115,3 @@ export function resolvePaneAgentActivity( livePtyRequired: false } } - -// Deliberately absent: a resolvePaneAgentOwner precedence resolver. The only -// Phase 2 identity consumer (native-chat toggle) reads hook identity without a -// freshness gate, so a gated owner resolver would change its behavior; the -// owner resolver lands with its first real consumer in a later slice. diff --git a/src/shared/pane-agent-identity-inventory.test.ts b/src/shared/pane-agent-identity-inventory.test.ts new file mode 100644 index 00000000000..730c6b0ce05 --- /dev/null +++ b/src/shared/pane-agent-identity-inventory.test.ts @@ -0,0 +1,363 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { glob } from 'tinyglobby' +import { + blankStringContents, + blankStringContentsDesynced, + isTestFile, + stripComments +} from './source-scan/source-tree-scan' + +const HELPERS = [ + 'getAgentLabel', + 'isClaudeAgent', + 'titleHasAgentName', + 'buildAgentNameRe', + 'resolveTerminalTitleAgentType', + 'resolveExplicitTerminalTitleAgentType', + 'resolveCommittedTitleAgentType', + 'resolvePaneAgentOwner', + 'resolveCompatibleAgentTypeForOwner' +] as const + +const TEST_SUPPORT_PATHS = new Set([ + 'src/renderer/src/components/terminal-pane/pty-connection-test-environment.ts' +]) + +type Helper = (typeof HELPERS)[number] +type InventoryPath = string | readonly [path: string, occurrences: number] +type Classification = + | 'parser-implementation' + | 'activity-only' + | 'enum-formatter' + | 'evidence-producer' + | 'identity-consumer' + | 'action-consumer' + +type InventoryGroup = { + helper: Helper + classification: Classification + paths: readonly InventoryPath[] +} + +const INVENTORY: readonly InventoryGroup[] = [ + { + helper: 'getAgentLabel', + classification: 'enum-formatter', + paths: [ + [ + 'src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx', + 2 + ], + ['src/renderer/src/components/automations/AutomationListLocalRows.tsx', 2], + 'src/renderer/src/components/automations/automation-draft-model.ts', + ['src/renderer/src/components/automations/automation-list-search-rows.ts', 2], + ['src/renderer/src/components/dashboard-popout/AgentMapSnapshotWorkspaceMenu.tsx', 2], + ['src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx', 2], + ['src/renderer/src/components/settings/QuickCommandsList.tsx', 2], + ['src/renderer/src/components/tab-bar/TabBarQuickCommandItem.tsx', 2], + ['src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.tsx', 2], + 'src/renderer/src/lib/agent-catalog.tsx', + ['src/renderer/src/lib/launch-agent-session-continuation.ts', 3], + ['src/renderer/src/lib/orchestration-skill-coverage.ts', 2] + ] + }, + { + helper: 'getAgentLabel', + classification: 'activity-only', + paths: [['src/renderer/src/lib/pane-agent-evidence.ts', 2]] + }, + { + helper: 'getAgentLabel', + classification: 'parser-implementation', + paths: [ + 'src/renderer/src/lib/agent-status.ts', + 'src/shared/agent-detection.ts', + 'src/shared/agent-title-identity.ts', + ['src/shared/agent-title-owner.ts', 2], + ['src/shared/terminal-title-agent-type.ts', 2] + ] + }, + { + helper: 'isClaudeAgent', + classification: 'action-consumer', + paths: [ + ['src/renderer/src/components/terminal-pane/cache-timer-seeding.ts', 2], + ['src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts', 2], + ['src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-notify.ts', 2], + ['src/renderer/src/store/terminals/terminal-ephemeral-state.ts', 2] + ] + }, + { + helper: 'isClaudeAgent', + classification: 'action-consumer', + paths: [ + ['src/renderer/src/components/terminal-pane/pty-connection/command-inferred-pane-agent.ts', 2] + ] + }, + { + helper: 'isClaudeAgent', + classification: 'parser-implementation', + paths: [ + 'src/renderer/src/lib/agent-status.ts', + 'src/shared/agent-detection.ts', + ['src/shared/agent-title-identity.ts', 2], + ['src/shared/terminal-title-agent-type.ts', 2] + ] + }, + { + helper: 'titleHasAgentName', + classification: 'parser-implementation', + paths: [ + 'src/shared/agent-detection.ts', + 'src/shared/agent-name-token-match.ts', + ['src/shared/agent-title-core.ts', 4], + ['src/shared/agent-title-evidence.ts', 2], + ['src/shared/agent-title-identity.ts', 11], + ['src/shared/terminal-title-agent-type.ts', 14] + ] + }, + { + helper: 'titleHasAgentName', + classification: 'evidence-producer', + paths: [['src/renderer/src/hooks/ipc-events/agent-status-routing.ts', 2]] + }, + { + helper: 'buildAgentNameRe', + classification: 'action-consumer', + paths: [['src/main/runtime/orchestration/groups.ts', 2]] + }, + { + helper: 'buildAgentNameRe', + classification: 'parser-implementation', + paths: [['src/shared/agent-name-token-match.ts', 2]] + }, + { + helper: 'resolveTerminalTitleAgentType', + classification: 'identity-consumer', + paths: [['src/renderer/src/lib/notes-send-agent-targets.ts', 2]] + }, + { + helper: 'resolveTerminalTitleAgentType', + classification: 'parser-implementation', + paths: [['src/shared/terminal-title-agent-type.ts', 2]] + }, + { + helper: 'resolveExplicitTerminalTitleAgentType', + classification: 'identity-consumer', + paths: [ + ['mobile/src/session/mobile-terminal-tab-agent.ts', 2], + ['src/renderer/src/lib/open-tab-occupant-agent.ts', 2], + ['src/renderer/src/lib/use-tab-agent.ts', 3] + ] + }, + { + helper: 'resolveExplicitTerminalTitleAgentType', + classification: 'parser-implementation', + paths: [ + ['src/renderer/src/lib/pane-agent-evidence.ts', 2], + 'src/shared/terminal-title-agent-type.ts' + ] + }, + { + helper: 'resolveCommittedTitleAgentType', + classification: 'action-consumer', + paths: [ + ['src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts', 3], + ['src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts', 2], + ['src/renderer/src/components/terminal-pane/terminal-ctrl-enter.ts', 2], + ['src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.ts', 2], + ['src/renderer/src/components/terminal-pane/use-notification-dispatch.ts', 2] + ] + }, + { + helper: 'resolveCommittedTitleAgentType', + classification: 'identity-consumer', + paths: [ + ['src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx', 3], + ['src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts', 4], + ['src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts', 2] + ] + }, + { + helper: 'resolveCommittedTitleAgentType', + classification: 'parser-implementation', + paths: ['src/renderer/src/lib/pane-agent-evidence.ts'] + }, + { + helper: 'resolveCommittedTitleAgentType', + classification: 'action-consumer', + paths: [ + ['src/renderer/src/components/terminal-pane/pty-connection/command-inferred-pane-agent.ts', 2] + ] + }, + { + helper: 'resolvePaneAgentOwner', + classification: 'parser-implementation', + paths: ['src/shared/pane-agent-owner.ts'] + }, + { + helper: 'resolvePaneAgentOwner', + classification: 'evidence-producer', + paths: [['src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts', 2]] + }, + { + helper: 'resolvePaneAgentOwner', + classification: 'identity-consumer', + paths: [ + ['src/main/runtime/orca-runtime.ts', 3], + ['src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts', 2], + ['src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts', 2], + ['src/renderer/src/lib/use-tab-agent.ts', 2], + ['src/renderer/src/runtime/web-session-tabs-sync.ts', 3] + ] + }, + { + helper: 'resolveCompatibleAgentTypeForOwner', + classification: 'parser-implementation', + paths: [['src/shared/agent-title-owner.ts', 2]] + }, + { + helper: 'resolveCompatibleAgentTypeForOwner', + classification: 'identity-consumer', + paths: [ + ['src/main/runtime/orca-runtime.ts', 3], + ['src/renderer/src/components/sidebar/worktree-agent-rows.ts', 2], + ['src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts', 2], + ['src/renderer/src/lib/use-tab-agent.ts', 2] + ] + }, + { + helper: 'resolveCompatibleAgentTypeForOwner', + classification: 'action-consumer', + paths: [ + ['src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-notify.ts', 2], + [ + 'src/renderer/src/components/terminal-pane/pty-connection/command-inferred-pane-agent.ts', + 3 + ], + ['src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts', 3], + ['src/renderer/src/components/terminal-pane/use-notification-dispatch.ts', 2] + ] + }, + { + helper: 'resolveCompatibleAgentTypeForOwner', + classification: 'evidence-producer', + paths: [ + ['src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-status.ts', 2], + ['src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts', 2] + ] + } +] + +const DIRECT_SINGLE_SOURCE_SURFACES: readonly { + path: string + classification: Classification + marker: string +}[] = [ + { + path: 'src/renderer/src/components/terminal-pane/terminal-renderer-policy.ts', + classification: 'identity-consumer', + marker: 'resolveGeminiCompatFallback' + }, + { + path: 'src/renderer/src/components/terminal-pane/terminal-title-evidence.ts', + classification: 'identity-consumer', + marker: 'resolvePaneTitleDecision' + }, + { + path: 'src/renderer/src/components/terminal/terminal-close-copy-kind.ts', + classification: 'identity-consumer', + marker: 'resolveLeafCloseCopyKind' + }, + { + path: 'src/main/runtime/orchestration/mailbox-pointer-delivery.ts', + classification: 'action-consumer', + marker: 'isCursorAgentTitle' + }, + { + path: 'src/main/providers/local-pty-provider.ts', + classification: 'action-consumer', + marker: 'launchAgent' + }, + { + path: 'src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts', + classification: 'action-consumer', + marker: 'sendStartupDraftPaste' + }, + { + path: 'src/renderer/src/lib/active-agent-note-send.ts', + classification: 'action-consumer', + marker: 'sendNotesToActiveAgentSession' + }, + { + path: 'src/renderer/src/components/native-chat/native-chat-runtime-send.ts', + classification: 'action-consumer', + marker: 'sendNativeChatMessage' + }, + { + path: 'mobile/src/session/mobile-native-chat-send.ts', + classification: 'action-consumer', + marker: 'sendMobileNativeChatMessageWithOutcome' + }, + { + path: 'mobile/src/session/mobile-native-chat-image-send.ts', + classification: 'action-consumer', + marker: 'pasteMobileNativeChatImagePaths' + }, + { + path: 'mobile/src/session/pr-ai-triage-launch.ts', + classification: 'action-consumer', + marker: 'createTerminalAndSendPrompt' + } +] + +describe('pane agent identity inventory ratchet', () => { + it('classifies every legacy helper definition, import, and callsite in src and mobile/src', async () => { + const files = await glob(['src/**/*.{ts,tsx}', 'mobile/src/**/*.{ts,tsx}'], { + ignore: ['**/*.test.*', '**/*.spec.*'] + }) + const actual: { helper: Helper; path: string; occurrences: number }[] = [] + for (const path of files) { + if (isTestFile(path) || TEST_SUPPORT_PATHS.has(path)) { + continue + } + const rawSource = readFileSync(join(process.cwd(), path), 'utf8') + if (!HELPERS.some((helper) => rawSource.includes(helper))) { + continue + } + const decommentedSource = stripComments(rawSource) + if (blankStringContentsDesynced(decommentedSource)) { + throw new Error(`String scanner desynchronized while inventorying ${path}`) + } + const source = blankStringContents(decommentedSource) + for (const helper of HELPERS) { + const occurrences = source.match(new RegExp(`\\b${helper}\\b`, 'g'))?.length ?? 0 + if (occurrences > 0) { + actual.push({ helper, path, occurrences }) + } + } + } + const expected = INVENTORY.flatMap(({ helper, paths }) => + paths.map((site) => { + const [path, occurrences] = typeof site === 'string' ? [site, 1] : site + return { helper, path, occurrences } + }) + ) + const byHelperAndPath = (left: (typeof actual)[number], right: (typeof actual)[number]) => + left.helper.localeCompare(right.helper) || left.path.localeCompare(right.path) + expect(actual.sort(byHelperAndPath)).toEqual(expected.sort(byHelperAndPath)) + }) + + it('pins direct single-source identity and action branches outside named helpers', () => { + for (const site of DIRECT_SINGLE_SOURCE_SURFACES) { + const source = stripComments(readFileSync(join(process.cwd(), site.path), 'utf8')) + expect({ + path: site.path, + classification: site.classification, + hasMarker: source.includes(site.marker) + }).toEqual({ path: site.path, classification: site.classification, hasMarker: true }) + } + }) +}) diff --git a/src/shared/pane-agent-identity-resolver.test.ts b/src/shared/pane-agent-identity-resolver.test.ts index e11ef249c65..e28fb0cbd22 100644 --- a/src/shared/pane-agent-identity-resolver.test.ts +++ b/src/shared/pane-agent-identity-resolver.test.ts @@ -85,6 +85,14 @@ describe('resolvePaneAgentIdentity', () => { }) describe('mixed-version peers', () => { + it('keeps a run-key-less completed row from hijacking launch evidence', () => { + const result = resolve([ + { source: 'completed-hook', agent: 'claude' }, + { source: 'launch', agent: 'codex' } + ]) + expect(result).toMatchObject({ agent: 'codex', source: 'launch' }) + }) + it('treats evidence with no run id as eligible', () => { // An old host publishes no run ids. Treating unknown as stale would blank every row. const result = resolvePaneAgentIdentity({ From e8005c332574835ed18d88359d050dbcaad441d9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:33:34 -0700 Subject: [PATCH 015/152] fix(codex): preserve WSL account home trust (#16496) * fix(codex): preserve WSL account home trust * fix(codex): preserve WSL drive path semantics * fix(codex): preserve mounted-drive WSL config paths * test(codex): preserve WSL path helpers in mock --- .../codex-accounts/runtime-home-service.ts | 8 +- .../runtime-home-wsl-session-bridge.test.ts | 23 ++- .../runtime-home-wsl-system-default.test.ts | 44 +++++ .../service-wsl-accounts.test.ts | 158 +++++++++++++++++- src/main/codex-accounts/service.ts | 69 ++------ src/main/codex/codex-config-mirror.test.ts | 9 + src/main/codex/codex-config-mirror.ts | 15 +- src/main/codex/config-settings-promotion.ts | 2 + 8 files changed, 253 insertions(+), 75 deletions(-) diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index c9be97fc641..3049a2a70b6 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -59,7 +59,7 @@ import { prepareSystemConfigForFreshRuntimeMirror, syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' -import { parseWslUncPath } from '../../shared/wsl-paths' +import { parseWslUncPath, toLinuxPath } from '../../shared/wsl-paths' import { getWslSelectionKey, getSelectedCodexAccountIdForTarget, @@ -757,7 +757,11 @@ export class CodexRuntimeHomeService { systemHomePath, managedHomePath: runtimeHomePath }) - syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) + syncSystemConfigIntoManagedCodexHome({ + runtimeHomePath, + systemHomePath, + systemConfigDir: toLinuxPath(systemHomePath) + }) } // Why: `null` is a real value here — it means "use the system-default lane". diff --git a/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts b/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts index 0c3b418b06a..05cfa12e240 100644 --- a/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' +import type * as WslPaths from '../../shared/wsl-paths' import { createSettings } from './runtime-home-settings-test-fixtures' import { createManagedAuth, @@ -281,15 +282,19 @@ describe('CodexRuntimeHomeService', () => { getDefaultWslDistro: () => null, getWslHome: (distro: string) => (distro === 'Debian' ? wslHome : null) })) - vi.doMock('../../shared/wsl-paths', () => ({ - parseWslUncPath: (candidate: string) => - candidate === wslRuntimeHomePath - ? { - distro: 'Debian', - linuxPath: '/home/alice/.local/share/orca/codex-runtime-home/home' - } - : null - })) + vi.doMock('../../shared/wsl-paths', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + parseWslUncPath: (candidate: string) => + candidate === wslRuntimeHomePath + ? { + distro: 'Debian', + linuxPath: '/home/alice/.local/share/orca/codex-runtime-home/home' + } + : null + } + }) const managedHomePath = createManagedAuth( testState.userDataDir, 'debian-account', diff --git a/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts b/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts index bfac7707f5a..a1ad86a0a5f 100644 --- a/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' +import type * as CodexConfigMirror from '../codex/codex-config-mirror' import { createSettings } from './runtime-home-settings-test-fixtures' import { createCodexAuthJson, @@ -327,4 +328,47 @@ describe('CodexRuntimeHomeService', () => { } } }) + + it('passes the Linux source config directory for mounted-drive WSL homes', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => 'C:\\Users\\alice' + })) + const syncConfig = vi.fn() + vi.doMock('../codex/codex-config-mirror', async () => ({ + ...(await vi.importActual('../codex/codex-config-mirror')), + syncSystemConfigIntoManagedCodexHome: syncConfig + })) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(createStore(createSettings()) as never) + const syncWslConfig = ( + service as unknown as { + syncWslConfigAndGlobalInstructionsForLaunch: ( + target: { runtime: 'wsl'; wslDistro?: string | null }, + runtimeHomePath: string | null + ) => void + } + ).syncWslConfigAndGlobalInstructionsForLaunch + + syncWslConfig.call( + service, + { runtime: 'wsl', wslDistro: 'Ubuntu' }, + join(testState.userDataDir, 'runtime-home') + ) + + expect(syncConfig).toHaveBeenCalledWith({ + runtimeHomePath: join(testState.userDataDir, 'runtime-home'), + systemHomePath: 'C:\\Users\\alice/.codex', + systemConfigDir: '/mnt/c/Users/alice/.codex' + }) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) }) diff --git a/src/main/codex-accounts/service-wsl-accounts.test.ts b/src/main/codex-accounts/service-wsl-accounts.test.ts index 2dbf44026a2..0a1e69a81cd 100644 --- a/src/main/codex-accounts/service-wsl-accounts.test.ts +++ b/src/main/codex-accounts/service-wsl-accounts.test.ts @@ -45,6 +45,148 @@ function wslFailed(code: number, stderr = ''): WslResult { describe('CodexAccountService config sync', () => { registerCodexAccountsTestHomes() + it('preserves WSL account-home project trust while refreshing canonical settings', async () => { + const wslManagedHomePath = join(testState.userDataDir, 'wsl-account', 'home') + const wslCanonicalHomePath = join(testState.userDataDir, 'wsl-home', '.codex') + const wslLinuxHomePath = '/home/alice/.local/share/orca/codex-accounts/account-1/home' + const wslLinuxCanonicalHomePath = '/home/alice/.codex' + mkdirSync(wslManagedHomePath, { recursive: true }) + mkdirSync(wslCanonicalHomePath, { recursive: true }) + writeFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'account-1\n', 'utf-8') + writeFileSync( + join(wslManagedHomePath, 'config.toml'), + 'approval_policy = "untrusted"\n[projects."/workspace"]\ntrust_level = "trusted"\n', + 'utf-8' + ) + writeFileSync( + join(wslCanonicalHomePath, 'config.toml'), + 'sandbox_mode = "danger-full-access"\n', + 'utf-8' + ) + + vi.doMock('../../shared/wsl-paths', () => ({ + parseWslUncPath: (path: string) => { + if (path === wslManagedHomePath) { + return { distro: 'Ubuntu', linuxPath: wslLinuxHomePath } + } + if (path === wslCanonicalHomePath) { + return { distro: 'Ubuntu', linuxPath: wslLinuxCanonicalHomePath } + } + return null + } + })) + vi.doMock('../wsl', () => ({ + toWindowsWslPath: (linuxPath: string) => + linuxPath === wslLinuxCanonicalHomePath || + linuxPath === `${wslLinuxCanonicalHomePath}/config.toml` + ? linuxPath.endsWith('/config.toml') + ? join(wslCanonicalHomePath, 'config.toml') + : wslCanonicalHomePath + : wslManagedHomePath + })) + + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'wsl@example.com', + managedHomePath: wslManagedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + + const { CodexAccountService } = await import('./service') + new CodexAccountService( + createStore(settings) as never, + createRateLimits() as never, + createRuntimeHome() as never + ) + + expect(readFileSync(join(wslManagedHomePath, 'config.toml'), 'utf-8')).toBe( + 'sandbox_mode = "danger-full-access"\n\n' + + '[projects."/workspace"]\ntrust_level = "trusted"\n' + ) + }) + + it('keeps Linux-relative config paths when a WSL home is under a mounted drive', async () => { + vi.resetModules() + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + const wslManagedHomePath = join(testState.userDataDir, 'wsl-account', 'home') + const wslCanonicalHomePath = join(testState.userDataDir, 'wsl-home', '.codex') + const wslCanonicalConfigPath = join(wslCanonicalHomePath, 'config.toml') + const wslLinuxHomePath = '/mnt/c/Users/alice/.local/share/orca/codex-accounts/account-1/home' + const wslLinuxCanonicalHomePath = '/mnt/c/Users/alice/.codex' + mkdirSync(wslManagedHomePath, { recursive: true }) + mkdirSync(wslCanonicalHomePath, { recursive: true }) + writeFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'account-1\n', 'utf-8') + writeFileSync(wslCanonicalConfigPath, 'model_instructions_file = "instructions.md"\n', 'utf-8') + + vi.doMock('node:child_process', () => ({ + execFileSync: vi.fn(() => `${wslLinuxHomePath}\n`), + spawn: vi.fn() + })) + vi.doMock('../../shared/wsl-paths', () => ({ + parseWslUncPath: (path: string) => + path === wslManagedHomePath ? { distro: 'Ubuntu', linuxPath: wslLinuxHomePath } : null + })) + vi.doMock('../wsl', () => ({ + toWindowsWslPath: (linuxPath: string) => + linuxPath.endsWith('/config.toml') + ? wslCanonicalConfigPath + : linuxPath === wslLinuxCanonicalHomePath + ? wslCanonicalHomePath + : wslManagedHomePath + })) + + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'wsl@example.com', + managedHomePath: wslManagedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + + try { + const { CodexAccountService } = await import('./service') + new CodexAccountService( + createStore(settings) as never, + createRateLimits() as never, + createRuntimeHome() as never + ) + + expect(readFileSync(join(wslManagedHomePath, 'config.toml'), 'utf-8')).toContain( + "model_instructions_file = '/mnt/c/Users/alice/.codex/instructions.md'" + ) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + it('adds a managed Codex account inside WSL when the account context is WSL', async () => { vi.resetModules() const originalPlatform = process.platform @@ -54,8 +196,10 @@ describe('CodexAccountService config sync', () => { }) const wslManagedHomePath = join(testState.userDataDir, 'wsl-managed-home') - const wslConfigPath = join(testState.userDataDir, 'wsl-config.toml') + const wslConfigHomePath = join(testState.userDataDir, 'wsl-config-home') + const wslConfigPath = join(wslConfigHomePath, 'config.toml') const wslLinuxHomePath = '/home/alice/.local/share/orca/codex-accounts/account-id-for-test/home' + mkdirSync(wslConfigHomePath, { recursive: true }) writeFileSync( wslConfigPath, 'sandbox_mode = "danger-full-access"\nmodel_instructions_file = "instructions.md"\n', @@ -130,11 +274,19 @@ describe('CodexAccountService config sync', () => { vi.doMock('../wsl/wsl-runner', () => ({ runWslProcess: runWslProcessMock })) vi.doMock('../../shared/wsl-paths', () => ({ parseWslUncPath: (path: string) => - path === wslManagedHomePath ? { distro: 'Debian', linuxPath: wslLinuxHomePath } : null + path === wslManagedHomePath + ? { distro: 'Debian', linuxPath: wslLinuxHomePath } + : path === wslConfigHomePath + ? { distro: 'Debian', linuxPath: '/home/alice/.codex' } + : null })) vi.doMock('../wsl', () => ({ toWindowsWslPath: (linuxPath: string) => - linuxPath.endsWith('/.codex/config.toml') ? wslConfigPath : wslManagedHomePath + linuxPath.endsWith('/.codex/config.toml') + ? wslConfigPath + : linuxPath.endsWith('/.codex') + ? wslConfigHomePath + : wslManagedHomePath })) const settings = createSettings() diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 56cfd630f2d..97a3985c701 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -32,12 +32,8 @@ import type { } from '../../shared/codex-reset-credit-attempt-ledger' import type { CodexRuntimeHomeService } from './runtime-home-service' import { writeFileAtomically } from './fs-utils' -import { rewriteRelativePathConfigValues } from '../codex/codex-config-path-reference-rewrite' -import { stripCodexManagedHookTrustEntriesFromConfig } from '../codex/codex-managed-trust-reconciliation' -import { getCodexManagedHookInstallMaterial } from '../codex/hook-service' import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' import { getSystemCodexHomePath } from '../codex/codex-home-paths' -import { MANAGED_HOOK_TIMEOUT_SECONDS } from '../agent-hooks/installer-utils' import { readCodexTopLevelModelProvider } from '../codex/codex-model-provider-config' import { resolveCodexCommand } from '../codex-cli/command' import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' @@ -93,9 +89,10 @@ type ResolvedCodexIdentity = { type CanonicalCodexConfig = { contents: string - /** Home the config was read from, in the path style Codex sees (Linux-side for WSL); relative settings resolve against it. */ + /** Host-readable source home; the mirror resolves WSL UNC paths to their Linux spelling. */ sourceHomePath: string - sourceHooksPath: string + /** Preserve Linux path semantics when WSL $HOME is under /mnt/. */ + sourceConfigDir?: string } export type CodexAccountAddTarget = { @@ -1303,12 +1300,6 @@ export class CodexAccountService { } } - private isSelfContainedHostManagedHome(managedHomePath: string): boolean { - // Why: each host account home is its own launch CODEX_HOME. WSL homes keep - // their distro-local seed lane. - return !parseWslUncPath(managedHomePath) - } - private syncCanonicalConfigIntoManagedHome( managedHomePath: string, canonicalConfig = this.readCanonicalConfigForManagedHome(managedHomePath), @@ -1319,36 +1310,13 @@ export class CodexAccountService { } const trustedManagedHomePath = this.assertManagedHomePath(managedHomePath, expectedAccountId) - if (this.isSelfContainedHostManagedHome(trustedManagedHomePath)) { - // Why: this home is codex's live CODEX_HOME, so mirror config with the - // trust-preserving merge — the plain overwrite below would wipe the - // hook/project trust codex granted in this home, forcing a re-approval and - // an app-server re-grant on every account switch. - syncSystemConfigIntoManagedCodexHome({ - runtimeHomePath: trustedManagedHomePath, - systemHomePath: getSystemCodexHomePath() - }) - return - } - // Why: Orca account switching is meant to swap Codex credentials and quota - // identity, not silently fork the user's sandbox/config defaults. Syncing - // one canonical config into every managed home keeps auth isolated per - // account while preserving consistent Codex behavior. Managed homes are - // real CODEX_HOMEs for `codex login`, so relative path-valued settings - // must keep resolving against the home the config was read from. - const material = getCodexManagedHookInstallMaterial() - // Why: source-home Orca trust is foreign to each managed home's hooks.json. - const sanitizedConfig = stripCodexManagedHookTrustEntriesFromConfig(canonicalConfig.contents, { - runtimeHomePath: canonicalConfig.sourceHomePath, - sourcePath: canonicalConfig.sourceHooksPath, - command: material.command, - managedEventLabels: new Set(Object.values(material.eventLabel)), - timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + // Why: every account home is Codex's own CODEX_HOME. Preserve trust Codex + // granted there while refreshing ordinary settings from the lane's source. + syncSystemConfigIntoManagedCodexHome({ + runtimeHomePath: trustedManagedHomePath, + systemHomePath: canonicalConfig.sourceHomePath, + systemConfigDir: canonicalConfig.sourceConfigDir }) - this.writeManagedConfig( - trustedManagedHomePath, - rewriteRelativePathConfigValues(sanitizedConfig, canonicalConfig.sourceHomePath) - ) } private readCanonicalConfig(): CanonicalCodexConfig | null { @@ -1361,8 +1329,7 @@ export class CodexAccountService { try { return { contents: readFileSync(primaryConfigPath, 'utf-8'), - sourceHomePath, - sourceHooksPath: join(sourceHomePath, 'hooks.json') + sourceHomePath } } catch (error) { console.warn('[codex-accounts] Failed to read canonical config:', error) @@ -1392,8 +1359,8 @@ export class CodexAccountService { // path rewrites must anchor to the Linux-side ~/.codex, not the UNC path. return { contents: readFileSync(configPath, 'utf-8'), - sourceHomePath: `${wslHome}/.codex`, - sourceHooksPath: `${wslHome}/.codex/hooks.json` + sourceHomePath: toWindowsWslPath(`${wslHome}/.codex`, wslInfo.distro), + sourceConfigDir: `${wslHome}/.codex` } } catch (error) { console.warn('[codex-accounts] Failed to read WSL canonical config:', error) @@ -1416,18 +1383,6 @@ export class CodexAccountService { ) } - private writeManagedConfig(managedHomePath: string, contents: string): void { - const configPath = join(managedHomePath, 'config.toml') - try { - if (existsSync(configPath) && readFileSync(configPath, 'utf-8') === contents) { - return - } - } catch { - // Why: a read error must not make a stale config look current; atomic write owns ACL repair and error surfacing. - } - writeFileAtomically(configPath, contents) - } - private getManagedAccountsRoot(): string { const root = join(app.getPath('userData'), 'codex-accounts') mkdirSync(root, { recursive: true }) diff --git a/src/main/codex/codex-config-mirror.test.ts b/src/main/codex/codex-config-mirror.test.ts index 7fcd1754992..ee1d375582f 100644 --- a/src/main/codex/codex-config-mirror.test.ts +++ b/src/main/codex/codex-config-mirror.test.ts @@ -768,6 +768,15 @@ describe('syncSystemConfigIntoLegacySharedCodexHome', () => { }) describe('prepareSystemConfigForFreshRuntimeMirror', () => { + it('allows WSL callers to retain Linux semantics for mounted-drive homes', () => { + expect( + resolveCodexConfigMirrorSourceDirectory( + 'C:\\Users\\alice\\.codex', + '/mnt/c/Users/alice/.codex' + ) + ).toBe('/mnt/c/Users/alice/.codex') + }) + it('uses the Linux-side directory for WSL UNC source homes', () => { const sourceDir = resolveCodexConfigMirrorSourceDirectory( '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex' diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index f3d96a1064c..0b0e66c35be 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -155,7 +155,7 @@ type CodexConfigMirrorResult = | { status: 'mirrored'; preservedConflictKeys: ReadonlySet } function syncSystemConfigIntoManagedCodexHomeUnsafe( - { runtimeHomePath, systemHomePath }: CodexSettingsPromotionHomes, + { runtimeHomePath, systemHomePath, systemConfigDir }: CodexSettingsPromotionHomes, promotionPlan: CodexSettingsPromotionPlan ): CodexConfigMirrorResult { const systemConfigPath = join(systemHomePath, 'config.toml') @@ -184,7 +184,7 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe( : { status: 'mirrored', preservedConflictKeys: new Set() } } - const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(systemHomePath) + const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(systemHomePath, systemConfigDir) if (!runtimeConfigExists) { writeFileAtomically( runtimeConfigPath, @@ -207,8 +207,15 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe( return { status: 'mirrored', preservedConflictKeys: preserved.keys } } -export function resolveCodexConfigMirrorSourceDirectory(systemHomePath: string): string { - return parseWslUncPath(systemHomePath)?.linuxPath ?? dirname(join(systemHomePath, 'config.toml')) +export function resolveCodexConfigMirrorSourceDirectory( + systemHomePath: string, + systemConfigDir?: string +): string { + return ( + systemConfigDir ?? + parseWslUncPath(systemHomePath)?.linuxPath ?? + dirname(join(systemHomePath, 'config.toml')) + ) } function prepareSystemConfigForRuntimeMirror(config: string, systemConfigDir: string): string { diff --git a/src/main/codex/config-settings-promotion.ts b/src/main/codex/config-settings-promotion.ts index 9ff7383291d..45d95a266b1 100644 --- a/src/main/codex/config-settings-promotion.ts +++ b/src/main/codex/config-settings-promotion.ts @@ -184,6 +184,8 @@ export function snapshotCodexRuntimeSettingsBaseline( export type CodexSettingsPromotionHomes = { runtimeHomePath: string systemHomePath: string + /** Linux spelling of the source config directory when its host path is a drvfs drive. */ + systemConfigDir?: string } export type CodexSettingsPromotionPlan = { From d1a11b32992bc10fba7d8fb91c53f84b2380c5ad Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:36:45 -0700 Subject: [PATCH 016/152] fix(pty): match the echo shapes a real tty actually produces (#16542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reply echo suppression modelled two echo shapes from the spec rather than from a tty. Captured under node-pty against real bash, at a readline prompt and under `read`: - Readline mangles CSI replies, not just OSC: `ESC [ ?` becomes BEL and the residue echoes. The projection was gated on an OSC introducer, so a private DSR echo was never matched at a readline prompt. This is the reachable one: a mode-2031 theme push (`CSI ?997;1n`) left latched by an exited TUI paints `997;1n` on a bash prompt (#9993's scenario). - ECHOCTL carets EVERY control, not just ESC. A BEL-terminated OSC reply echoes as `^G`, but the needle kept a literal BEL — a string no tty produces. Hardening only: every in-tree OSC reply is ST-terminated (terminal-osc-color-reply.ts:112, xterm's own reply), so the changed byte is unreachable except from a foreign or older emulator. Why this is not the CSI projection #13160 review dropped: that one was the identity (`replaceAll('\x1b]', …)` is a no-op on a CSI reply), so it was ESC-led and 500ms-held bare-ESC tails away from the query parser. This one is BEL-led. The rule is now asserted for every shape rather than implied by the gate: holdPartial iff the needle does not start with ESC. The readline branch is keyed on the private-DSR grammar with a non-empty parameter list, plus a floor on needle length. The containment grammar admits `CSI ? n`, and `answerLiveQueryReply` takes client-supplied bytes on the relay path, so a peer could otherwise arm a two-byte `BEL n` needle and delete the first bell-then-`n` in ordinary output. #61c65151129 proved this system can eat real output when a needle outlives its budget; a length floor is cheap. Live coverage: pty-reply-echo-shapes.node-pty.test.ts writes a reply to a real bash master and feeds back what it echoes, so a shell or libc change fails the suite instead of silently disarming suppression. Registered in the shell-contracts lane. The transcript tests and the caretEcho helpers that encoded the same ESC-only assumption are corrected alongside. Suppression is display-only. This does not change what reaches the child's stdin — the reply is written to the master either way, in call order. --- .github/workflows/pr.yml | 2 + config/reliability-gates.jsonc | 11 +- .../pty-input-write-queue.test.ts | 9 +- .../pty-reply-echo-shapes.node-pty.test.ts | 122 +++++++++++++++ ...y-startup-ingress-live-query-reply.test.ts | 64 +++++++- .../pty-startup-reply-echo-shapes.test.ts | 140 ++++++++++++++++++ src/shared/pty-startup-reply-echo-shapes.ts | 74 ++++++++- 7 files changed, 409 insertions(+), 13 deletions(-) create mode 100644 src/shared/pty-reply-echo-shapes.node-pty.test.ts create mode 100644 src/shared/pty-startup-reply-echo-shapes.test.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 97c68687a81..21d43d3bee5 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -425,6 +425,7 @@ jobs: src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \ src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ src/shared/fish-query-reply-child-stdin.node-pty.test.ts \ + src/shared/pty-reply-echo-shapes.node-pty.test.ts \ src/shared/startup-shell-portability.live-shell.test.ts \ src/shared/posix-command-path-lookup.test.ts @@ -470,6 +471,7 @@ jobs: --exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \ --exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ --exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \ + --exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \ --exclude=src/shared/startup-shell-portability.live-shell.test.ts \ --exclude=src/shared/posix-command-path-lookup.test.ts \ --exclude=tests/e2e/cross-version-wire/** \ diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index bd7d868a998..482f9808656 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -13116,7 +13116,7 @@ "invariant": "The desktop PTY input queue retains at most 64 explicitly sourced pending terminal query replies and 4096 UTF-16 code units. Every retained reply reaches the provider as one atomic write, and the host writes each reply the moment it accepts it, so replies reach the PTY in the order they were produced with no queue that could reorder them. A reply's own echo is contained on the output side by projecting its known echo shapes; the ESC-initial verbatim shape is matched only when complete, never held as a partial, so a query torn at its own ESC is still answered. Overflow removes only the oldest query replies, never ordinary input except the documented modified-F3/CPR byte collision, and drain failures cannot clear a newer queue generation.", "oracle": "Synchronously enqueue separate 10,000-entry OSC and DA1 reply floods before the scheduled drain and assert that only the initial immediate reply and newest 64 pending replies are written, each as one provider write, before a trailing keystroke. At the host boundary, defer an OSC reply and assert that separate or legacy-coalesced DA1/CPR replies flush after it in observed query order. At the remote-runtime boundary, preserve separate writes around pending ordinary input, async validation, and viewport-claim buffering. Repeat behind 10,000 ordinary inputs and exercise the text ceiling, real xterm generation, provider-write failure, rejected yield, and clear/reuse generation fencing.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/shared/terminal-query-reply.test.ts src/shared/pty-startup-ingress-live-query-reply.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/shared/terminal-query-reply.test.ts src/shared/pty-startup-ingress-live-query-reply.test.ts src/shared/pty-startup-reply-echo-shapes.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts", "pnpm exec playwright test tests/e2e/terminal-osc-color-queries.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "pnpm exec playwright test tests/e2e/terminal-typing-latency.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", @@ -13130,6 +13130,7 @@ "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts", "src/shared/terminal-query-reply.test.ts", "src/shared/pty-startup-ingress-live-query-reply.test.ts", + "src/shared/pty-startup-reply-echo-shapes.test.ts", "tests/e2e/terminal-osc-color-queries.spec.ts", "tests/e2e/terminal-typing-latency.spec.ts", "tests/e2e/pty-input-write-queue-ssh.spec.ts" @@ -13208,13 +13209,13 @@ ], "evidenceRuns": [ { - "date": "2026-08-09", + "date": "2026-08-25", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/shared/terminal-query-reply.test.ts src/shared/pty-startup-ingress-live-query-reply.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/shared/terminal-query-reply.test.ts src/shared/pty-startup-ingress-live-query-reply.test.ts src/shared/pty-startup-reply-echo-shapes.test.ts", "result": "passed", - "durationSeconds": 2.35, - "summary": "Four files and 138 tests passed, including explicit IPC reply-source routing, the 10,000-reply count ceiling, text ceiling, 10,000-entry ordinary backlog preservation, real xterm OSC query flood, single-shot drain-failure recovery, one-reply-per-write echo containment, and clear/reuse generation fencing." + "durationSeconds": 1.05, + "summary": "Five files and 92 tests passed, including the live-pty echo-shape transcript, including explicit IPC reply-source routing, the 10,000-reply count ceiling, text ceiling, 10,000-entry ordinary backlog preservation, real xterm OSC query flood, single-shot drain-failure recovery, one-reply-per-write echo containment, and clear/reuse generation fencing." }, { "date": "2026-08-09", diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts index b63ab58acec..42124f0f8c0 100644 --- a/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts @@ -509,7 +509,14 @@ describe('pty input write queue', () => { // → ingress echo strip, and assert no `997;1n` emission at the confirm prompt. vi.useFakeTimers() const reply = mode2031SequenceFor('dark') - const caretEcho = (data: string): string => data.replaceAll('\x1b', '^[') + // ECHOCTL carets every control, not just ESC. Identical for this reply (it carries no + // other control), but modelled correctly so this does not drift from the encoder. + const caretEcho = (data: string): string => + [...data] + .map((ch) => + ch.charCodeAt(0) < 0x20 ? `^${String.fromCharCode(ch.charCodeAt(0) + 0x40)}` : ch + ) + .join('') const masterWrites: string[] = [] const emissions: PtyIngressEmission[] = [] let ingress!: PtyStartupIngress diff --git a/src/shared/pty-reply-echo-shapes.node-pty.test.ts b/src/shared/pty-reply-echo-shapes.node-pty.test.ts new file mode 100644 index 00000000000..0fe06c7cae5 --- /dev/null +++ b/src/shared/pty-reply-echo-shapes.node-pty.test.ts @@ -0,0 +1,122 @@ +/** + * Keeps the transcript in pty-startup-reply-echo-shapes.test.ts honest. + * + * That file asserts against echo bytes recorded by hand, which is exactly how the two + * shapes this PR corrected went wrong in the first place: the projection and the test both + * encoded the same guess. This one writes a reply to a real PTY master, reads what bash + * actually echoes, and feeds it to the projection — so a shell or libc change that moves + * the shape fails here instead of silently disarming suppression. + * + * Two line disciplines, because they echo differently and both are reachable: + * readline — tty raw at a prompt, readline echoes in software + * cooked — under `read`, the kernel echoes via ECHOCTL + */ +import { existsSync } from 'node:fs' +import { afterEach, describe, expect, it } from 'vitest' +import { locateEcho, replyEchoProjections } from './pty-startup-reply-echo-shapes' +import { mode2031SequenceFor } from './terminal-color-scheme-protocol' + +const BASH = '/bin/bash' +const itWithBash = process.platform !== 'win32' && existsSync(BASH) ? it : it.skip + +const COLOR_SCHEME_REPLY = mode2031SequenceFor('dark') +const OSC_COLOR_REPLY_ST = '\x1b]11;rgb:2e2e/3434/3434\x1b\\' + +type Pty = { write: (data: string) => void; kill: () => void } + +let live: Pty | null = null + +afterEach(() => { + live?.kill() + live = null +}) + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + +async function waitFor(predicate: () => boolean, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline && !predicate()) { + await sleep(25) + } +} + +/** + * Writes `reply` to the master once bash is settled, and returns what came back. + * `discipline: 'cooked'` parks bash in `read` first, which restores ICANON+ECHO. + */ +async function echoOf(reply: string, discipline: 'readline' | 'cooked'): Promise { + const { spawn } = await import('node-pty') + let output = '' + const pty = spawn(BASH, ['--norc', '--noprofile', '-i'], { + name: 'xterm-256color', + cols: 80, + rows: 24, + env: { ...process.env, PS1: 'ORCA16542> ', TERM: 'xterm-256color' } + }) + live = { write: (data) => pty.write(data), kill: () => pty.kill() } + pty.onData((data) => { + output += data + }) + + await waitFor(() => output.includes('ORCA16542> '), 10_000) + if (discipline === 'cooked') { + pty.write('read -r ORCA_LINE\r') + await sleep(400) + } + output = '' + pty.write(reply) + // No marker to wait on: the echo is all this produces, so settle instead. + await waitFor(() => output.length > 0, 5_000) + await sleep(250) + return output +} + +describe('replyEchoProjections against a real bash pty', () => { + // The reachable fix: a latched mode-2031 push echoed at a readline prompt had no + // projection at all before this, so it always painted `997;1n` on the prompt (#9993). + itWithBash( + 'matches what readline echoes for a mode-2031 reply', + async () => { + const echo = await echoOf(COLOR_SCHEME_REPLY, 'readline') + expect(echo).not.toBe('') + const match = locateEcho(replyEchoProjections(COLOR_SCHEME_REPLY, 'posix-pty'), echo) + expect(match).toMatchObject({ kind: 'complete' }) + }, + 30_000 + ) + + itWithBash( + 'matches what the kernel echoes for a mode-2031 reply', + async () => { + const echo = await echoOf(COLOR_SCHEME_REPLY, 'cooked') + expect(echo).not.toBe('') + const match = locateEcho(replyEchoProjections(COLOR_SCHEME_REPLY, 'posix-pty'), echo) + expect(match).toMatchObject({ kind: 'complete' }) + }, + 30_000 + ) + + // The ST form is what every in-tree OSC reply uses, so this is the shape that must never + // regress — the BEL form is only reachable from a foreign or older emulator. + itWithBash( + 'matches what the kernel echoes for an ST-terminated OSC reply', + async () => { + const echo = await echoOf(OSC_COLOR_REPLY_ST, 'cooked') + expect(echo).not.toBe('') + const match = locateEcho(replyEchoProjections(OSC_COLOR_REPLY_ST, 'posix-pty'), echo) + expect(match).toMatchObject({ kind: 'complete' }) + }, + 30_000 + ) + + itWithBash( + 'matches what readline echoes for an ST-terminated OSC reply', + async () => { + const echo = await echoOf(OSC_COLOR_REPLY_ST, 'readline') + expect(echo).not.toBe('') + const match = locateEcho(replyEchoProjections(OSC_COLOR_REPLY_ST, 'posix-pty'), echo) + expect(match).toMatchObject({ kind: 'complete' }) + }, + 30_000 + ) +}) diff --git a/src/shared/pty-startup-ingress-live-query-reply.test.ts b/src/shared/pty-startup-ingress-live-query-reply.test.ts index 4a8e259f377..8048ceea127 100644 --- a/src/shared/pty-startup-ingress-live-query-reply.test.ts +++ b/src/shared/pty-startup-ingress-live-query-reply.test.ts @@ -13,7 +13,15 @@ const COLOR_SCHEME_REPLY = mode2031SequenceFor('dark') const OSC_COLOR_REPLY = '\x1b]11;rgb:00/00/00\x07' const CPR_REPLY = '\x1b[6;1R' const DA1_REPLY = '\x1b[?1;2c' -const caretEcho = (reply: string): string => reply.replaceAll('\x1b', '^[') +// ECHOCTL carets every C0 control, so an OSC reply's trailing BEL prints as `^G`. This +// modelled ESC alone, which is not a shape any tty produces — see the live-pty transcript +// in pty-startup-reply-echo-shapes.test.ts. +const caretEcho = (reply: string): string => + [...reply] + .map((ch) => + ch.charCodeAt(0) < 0x20 ? `^${String.fromCharCode(ch.charCodeAt(0) + 0x40)}` : ch + ) + .join('') const readlineEcho = (reply: string): string => reply.replaceAll('\x1b]', '\x07').replaceAll('\x1b\\', '') @@ -146,6 +154,60 @@ describe('live query replies', () => { ingress.drainAndClose() }) + // The private-DSR readline needle is only 7 bytes (`BEL 997;1n`), so it is the shortest + // span suppression can ever delete. These pin that the exposure is one exact match in an + // armed window — not a standing filter over ordinary output. + describe('the short readline needle does not eat ordinary output', () => { + const armed = (chunks: readonly string[]): string => { + const emissions: PtyIngressEmission[] = [] + const ingress = new PtyStartupIngress({ + ownerBackend: 'posix-pty', + write: () => {}, + onEmission: (emission) => void emissions.push(emission) + }) + ingress.answerLiveQueryReply(COLOR_SCHEME_REPLY) + for (const chunk of chunks) { + ingress.accept(chunk) + } + ingress.drainAndClose() + return visible(emissions) + } + + it.each([ + { name: 'BEL then a bare number', text: '\x07997 files changed\n' }, + { name: 'BEL then a near-miss final byte', text: '\x07997;1m not a reply\n' }, + { name: 'the digits without the BEL', text: ' 45% ==== 997;1n bytes\n' }, + { name: 'a bell mid-word', text: 'ding\x07 done\n' }, + { name: 'ls colour output', text: '\x1b[01;34msrc\x1b[0m \x1b[01;32mrun.sh\x1b[0m\n' }, + { name: 'a stack trace carrying 997:1', text: 'Error: boom\n at f (/a/b.js:997:1)\n' } + ])('passes $name through untouched', ({ text }) => { + expect(armed([text])).toBe(text) + // Same, torn at every byte boundary: a partial hold must still release intact. + for (let split = 1; split < text.length; split += 1) { + expect(armed([text.slice(0, split), text.slice(split)])).toBe(text) + } + }) + + it('suppresses the echo once, then stops', () => { + const echo = '\x07997;1n' + expect(armed([echo])).toBe('') + // One reply written, one span consumed — a repeat is ordinary output. + expect(armed([echo, echo])).toBe(echo) + }) + + it('suppresses nothing when no reply was written', () => { + const emissions: PtyIngressEmission[] = [] + const ingress = new PtyStartupIngress({ + ownerBackend: 'posix-pty', + write: () => {}, + onEmission: (emission) => void emissions.push(emission) + }) + ingress.accept('\x07997;1n') + ingress.drainAndClose() + expect(visible(emissions)).toBe('\x07997;1n') + }) + }) + it('bounds unmatched echo projections instead of shadowing the session', () => { const { ingress, writes, emissions } = harness() for (let index = 0; index < 200; index += 1) { diff --git a/src/shared/pty-startup-reply-echo-shapes.test.ts b/src/shared/pty-startup-reply-echo-shapes.test.ts new file mode 100644 index 00000000000..541d7d74d20 --- /dev/null +++ b/src/shared/pty-startup-reply-echo-shapes.test.ts @@ -0,0 +1,140 @@ +/** + * The echo shapes here are TRANSCRIBED FROM A LIVE PTY, not derived from the spec: each + * `echo` is what `/bin/bash` actually emitted after the reply was written to the master, + * captured under node-pty at a readline prompt (tty raw, readline echoes in software) and + * under a `read` builtin (tty cooked, kernel ECHOCTL echoes). + * + * Two shapes matched nothing before this file existed: an OSC reply on a cooked tty (its + * trailing BEL prints as `^G`, and only ESC was caret-encoded) and any private DSR at a + * readline prompt (the readline projection was gated on an OSC introducer). + */ +import { describe, expect, it } from 'vitest' +import { locateEcho, replyEchoProjections } from './pty-startup-reply-echo-shapes' + +const OSC11_BEL = '\x1b]11;rgb:2e2e/3434/3434\x07' +const OSC11_ST = '\x1b]11;rgb:2e2e/3434/3434\x1b\\' +const OSC10_BEL = '\x1b]10;rgb:c6c6/c6c6/c6c6\x07' +const DSR_997 = '\x1b[?997;1n' +const DSR_996 = '\x1b[?996n' + +/** `readline` = tty raw at a bash prompt; `cooked` = kernel ECHOCTL under `read`. */ +const LIVE_ECHOES: readonly { name: string; reply: string; echo: string }[] = [ + { name: 'OSC 11 BEL / readline', reply: OSC11_BEL, echo: '\x0711;rgb:2e2e/3434/3434\x07' }, + { name: 'OSC 11 ST / readline', reply: OSC11_ST, echo: '\x0711;rgb:2e2e/3434/3434' }, + { name: 'OSC 10 BEL / readline', reply: OSC10_BEL, echo: '\x0710;rgb:c6c6/c6c6/c6c6\x07' }, + { name: 'DSR 997 / readline', reply: DSR_997, echo: '\x07997;1n' }, + { name: 'DSR 996 / readline', reply: DSR_996, echo: '\x07996n' }, + { name: 'OSC 11 BEL / cooked', reply: OSC11_BEL, echo: '^[]11;rgb:2e2e/3434/3434^G' }, + { name: 'OSC 11 ST / cooked', reply: OSC11_ST, echo: '^[]11;rgb:2e2e/3434/3434^[\\' }, + { name: 'OSC 10 BEL / cooked', reply: OSC10_BEL, echo: '^[]10;rgb:c6c6/c6c6/c6c6^G' }, + { name: 'DSR 997 / cooked', reply: DSR_997, echo: '^[[?997;1n' }, + { name: 'DSR 996 / cooked', reply: DSR_996, echo: '^[[?996n' } +] + +describe('replyEchoProjections on a POSIX pty', () => { + it.each(LIVE_ECHOES)('matches the live $name echo', ({ reply, echo }) => { + const match = locateEcho(replyEchoProjections(reply, 'posix-pty'), echo) + expect(match).toEqual({ kind: 'complete', offset: 0, length: echo.length }) + }) + + // The tty coalesces its echo with surrounding shell output, so anchoring at offset 0 + // would recognize almost no real echo. + it.each(LIVE_ECHOES)('finds the $name echo embedded in output', ({ reply, echo }) => { + const match = locateEcho(replyEchoProjections(reply, 'posix-pty'), `user@host:~$ ${echo} `) + expect(match).toEqual({ kind: 'complete', offset: 13, length: echo.length }) + }) + + // `stty -echoctl` echoes the reply verbatim. + it.each(LIVE_ECHOES.map((entry) => entry.reply))('matches the verbatim echo of %j', (reply) => { + expect(locateEcho(replyEchoProjections(reply, 'posix-pty'), reply).kind).toBe('complete') + }) + + it('caret-encodes every control, not just ESC', () => { + const [kernel] = replyEchoProjections(OSC11_BEL, 'posix-pty') + expect(kernel?.needle).toBe('^[]11;rgb:2e2e/3434/3434^G') + expect(kernel?.needle).not.toContain('\x07') + }) + + // ECHOCTL passes TAB/LF/CR through literally and renders DEL as `^?`. No reply grammar + // carries one today; this pins the encoder so a future grammar cannot silently + // over-predict. Table matches the caret notation `vis(3)` defines. + it.each([ + { name: 'TAB stays literal', input: '\t', encoded: '\t' }, + { name: 'LF stays literal', input: '\n', encoded: '\n' }, + { name: 'CR stays literal', input: '\r', encoded: '\r' }, + { name: 'NUL carets to ^@', input: '\x00', encoded: '^@' }, + { name: 'BEL carets to ^G', input: '\x07', encoded: '^G' }, + { name: 'ESC carets to ^[', input: '\x1b', encoded: '^[' }, + { name: 'DEL carets to ^?', input: '\x7f', encoded: '^?' } + ])('$name under ECHOCTL', ({ input, encoded }) => { + const [kernel] = replyEchoProjections(`\x1b[?9${input}n`, 'posix-pty') + expect(kernel?.needle).toBe(`^[[?9${encoded}n`) + }) + + // DA1 shares the `ESC [ ?` prefix but is not a cooked-echo-risk reply. Keeping this off + // the readline path here, rather than relying on the caller's predicate, means widening + // that predicate cannot silently arm a holdable `BEL 1;2c` needle. + it.each(['\x1b[?1;2c', '\x1b[?0u', '\x1b[?2026;2$y', '\x1b[?12;5R'])( + 'projects no readline needle for %j, which is not a private DSR', + (reply) => { + const needles = replyEchoProjections(reply, 'posix-pty').map( + (projection) => projection.needle + ) + expect(needles.some((needle) => needle.startsWith('\x07'))).toBe(false) + } + ) + + // The containment grammar accepts an empty parameter list and `answerLiveQueryReply` + // takes client-supplied bytes on the relay path, so a peer could otherwise arm a two-byte + // `BEL n` needle and delete the first bell-then-`n` in ordinary output. + it.each(['\x1b[?n', '\x1b[?;n', '\x1b[?5n'])( + 'projects no readline needle for %j, which would be too short to be safe', + (reply) => { + const needles = replyEchoProjections(reply, 'posix-pty').map( + (projection) => projection.needle + ) + expect(needles.some((needle) => needle.startsWith('\x07'))).toBe(false) + } + ) + + it('projects readline for a private DSR, which carries no OSC introducer', () => { + const needles = replyEchoProjections(DSR_997, 'posix-pty').map( + (projection) => projection.needle + ) + expect(needles).toContain('\x07997;1n') + }) + + // A needle starting with ESC must never be held as a partial: a read ending on a bare + // ESC is a strict prefix of it, and an expired hold would release a stolen query raw. + it('holds a partial only for needles that do not start with ESC', () => { + for (const { reply } of LIVE_ECHOES) { + for (const projection of replyEchoProjections(reply, 'posix-pty')) { + expect(projection.holdPartial).toBe(!projection.needle.startsWith('\x1b')) + } + } + }) +}) + +describe('replyEchoProjections on other backends', () => { + it('keeps ConPTY on its documented ESC-stripped form', () => { + expect(replyEchoProjections(DSR_997, 'windows-conpty')).toEqual([ + { needle: '[?997;1n', holdPartial: true } + ]) + }) + + // Documents current behaviour, and is NOT a claim that it is right: conhost's echo of a + // BEL-terminated reply has never been captured, so the needle keeps a raw BEL exactly as + // the POSIX caret form used to. Unreachable in-tree (every OSC reply Orca emits is + // ST-terminated) and deliberately not corrected blind — see the branch comment. + it('leaves a BEL literal in the ConPTY needle, which is unverified', () => { + const [conpty] = replyEchoProjections(OSC11_BEL, 'windows-conpty') + expect(conpty?.needle).toBe(']11;rgb:2e2e/3434/3434\x07') + // The ST reply is the shape #9651 was actually reported against, and it has no BEL. + const [st] = replyEchoProjections(OSC11_ST, 'windows-conpty') + expect(st?.needle).toBe(']11;rgb:2e2e/3434/3434\\') + }) + + it('suppresses nothing when the echo shape is unverified', () => { + expect(replyEchoProjections(DSR_997, 'windows-wsl')).toEqual([]) + }) +}) diff --git a/src/shared/pty-startup-reply-echo-shapes.ts b/src/shared/pty-startup-reply-echo-shapes.ts index b232880d7dc..f03610497ad 100644 --- a/src/shared/pty-startup-reply-echo-shapes.ts +++ b/src/shared/pty-startup-reply-echo-shapes.ts @@ -21,25 +21,87 @@ export type PtyStartupReplyEchoMatch = | { kind: 'partial'; offset: number } | { kind: 'none' } +/* oxlint-disable-next-line no-control-regex -- terminal reply grammars are control sequences */ +const PRIVATE_DSR_RE = new RegExp('^\\u001b\\[\\?[0-9][0-9;]*n$') + +/** + * Floor on a BEL-led needle. The containment grammar accepts an empty parameter list + * (`CSI ? n`), and `answerLiveQueryReply` takes client-supplied bytes on the relay path — + * so without this a peer could arm a two-byte `BEL n` needle that deletes the first + * bell-then-`n` in ordinary output. Below this length the match is not worth the span. + */ +const MIN_READLINE_NEEDLE_LENGTH = 4 + +/** + * ECHOCTL carets EVERY control, not just ESC. Every OSC reply Orca emits is ST-terminated + * (terminal-osc-color-reply.ts) and ST is ESC-led, so this is byte-identical to the old + * ESC-only encoding for all of them. It matters for a BEL-terminated reply, which the + * grammar admits from a foreign or older emulator: the tty prints that BEL as `^G`, where + * encoding ESC alone left a literal BEL in the needle — a string no tty produces. + * + * Shapes verified against a live pty; bash, zsh and sh echo identically here, because this + * is the kernel and not the shell. + * + * TAB/LF/CR are exempt: ECHOCTL passes them through literally, so caret-encoding them + * would over-predict. No reply grammar carries one today, which is why this is a guard + * rather than a fix. + */ +function caretEncodeControls(reply: string): string { + let out = '' + for (const ch of reply) { + const code = ch.charCodeAt(0) + if (code === 0x09 || code === 0x0a || code === 0x0d) { + out += ch + } else if (code < 0x20) { + out += `^${String.fromCharCode(code + 0x40)}` + } else { + out += code === 0x7f ? '^?' : ch + } + } + return out +} + +/** + * Readline swallows the escape introducer, rings the bell, and echoes the residue — BEL + * replaces `ESC ]` for an OSC reply and `ESC [ ?` for a private DSR. The DSR shape had no + * projection at all, so `CSI ? … n` was never matched at a readline prompt. + */ +function readlineEchoProjection(reply: string): string | null { + if (reply.includes('\x1b]')) { + return reply.replaceAll('\x1b]', '\x07').replaceAll('\x1b\\', '') + } + // Keyed on the private-DSR grammar, not a bare `ESC [ ?` prefix: DA1 (`ESC [ ? 1 ; 2 c`) + // shares that prefix and is kept off this path today only by a predicate one module away. + // Matching the final `n` here means widening that predicate cannot silently arm a needle. + if (!PRIVATE_DSR_RE.test(reply)) { + return null + } + const needle = `\x07${reply.slice(3)}` + return needle.length >= MIN_READLINE_NEEDLE_LENGTH ? needle : null +} + export function replyEchoProjections( reply: string, ownerBackend: PtyOwnerBackend ): readonly EchoProjection[] { if (ownerBackend === 'windows-conpty') { - // Why: ConPTY's projection is the documented, deterministic ESC-stripped form. + // ESC-stripped, but observed only for the ST-terminated OSC 10/11 reply that #9651 + // was reported against — which is every OSC reply Orca emits. Whether conhost leaves a + // BEL literal, carets it, or eats it is unknown, so this shares the latent defect the + // POSIX caret form had. Not corrected blind: #9500 Decision 3 forbids generalising the + // ESC-strip without evidence, and the harness that would produce it does not exist. return [{ needle: reply.replaceAll('\x1b', ''), holdPartial: true }] } if (ownerBackend !== 'posix-pty') { // wsl.exe is ConPTY-hosted but its echo shape is unverified; suppress nothing. return [] } + const readline = readlineEchoProjection(reply) return [ // The kernel's ECHOCTL caret form — the POSIX default. - { needle: reply.replaceAll('\x1b', '^['), holdPartial: true }, - // Readline rewrites OSC, and echoes it even while the kernel reports ECHO clear. - ...(reply.includes('\x1b]') - ? [{ needle: reply.replaceAll('\x1b]', '\x07').replaceAll('\x1b\\', ''), holdPartial: true }] - : []), + { needle: caretEncodeControls(reply), holdPartial: true }, + // Readline rewrites the reply, and echoes it even while the kernel reports ECHO clear. + ...(readline === null ? [] : [{ needle: readline, holdPartial: true }]), // A `stty -echoctl` tty echoes the reply verbatim. Complete-match-only, because it // starts with ESC — see `holdPartial`. We just wrote these exact bytes, and we are // the terminal, so a child emitting the identical span in the same window is not a From ef0d5931bc59c31bd7f861cc3f66233cdef4ae2e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:37:15 -0700 Subject: [PATCH 017/152] fix(source-control): budget WSL bulk git command lines by bytes, not path count (#16634) Selecting ~100 changed files in a WSL worktree and hitting Stage All did nothing: the files stayed unstaged and the operation reported a failure. Bulk stage/unstage/discard chunked pathspecs 100 at a time, a count picked against a raw argv. A WSL-routed write is not a raw argv -- it is folded into one login-shell command line that shell-quotes every pathspec, quotes the result again, and embeds it three times (one branch per guest shell), so the finished line runs ~3.4x the raw pathspec bytes. Realistic project paths blew past the 32767-character CreateProcess cap at 100 paths and wsl.exe refused to spawn, with nothing staged. Chunking now measures the finished command line through the real resolver, so the wrapper's quoting rules live in one place and native, WSL and SSH hosts each get the budget of the host that actually spawns. A pathspec too long to fit alone still ships alone rather than being dropped, and no chunk is ever emitted empty -- a pathspec-free `clean -ffdx` would have swept the whole worktree. The tracked-path listing behind that discard also fences the WSL login shell now. Its stdout was parsed NUL-delimited without a fence, so Ubuntu's interactive rc banner glued itself onto the first record: that path failed to match anything git reported and was treated as untracked, sending a tracked file to `git clean` instead of `git restore`. Not observing a path in ls-files output is not evidence the path is untracked. The Windows command-line cap and its libuv-aware length estimate move out of the WSL runner into src/shared/windows-command-line-budget.ts, shared by both callers. --- .../bulk-pathspec-command-line-budget.test.ts | 193 ++++++++++++++++++ .../git/source-control/discard-changes.ts | 57 +++--- src/main/git/source-control/git-pathspec.ts | 84 +++++++- src/main/git/source-control/staging.ts | 31 +-- .../wsl-tracked-pathspec-banner.test.ts | 109 ++++++++++ .../status-discard-and-bulk-staging.test.ts | 4 +- src/main/wsl/wsl-runner.ts | 36 +--- src/shared/windows-command-line-budget.ts | 28 +++ 8 files changed, 454 insertions(+), 88 deletions(-) create mode 100644 src/main/git/source-control/bulk-pathspec-command-line-budget.test.ts create mode 100644 src/main/git/source-control/wsl-tracked-pathspec-banner.test.ts create mode 100644 src/shared/windows-command-line-budget.ts diff --git a/src/main/git/source-control/bulk-pathspec-command-line-budget.test.ts b/src/main/git/source-control/bulk-pathspec-command-line-budget.test.ts new file mode 100644 index 00000000000..39c4135aade --- /dev/null +++ b/src/main/git/source-control/bulk-pathspec-command-line-budget.test.ts @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + commandLineLength, + MAX_COMMAND_LINE_CHARS +} from '../../../shared/windows-command-line-budget' +import { resolveGitCommandWithoutProbe } from '../command-runner/git-command-resolution' + +const gitExecFileAsync = vi.fn(async () => ({ stdout: '', stderr: '' })) + +vi.mock('../runner', () => ({ + gitExecFileAsync: (...args: unknown[]) => + (gitExecFileAsync as unknown as (...a: unknown[]) => Promise<{ stdout: string }>)(...args) +})) +vi.mock('./git-read-cache-invalidation', () => ({ invalidateGitReadCaches: vi.fn() })) +vi.mock('../../../shared/git-discard-path-safety', () => ({ + removeSafeUntrackedDiscardTarget: vi.fn(), + removeSafeUntrackedDiscardTargets: async ( + _worktreePath: string, + untrackedPaths: string[], + cleanUntracked: (paths: string[]) => Promise, + restoreTracked: () => Promise + ) => { + await restoreTracked() + if (untrackedPaths.length > 0) { + await cleanUntracked(untrackedPaths) + } + } +})) + +const WSL_DISTRO = 'Ubuntu-24.04' +const WSL_WORKTREE = `\\\\wsl$\\${WSL_DISTRO}\\home\\emilio\\projects\\orca` + +/** Windows-side length of the line `wsl.exe` is spawned with, wrapper included. */ +function finishedCommandLineLength(args: readonly string[], wslDistro?: string): number { + const resolved = resolveGitCommandWithoutProbe([...args], { + cwd: wslDistro ? WSL_WORKTREE : '/home/emilio/projects/orca', + ...(wslDistro ? { wslDistro } : {}) + }) + return commandLineLength([resolved.binary, ...resolved.args]) +} + +/** Deep nesting, a space, and non-ASCII: all three inflate the quoted line. */ +function realisticChangedPaths(count: number): string[] { + return Array.from( + { length: count }, + (_, index) => + `apps/web/src/components/dashboard/widgets/analytics/rapport trimestriel ${String(index).padStart(3, '0')}/données-générales/AnalyticsSummaryWidget${index}.tsx` + ) +} + +function capturedInvocations(): string[][] { + return gitExecFileAsync.mock.calls.map((call) => (call as unknown as [string[]])[0]) +} + +describe('bulk pathspec command-line budget', () => { + const realPlatform = process.platform + + beforeEach(() => { + gitExecFileAsync.mockReset() + gitExecFileAsync.mockImplementation(async () => ({ stdout: '', stderr: '' })) + Object.defineProperty(process, 'platform', { value: 'win32' }) + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: realPlatform }) + vi.resetModules() + }) + + it('keeps every WSL bulk-stage invocation inside the Windows command-line cap', async () => { + const { bulkStageFiles } = await import('./staging') + const filePaths = realisticChangedPaths(100) + + await bulkStageFiles(WSL_WORKTREE, filePaths, { wslDistro: WSL_DISTRO }) + + const invocations = capturedInvocations() + expect(invocations.length).toBeGreaterThan(0) + const lengths = invocations.map((args) => finishedCommandLineLength(args, WSL_DISTRO)) + expect(Math.max(...lengths)).toBeLessThanOrEqual(MAX_COMMAND_LINE_CHARS) + }) + + it('stages every path exactly once, in order, across the chunks', async () => { + const { bulkStageFiles } = await import('./staging') + const filePaths = realisticChangedPaths(250) + + await bulkStageFiles(WSL_WORKTREE, filePaths, { wslDistro: WSL_DISTRO }) + + const staged = capturedInvocations().flatMap((args) => args.slice(args.indexOf('--') + 1)) + expect(staged).toEqual(filePaths.map((filePath) => `:(literal)${filePath}`)) + }) + + it('keeps WSL bulk unstage inside the cap', async () => { + const { bulkUnstageFiles } = await import('./staging') + + await bulkUnstageFiles(WSL_WORKTREE, realisticChangedPaths(100), { wslDistro: WSL_DISTRO }) + + const lengths = capturedInvocations().map((args) => finishedCommandLineLength(args, WSL_DISTRO)) + expect(Math.max(...lengths)).toBeLessThanOrEqual(MAX_COMMAND_LINE_CHARS) + }) + + it('never emits a pathspec-free chunk, which would widen `clean -ffdx` to the worktree', async () => { + const { bulkStageFiles } = await import('./staging') + + await bulkStageFiles(WSL_WORKTREE, realisticChangedPaths(300), { wslDistro: WSL_DISTRO }) + + for (const args of capturedInvocations()) { + expect(args.slice(args.indexOf('--') + 1).length).toBeGreaterThan(0) + } + }) + + it('splits a WSL bulk discard of tracked paths into spawnable restores', async () => { + const filePaths = realisticChangedPaths(120) + gitExecFileAsync.mockImplementation(async () => ({ stdout: filePaths.join('\0'), stderr: '' })) + const { bulkDiscardChanges } = await import('./discard-changes') + + await bulkDiscardChanges(WSL_WORKTREE, filePaths, { wslDistro: WSL_DISTRO }) + + const restores = capturedInvocations().filter((args) => args[0] === 'restore') + expect(restores.length).toBeGreaterThan(1) + for (const args of restores) { + expect(finishedCommandLineLength(args, WSL_DISTRO)).toBeLessThanOrEqual( + MAX_COMMAND_LINE_CHARS + ) + } + }) + + it('never widens `clean -ffdx` past the paths it was given', async () => { + const filePaths = realisticChangedPaths(120) + const { bulkDiscardChanges } = await import('./discard-changes') + + // Empty ls-files output: every path is untracked, so all of them take the clean lane. + await bulkDiscardChanges(WSL_WORKTREE, filePaths, { wslDistro: WSL_DISTRO }) + + const cleans = capturedInvocations().filter((args) => args[0] === 'clean') + expect(cleans.length).toBeGreaterThan(1) + const cleaned = cleans.flatMap((args) => args.slice(args.indexOf('--') + 1)) + expect(cleaned).toEqual(filePaths.map((filePath) => `:(literal)${filePath}`)) + for (const args of cleans) { + expect(finishedCommandLineLength(args, WSL_DISTRO)).toBeLessThanOrEqual( + MAX_COMMAND_LINE_CHARS + ) + } + }) + + it('ships a single over-budget pathspec alone rather than dropping it', async () => { + const { bulkStageFiles } = await import('./staging') + const hugePath = `src/${'nested-directory/'.repeat(700)}Component.tsx` + + await bulkStageFiles(WSL_WORKTREE, [hugePath, 'src/app.tsx'], { wslDistro: WSL_DISTRO }) + + const invocations = capturedInvocations() + expect(invocations).toHaveLength(2) + expect(invocations[0]).toEqual(['add', '--', `:(literal)${hugePath}`]) + expect(invocations[1]).toEqual(['add', '--', ':(literal)src/app.tsx']) + }) + + it('packs chunks to the budget instead of splitting timidly', async () => { + const { bulkStageFiles } = await import('./staging') + + await bulkStageFiles(WSL_WORKTREE, realisticChangedPaths(100), { wslDistro: WSL_DISTRO }) + + const lengths = capturedInvocations().map((args) => finishedCommandLineLength(args, WSL_DISTRO)) + // Every chunk but the last is filled to within one pathspec of the cap. + expect(Math.min(...lengths.slice(0, -1))).toBeGreaterThan(MAX_COMMAND_LINE_CHARS * 0.9) + }) + + it('gives a native Windows git.exe the CreateProcess cap and a POSIX host a larger one', async () => { + const { bulkPathspecCommands } = await import('./git-pathspec') + // Long enough that the raw argv alone passes the Windows cap with no wrapper in sight. + const filePaths = Array.from( + { length: 100 }, + (_, index) => `packages/${'deeply-nested-module/'.repeat(18)}file-${index}.ts` + ) + + const windowsNative = bulkPathspecCommands(['add', '--'], filePaths, 'C:\\repo', {}) + expect(windowsNative.length).toBeGreaterThan(1) + for (const args of windowsNative) { + expect(finishedCommandLineLength(args)).toBeLessThanOrEqual(MAX_COMMAND_LINE_CHARS) + } + + Object.defineProperty(process, 'platform', { value: 'linux' }) + expect(bulkPathspecCommands(['add', '--'], filePaths, '/repo', {})).toHaveLength(1) + }) + + it('does not charge a native invocation for the WSL wrapper', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }) + const { bulkStageFiles } = await import('./staging') + + await bulkStageFiles('/home/emilio/projects/orca', realisticChangedPaths(100)) + + // Same 100 paths that need several chunks under the WSL wrapper stay one native call. + expect(capturedInvocations()).toHaveLength(1) + }) +}) diff --git a/src/main/git/source-control/discard-changes.ts b/src/main/git/source-control/discard-changes.ts index 33fd2f3f84a..e06f76f0c44 100644 --- a/src/main/git/source-control/discard-changes.ts +++ b/src/main/git/source-control/discard-changes.ts @@ -7,7 +7,7 @@ import type { GitRuntimeOptions } from '../git-runtime-options' import { gitOptionsForWorktree } from '../git-runtime-options' import { gitExecFileAsync } from '../runner' import { invalidateGitReadCaches } from './git-read-cache-invalidation' -import { BULK_CHUNK_SIZE, isTrackedPathSpec, literalPathspec } from './git-pathspec' +import { bulkPathspecCommands, isTrackedPathSpec, literalPathspec } from './git-pathspec' /** * Discard working tree changes for a file. @@ -62,14 +62,15 @@ async function listTrackedPathSpecs( options: GitRuntimeOptions = {} ): Promise { const trackedPaths: string[] = [] - for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { - const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) - const { stdout } = await gitExecFileAsync( - ['ls-files', '-z', '--', ...chunk.map((filePath) => literalPathspec(filePath, options))], - { - ...gitOptionsForWorktree(worktreePath, options) - } - ) + const commands = bulkPathspecCommands(['ls-files', '-z', '--'], filePaths, worktreePath, options) + for (const args of commands) { + const { stdout } = await gitExecFileAsync(args, { + ...gitOptionsForWorktree(worktreePath, options), + // Why: this buffers rather than streams, so it must fence -- an unfenced WSL + // login shell glues its rc banner onto the first NUL record, and a tracked + // path that fails to match is silently reclassified as untracked. + captureWslLoginShellOutput: true + }) // Why: a tracked directory can hold enough paths to exceed the JS argument limit. for (const trackedPath of stdout.split('\0')) { if (trackedPath) { @@ -85,17 +86,11 @@ async function cleanUntrackedPaths( filePaths: readonly string[], options: GitRuntimeOptions = {} ): Promise { - for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { - const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) - if (chunk.length > 0) { - // Why: Git pathspec cleanup avoids raw recursive deletion through symlinked parents. - await gitExecFileAsync( - ['clean', '-ffdx', '--', ...chunk.map((filePath) => literalPathspec(filePath, options))], - { - ...gitOptionsForWorktree(worktreePath, options) - } - ) - } + // Why: Git pathspec cleanup avoids raw recursive deletion through symlinked parents. + // A pathspec-free `clean -ffdx` would sweep the whole worktree; the chunker emits no empty chunk. + const commands = bulkPathspecCommands(['clean', '-ffdx', '--'], filePaths, worktreePath, options) + for (const args of commands) { + await gitExecFileAsync(args, { ...gitOptionsForWorktree(worktreePath, options) }) } } @@ -133,20 +128,14 @@ export async function bulkDiscardChanges( untrackedPaths, (targetPaths) => cleanUntrackedPaths(worktreePath, targetPaths, options), async () => { - for (let i = 0; i < trackedPaths.length; i += BULK_CHUNK_SIZE) { - const chunk = trackedPaths.slice(i, i + BULK_CHUNK_SIZE) - await gitExecFileAsync( - [ - 'restore', - '--worktree', - '--source=HEAD', - '--', - ...chunk.map((filePath) => literalPathspec(filePath, options)) - ], - { - ...gitOptionsForWorktree(worktreePath, options) - } - ) + const commands = bulkPathspecCommands( + ['restore', '--worktree', '--source=HEAD', '--'], + trackedPaths, + worktreePath, + options + ) + for (const args of commands) { + await gitExecFileAsync(args, { ...gitOptionsForWorktree(worktreePath, options) }) } } ) diff --git a/src/main/git/source-control/git-pathspec.ts b/src/main/git/source-control/git-pathspec.ts index 29ce04ade99..c4fe23d697e 100644 --- a/src/main/git/source-control/git-pathspec.ts +++ b/src/main/git/source-control/git-pathspec.ts @@ -1,6 +1,20 @@ +import { + commandLineLength, + MAX_COMMAND_LINE_CHARS +} from '../../../shared/windows-command-line-budget' +import { resolveGitCommandWithoutProbe } from '../command-runner/git-command-resolution' import type { GitRuntimeOptions } from '../git-runtime-options' -export const BULK_CHUNK_SIZE = 100 +/** Ceiling on argv entries per invocation; under WSL the byte budget bites first. */ +const BULK_CHUNK_SIZE = 100 + +/** + * POSIX hosts have no CreateProcess cap: ARG_MAX is 256KB on macOS and 2MB on + * Linux, shared with the environment block. Half the macOS floor keeps a native + * or SSH-host invocation clear of E2BIG without charging it the WSL wrapper's + * quoting overhead, which is a different transport's problem. + */ +const POSIX_COMMAND_LINE_BUDGET = 128_000 function normalizeGitPathForCompare(filePath: string): string { return filePath.replace(/\\/g, '/').replace(/\/+$/, '') @@ -19,3 +33,71 @@ export function isTrackedPathSpec(filePath: string, trackedPaths: readonly strin return normalizedTracked === normalized || normalizedTracked.startsWith(`${normalized}/`) }) } + +/** + * Length of the line the OS will actually be handed, wrapper included. + * + * Why resolve rather than estimate: a WSL-routed write goes through the login + * shell, which shell-quotes every pathspec, quotes the resulting command line + * again, and embeds that three times (one branch per guest shell). The finished + * line runs ~3.4x the raw pathspec bytes, and nothing about the path list says + * so. Writes never take the direct-git lane, so this is the exact shape they + * get; a read that does take it resolves shorter, so the estimate stays safe. + */ +function finishedCommandLineLength( + args: readonly string[], + worktreePath: string, + options: GitRuntimeOptions +): number { + const resolved = resolveGitCommandWithoutProbe([...args], { + cwd: worktreePath, + ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}) + }) + return commandLineLength([resolved.binary, ...resolved.args]) +} + +/** + * Split a bulk pathspec operation into invocations the host can actually spawn. + * + * Why a byte budget and not a path count: 100 was chosen against a raw argv, but + * a WSL-routed `git add` is folded into one login-shell command line, so 100 + * ordinary paths reached ~43,000 characters -- past the 32,767 CreateProcess cap + * -- and the bulk stage failed with nothing staged. Cost is measured per + * pathspec through the real resolver so the wrapper's quoting rules live in one + * place. + * + * Chunks split only between whole pathspecs, and a pathspec that alone exceeds + * the budget still ships alone rather than being dropped or truncated. If an + * invocation fails partway through, the earlier chunks stay applied: every + * operation here is idempotent and per-path, so `git status` shows the true + * state and re-running converges. + */ +export function bulkPathspecCommands( + leadingArgs: readonly string[], + filePaths: readonly string[], + worktreePath: string, + options: GitRuntimeOptions +): string[][] { + // Budget belongs to the host that spawns; the overhead measured above belongs to the transport. + const budget = process.platform === 'win32' ? MAX_COMMAND_LINE_CHARS : POSIX_COMMAND_LINE_BUDGET + const baseLength = finishedCommandLineLength(leadingArgs, worktreePath, options) + const commands: string[][] = [] + let pathspecs: string[] = [] + let length = baseLength + for (const filePath of filePaths) { + const pathspec = literalPathspec(filePath, options) + const cost = + finishedCommandLineLength([...leadingArgs, pathspec], worktreePath, options) - baseLength + if (pathspecs.length > 0 && (pathspecs.length >= BULK_CHUNK_SIZE || length + cost > budget)) { + commands.push([...leadingArgs, ...pathspecs]) + pathspecs = [] + length = baseLength + } + pathspecs.push(pathspec) + length += cost + } + if (pathspecs.length > 0) { + commands.push([...leadingArgs, ...pathspecs]) + } + return commands +} diff --git a/src/main/git/source-control/staging.ts b/src/main/git/source-control/staging.ts index 5aa8f55fa48..3aa6e16bbc9 100644 --- a/src/main/git/source-control/staging.ts +++ b/src/main/git/source-control/staging.ts @@ -2,7 +2,7 @@ import type { GitRuntimeOptions } from '../git-runtime-options' import { gitOptionsForWorktree } from '../git-runtime-options' import { gitExecFileAsync } from '../runner' import { invalidateGitReadCaches } from './git-read-cache-invalidation' -import { BULK_CHUNK_SIZE, literalPathspec } from './git-pathspec' +import { bulkPathspecCommands, literalPathspec } from './git-pathspec' /** * Stage a file. @@ -54,12 +54,8 @@ export async function bulkStageFiles( return } try { - for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { - const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) - await gitExecFileAsync( - ['add', '--', ...chunk.map((filePath) => literalPathspec(filePath, options))], - gitOptionsForWorktree(worktreePath, options) - ) + for (const args of bulkPathspecCommands(['add', '--'], filePaths, worktreePath, options)) { + await gitExecFileAsync(args, gitOptionsForWorktree(worktreePath, options)) } } finally { invalidateGitReadCaches() @@ -79,19 +75,14 @@ export async function bulkUnstageFiles( return } try { - for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { - const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) - await gitExecFileAsync( - [ - 'restore', - '--staged', - '--', - ...chunk.map((filePath) => literalPathspec(filePath, options)) - ], - { - ...gitOptionsForWorktree(worktreePath, options) - } - ) + const commands = bulkPathspecCommands( + ['restore', '--staged', '--'], + filePaths, + worktreePath, + options + ) + for (const args of commands) { + await gitExecFileAsync(args, { ...gitOptionsForWorktree(worktreePath, options) }) } } finally { invalidateGitReadCaches() diff --git a/src/main/git/source-control/wsl-tracked-pathspec-banner.test.ts b/src/main/git/source-control/wsl-tracked-pathspec-banner.test.ts new file mode 100644 index 00000000000..fbae736e77f --- /dev/null +++ b/src/main/git/source-control/wsl-tracked-pathspec-banner.test.ts @@ -0,0 +1,109 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock, execFileSyncMock, spawnMock } = vi.hoisted(() => ({ + execFileMock: vi.fn(), + execFileSyncMock: vi.fn(), + spawnMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ + execFile: execFileMock, + execFileSync: execFileSyncMock, + spawn: spawnMock +})) +vi.mock('../../observability/instrumentation', () => ({ + withGitSpan: (_attributes: unknown, run: () => unknown) => run() +})) +vi.mock('../../diagnostics/main-thread-churn-probe', () => ({ recordSubprocessSpawn: vi.fn() })) +vi.mock('./git-read-cache-invalidation', () => ({ invalidateGitReadCaches: vi.fn() })) +// Stand in for the on-disk safety filter: every discard target here exists and is symlink-free. +vi.mock('../../../shared/git-discard-path-safety', () => ({ + removeSafeUntrackedDiscardTarget: vi.fn(), + removeSafeUntrackedDiscardTargets: async ( + _worktreePath: string, + untrackedPaths: string[], + cleanUntracked: (paths: string[]) => Promise, + restoreTracked: () => Promise + ) => { + await restoreTracked() + if (untrackedPaths.length > 0) { + await cleanUntracked(untrackedPaths) + } + } +})) + +import { bulkDiscardChanges } from './discard-changes' +import { resetWslGitReadEnvironmentForTests } from '../wsl-git-read-environment' + +const DISTRO = 'Ubuntu-24.04' +const WSL_WORKTREE = `\\\\wsl$\\${DISTRO}\\home\\emilio\\projects\\orca` +const TRACKED_PATHS = ['docs/architecture.md', 'src/main/git/runner.ts'] +// Stock Ubuntu writes this to *stdout* from the interactive login shell's rc. +const BANNER = 'To run a command as administrator (user "root"), use "sudo ".\n\n' + +type MockChild = EventEmitter & { stdout: EventEmitter; stderr: EventEmitter; kill: () => void } + +function createMockChild(): MockChild { + const child = new EventEmitter() as MockChild + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + child.kill = vi.fn() + return child +} + +function guestScript(args: unknown): string { + return (args as string[] | undefined)?.join(' ') ?? '' +} + +/** Wrap the payload in the caller's own fence when it asked for one; otherwise hand it over raw. */ +function loginShellStdout(script: string, payload: string): string { + const nonce = /__ORCA_WSL_CAPTURE_BEGIN_([^_]+)__/.exec(script)?.[1] + return nonce + ? `${BANNER}__ORCA_WSL_CAPTURE_BEGIN_${nonce}__${payload}__ORCA_WSL_CAPTURE_END_${nonce}__` + : `${BANNER}${payload}` +} + +function gitCommandLines(): string[] { + return execFileMock.mock.calls + .map((call) => guestScript(call[1])) + .filter((script) => !script.includes('_orca_git_path=')) +} + +describe('WSL tracked-path listing behind a login-shell banner', () => { + const realPlatform = process.platform + + beforeEach(() => { + resetWslGitReadEnvironmentForTests() + execFileMock.mockReset() + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + execFileMock.mockImplementation((_command, args, _options, callback) => { + const script = guestScript(args) + if (script.includes('_orca_git_path=')) { + // Distro exports GIT_* / XDG_CONFIG_HOME, so the direct-git read probe is rejected for good. + queueMicrotask(() => callback?.(Object.assign(new Error('probe rejected'), { code: 78 }))) + return createMockChild() + } + const payload = script.includes('ls-files') ? `${TRACKED_PATHS.join('\0')}\0` : '' + queueMicrotask(() => callback?.(null, loginShellStdout(script, payload), '')) + return createMockChild() + }) + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: realPlatform }) + resetWslGitReadEnvironmentForTests() + }) + + it('restores every tracked path instead of routing the first one to git clean', async () => { + await bulkDiscardChanges(WSL_WORKTREE, [...TRACKED_PATHS], { wslDistro: DISTRO }) + + const commandLines = gitCommandLines() + expect(commandLines.filter((line) => line.includes('clean'))).toEqual([]) + const restored = commandLines.filter((line) => line.includes('restore')) + expect(restored.length).toBeGreaterThan(0) + for (const trackedPath of TRACKED_PATHS) { + expect(restored.some((line) => line.includes(`:(literal)${trackedPath}`))).toBe(true) + } + }) +}) diff --git a/src/main/git/status-discard-and-bulk-staging.test.ts b/src/main/git/status-discard-and-bulk-staging.test.ts index b1f39f9102b..8e0b12623c4 100644 --- a/src/main/git/status-discard-and-bulk-staging.test.ts +++ b/src/main/git/status-discard-and-bulk-staging.test.ts @@ -198,7 +198,9 @@ describe('bulk git helpers', () => { ':(literal)scratch' ], { - cwd: '/repo' + cwd: '/repo', + // Why: this read buffers, so it fences the WSL login shell's rc banner off its first NUL record. + captureWslLoginShellOutput: true } ) // Why: a pathspec is tracked if git reports either the exact path or a diff --git a/src/main/wsl/wsl-runner.ts b/src/main/wsl/wsl-runner.ts index 6a0fbc051ac..30097246a86 100644 --- a/src/main/wsl/wsl-runner.ts +++ b/src/main/wsl/wsl-runner.ts @@ -1,4 +1,5 @@ import { addWslEnvKeys } from '../../shared/wsl-env' +import { commandLineLength, MAX_COMMAND_LINE_CHARS } from '../../shared/windows-command-line-budget' import { runProcess } from '../../shared/child-process/run-process' import { buildWslExecArgs } from '../../shared/wsl-login-shell-command' import { getWslGuestEnvironment, type WslGuestEnvironment } from './wsl-guest-environment' @@ -159,25 +160,6 @@ function withGuestCwd(cwd: string | undefined, argv: readonly string[]): string[ return ['sh', '-c', 'cd "$1" || exit 1; shift; exec "$@"', 'orca-wsl', cwd, ...argv] } -/** - * Argv is the default, but it has a hard ceiling that stdin does not. - * - * Windows caps a command line at 32767 characters, and the distro, `--exec`, - * the env prefix and the args all share it. A user's `orca.yaml` hook is the - * one unbounded script Orca runs -- `run-both` concatenates two of them, and a - * vendored installer is ~15KB -- so past this size the choice is between - * failing to spawn at all and accepting the stdin caveat. Degrading beats - * failing: a large script that also reads stdin was already broken, while a - * large script that does not now works where it would have died. - * - * Measured on the WHOLE command line, not on the script alone. A login PATH is - * itself a few KB and is spliced in as `PATH=...`, so a script-only threshold - * produced a perverse band: with a long enough PATH, a 7,999-char hook went to - * argv and failed to spawn while the same hook at 8,001 chars flipped to stdin - * and ran. Size decided how a hook behaved, in the wrong direction. - */ -const MAX_COMMAND_LINE_CHARS = 30_000 - /** ` -c`/`-s` for a script, otherwise the program itself. */ function guestCommandArgv(spec: WslSpec, delivery: 'argv' | 'stdin'): string[] { if (spec.script === undefined) { @@ -190,19 +172,6 @@ function guestCommandArgv(spec: WslSpec, delivery: 'argv' | 'stdin'): string[] { : [shell, '-c', spec.script, '--', ...(spec.args ?? [])] } -/** - * What `CreateProcess` will count. - * - * libuv escapes every `"` and doubles a backslash run before a quote, so a - * quote-dense script costs more than its length. Charging one extra character - * per `"` or `\\` keeps the estimate on the safe side of the cap; an earlier - * version claimed to over-count and in fact under-counted, which put a - * quote-heavy ~26KB script on argv and over the real limit. - */ -function commandLineLength(args: readonly string[]): number { - return args.reduce((total, arg) => total + arg.length + 3 + (arg.match(/["\\]/g)?.length ?? 0), 0) -} - /** Shell-free argv, with the cached environment applied when one is available. */ function buildGuestArgv( environment: WslGuestEnvironment | null, @@ -255,6 +224,9 @@ export async function runWslProcess(spec: WslSpec): Promise { // Measure what is actually spawned: `wsl.exe` and `-d --exec` are // prepended after this point and are part of the same budget. const fullLine = [resolveWslExecutablePath(), ...buildWslExecArgs(spec.distro, argvForm)] + // Argv is the default, but it has a hard ceiling that stdin does not. A user's + // `orca.yaml` hook is the one unbounded script Orca runs, so past the cap the + // choice is between failing to spawn at all and accepting the stdin caveat. const delivery: 'argv' | 'stdin' = spec.script !== undefined && commandLineLength(fullLine) > MAX_COMMAND_LINE_CHARS ? 'stdin' diff --git a/src/shared/windows-command-line-budget.ts b/src/shared/windows-command-line-budget.ts new file mode 100644 index 00000000000..77074e3f531 --- /dev/null +++ b/src/shared/windows-command-line-budget.ts @@ -0,0 +1,28 @@ +/** + * How long a command line may get before `CreateProcess` refuses it. + * + * Windows caps a command line at 32767 characters, and *everything* shares that + * one budget: the binary, `wsl.exe -d --exec`, the login-shell wrapper + * and the payload. So the number only means anything when it is measured on the + * FINISHED line. Two shipped defects came from measuring a part instead: a + * script-only threshold in the WSL runner, where a multi-KB login PATH pushed a + * legal-looking script over the real limit, and count-only chunking of bulk git + * pathspecs, where the login-shell wrapper tripled the line behind our back. + * + * The 2767-character margin absorbs what we do not model exactly (the distro + * name, libuv's requoting of the outer argv). + */ +export const MAX_COMMAND_LINE_CHARS = 30_000 + +/** + * What `CreateProcess` will count. + * + * libuv escapes every `"` and doubles a backslash run before a quote, so a + * quote-dense script costs more than its length. Charging one extra character + * per `"` or `\\` keeps the estimate on the safe side of the cap; an earlier + * version claimed to over-count and in fact under-counted, which put a + * quote-heavy ~26KB script on argv and over the real limit. + */ +export function commandLineLength(args: readonly string[]): number { + return args.reduce((total, arg) => total + arg.length + 3 + (arg.match(/["\\]/g)?.length ?? 0), 0) +} From 7d5c7aa9c3533ec3d880feb058c5e068e36df55a Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Wed, 26 Aug 2026 15:04:51 -0700 Subject: [PATCH 018/152] i18n: Make skill install dialogs and errors translatable (#16682) Extract hardcoded error messages and status labels from skill installation components into the i18n system. Supports localized UI for install flows in English, Spanish, Japanese, Korean, Chinese. Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../skills/SkillBundleInstallFlow.tsx | 12 ++- .../components/skills/SkillInstallDialog.tsx | 19 ++-- .../skills/SkillInstallManagementDialog.tsx | 19 ++-- .../components/skills/SkillShareDialog.tsx | 7 +- .../skills/skill-install-progress-state.ts | 12 +-- src/renderer/src/i18n/locales/en.json | 20 ++++- src/renderer/src/i18n/locales/es.json | 87 +++++++++++++++++++ src/renderer/src/i18n/locales/ja.json | 48 ++++++++++ src/renderer/src/i18n/locales/ko.json | 13 +++ src/renderer/src/i18n/locales/zh.json | 23 ++--- 10 files changed, 222 insertions(+), 38 deletions(-) diff --git a/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx b/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx index 74d3c994c14..2040b63682b 100644 --- a/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx +++ b/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx @@ -126,7 +126,11 @@ export function SkillBundleInstallFlow(props: { ): Promise => { const target = destination() if (!target || requestedIds.size === 0) { - setError(target ? 'Select at least one skill.' : 'Choose a workspace.') + setError( + target + ? translate('auto.components.skills.install.selectSkill', 'Select at least one skill.') + : translate('auto.components.skills.install.chooseWorkspace', 'Choose a workspace.') + ) return } setBusy(true) @@ -189,7 +193,7 @@ export function SkillBundleInstallFlow(props: { } else if (operation.status !== 'ok') { setError( operation.status === 'reconnect-required' - ? 'Reconnect your Orca account before installing.' + ? translate('auto.components.skills.install.reconnectBeforeInstalling', 'Reconnect your Orca account before installing.') : operation.message ) } else { @@ -204,7 +208,7 @@ export function SkillBundleInstallFlow(props: { } } catch (cause) { console.warn('[skills] bundle install failed:', cause) - setError('Installation failed before Orca could verify the requested bundle.') + setError(translate('auto.components.skills.install.bundleVerificationFailed', 'Installation failed before Orca could verify the requested bundle.')) } finally { installProgress.finish() setBusy(false) @@ -221,7 +225,7 @@ export function SkillBundleInstallFlow(props: { ...(environmentId === 'local' || environmentId.startsWith('ssh:') ? {} : { environmentId }) }) if (!cancelled.cancelled) { - setError('The destination had already finished this installation.') + setError(translate('auto.components.skills.install.destinationAlreadyFinished', 'The destination had already finished this installation.')) } } diff --git a/src/renderer/src/components/skills/SkillInstallDialog.tsx b/src/renderer/src/components/skills/SkillInstallDialog.tsx index e011264374f..c5498ac6b3d 100644 --- a/src/renderer/src/components/skills/SkillInstallDialog.tsx +++ b/src/renderer/src/components/skills/SkillInstallDialog.tsx @@ -91,7 +91,7 @@ export function SkillInstallDialog({ const resolveLink = useCallback(async (value: string): Promise => { const shareId = parseSkillShareId(value) if (!shareId) { - setError('Enter an Orca skill share link.') + setError(translate('auto.components.skills.install.enterShareLink', 'Enter an Orca skill share link.')) return } setBusy(true) @@ -103,14 +103,14 @@ export function SkillInstallDialog({ setError( operation.status === 'unconfigured' ? operation.message - : 'This share is unavailable. The link may be invalid, expired, or revoked.' + : translate('auto.components.skills.install.shareUnavailable', 'This share is unavailable. The link may be invalid, expired, or revoked.') ) return } setPreview({ shareId, version: operation.value.version }) } catch (cause) { console.warn('[skills] share resolution failed:', cause) - setError('This share is unavailable. The link may be invalid, expired, or revoked.') + setError(translate('auto.components.skills.install.shareUnavailable', 'This share is unavailable. The link may be invalid, expired, or revoked.')) } finally { setBusy(false) } @@ -138,7 +138,7 @@ export function SkillInstallDialog({ } const choice = workspaceChoices.find((candidate) => candidate.id === workspace) if (scope === 'workspace' && !choice) { - setError('Choose a workspace.') + setError(translate('auto.components.skills.install.chooseWorkspace', 'Choose a workspace.')) return } setBusy(true) @@ -204,7 +204,7 @@ export function SkillInstallDialog({ if (operation.status !== 'ok') { setError( operation.status === 'reconnect-required' - ? 'Reconnect your Orca account before installing.' + ? translate('auto.components.skills.install.reconnectBeforeInstalling', 'Reconnect your Orca account before installing.') : operation.message ) return @@ -215,7 +215,12 @@ export function SkillInstallDialog({ } } catch (cause) { console.warn('[skills] install failed:', cause) - setError('Installation failed before Orca could verify the requested version.') + setError( + translate( + 'auto.components.skills.install.requestedVersionVerificationFailed', + 'Installation failed before Orca could verify the requested version.' + ) + ) } finally { installProgress.finish() setBusy(false) @@ -231,7 +236,7 @@ export function SkillInstallDialog({ ...(environmentId === 'local' || environmentId.startsWith('ssh:') ? {} : { environmentId }) }) if (!cancelled.cancelled) { - setError('The destination had already finished this installation.') + setError(translate('auto.components.skills.install.destinationAlreadyFinished', 'The destination had already finished this installation.')) } } diff --git a/src/renderer/src/components/skills/SkillInstallManagementDialog.tsx b/src/renderer/src/components/skills/SkillInstallManagementDialog.tsx index 47d7518ccdd..a760af0335e 100644 --- a/src/renderer/src/components/skills/SkillInstallManagementDialog.tsx +++ b/src/renderer/src/components/skills/SkillInstallManagementDialog.tsx @@ -28,6 +28,7 @@ import { } from './skill-managed-install-groups' import { SkillInstallMachineSelect } from './SkillInstallMachineSelect' import { SkillInstallManagementStatus } from './SkillInstallManagementStatus' +import { translate } from '@/i18n/i18n' export function SkillInstallManagementDialog({ open, @@ -90,7 +91,7 @@ export function SkillInstallManagementDialog({ return } console.warn('[skills] managed install listing failed:', cause) - setError('Orca could not inspect managed installs on this machine.') + setError(translate('auto.components.skills.install.inspectManagedFailed', 'Orca could not inspect managed installs on this machine.')) } finally { if (generation === loadGeneration.current) { setBusy(false) @@ -120,7 +121,7 @@ export function SkillInstallManagementDialog({ if (operation.status !== 'ok') { setError( operation.status === 'reconnect-required' - ? 'Reconnect your Orca account to load version history.' + ? translate('auto.components.skills.install.reconnectForVersionHistory', 'Reconnect your Orca account to load version history.') : operation.message ) return @@ -132,7 +133,7 @@ export function SkillInstallManagementDialog({ return } console.warn('[skills] package history failed:', cause) - setError('Version history is unavailable for this skill.') + setError(translate('auto.components.skills.install.versionHistoryUnavailable', 'Version history is unavailable for this skill.')) } finally { if (generation === detailGeneration.current) { setBusy(false) @@ -168,7 +169,7 @@ export function SkillInstallManagementDialog({ installedNames.has(skill.name) ) if (selectedSkills.length === 0) { - setError('This version does not contain any of the installed bundle skills.') + setError(translate('auto.components.skills.install.bundleSkillsMissing', 'This version does not contain any of the installed bundle skills.')) return } const operation = await window.api.skills.installBundlePackageVersion({ @@ -193,7 +194,7 @@ export function SkillInstallManagementDialog({ if (operation.status !== 'ok') { setError( operation.status === 'reconnect-required' - ? 'Reconnect your Orca account before changing versions.' + ? translate('auto.components.skills.install.reconnectBeforeVersionChange', 'Reconnect your Orca account before changing versions.') : operation.message ) return @@ -219,7 +220,7 @@ export function SkillInstallManagementDialog({ if (operation.status !== 'ok') { setError( operation.status === 'reconnect-required' - ? 'Reconnect your Orca account before changing versions.' + ? translate('auto.components.skills.install.reconnectBeforeVersionChange', 'Reconnect your Orca account before changing versions.') : operation.message ) return @@ -233,7 +234,7 @@ export function SkillInstallManagementDialog({ } } catch (cause) { console.warn('[skills] version installation failed:', cause) - setError('Orca could not verify the requested version.') + setError(translate('auto.components.skills.install.versionVerificationFailed', 'Orca could not verify the requested version.')) } finally { installProgress.finish() setBusy(false) @@ -249,7 +250,7 @@ export function SkillInstallManagementDialog({ ...(environmentId === 'local' || environmentId.startsWith('ssh:') ? {} : { environmentId }) }) if (!cancelled.cancelled) { - setError('The destination had already finished this installation.') + setError(translate('auto.components.skills.install.destinationAlreadyFinished', 'The destination had already finished this installation.')) } } @@ -294,7 +295,7 @@ export function SkillInstallManagementDialog({ } } catch (cause) { console.warn('[skills] managed removal failed:', cause) - setError('Orca could not safely remove this skill.') + setError(translate('auto.components.skills.install.removeFailed', 'Orca could not safely remove this skill.')) } finally { setBusy(false) } diff --git a/src/renderer/src/components/skills/SkillShareDialog.tsx b/src/renderer/src/components/skills/SkillShareDialog.tsx index 145f9066095..9cf73590dfc 100644 --- a/src/renderer/src/components/skills/SkillShareDialog.tsx +++ b/src/renderer/src/components/skills/SkillShareDialog.tsx @@ -231,7 +231,12 @@ export function SkillShareDialog({ } catch { cancellationRequested.current = false setCancelling(false) - setError('Orca could not send the cancellation request. The upload may still finish.') + setError( + translate( + 'auto.components.skills.SkillShareDialog.cancelRequestFailed', + 'Orca could not send the cancellation request. The upload may still finish.' + ) + ) } } diff --git a/src/renderer/src/components/skills/skill-install-progress-state.ts b/src/renderer/src/components/skills/skill-install-progress-state.ts index d88c523ea95..c16b27f402c 100644 --- a/src/renderer/src/components/skills/skill-install-progress-state.ts +++ b/src/renderer/src/components/skills/skill-install-progress-state.ts @@ -2,11 +2,6 @@ import { useEffect, useRef, useState } from 'react' import type { SkillInstallProgress } from '../../../../shared/skill-sharing-contract' import { translate } from '@/i18n/i18n' -const INSTALL_PHASE_LABELS = { - authorizing: 'Authorizing package access…', - installing: 'Downloading, verifying, and installing…' -} as const - export function useSkillInstallProgress(): { activeOperationId: string | null phaseLabel: string | null @@ -40,7 +35,12 @@ export function useSkillInstallProgress(): { } ) : progress - ? INSTALL_PHASE_LABELS[progress.phase] + ? progress.phase === 'authorizing' + ? translate('auto.components.skills.install.authorizing', 'Authorizing package access…') + : translate( + 'auto.components.skills.install.installing', + 'Downloading, verifying, and installing…' + ) : null, begin: (operationId) => { activeOperationIdRef.current = operationId diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ac4dae956b3..c776a97452a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4377,7 +4377,8 @@ "3af85f6add": "Done", "readyDescriptionV2": "Anyone with this unlisted link can inspect and install the skills.", "descriptionV2": "Review the exact files, then publish an immutable version behind an unlisted link.", - "publishBundle": "Publish bundle" + "publishBundle": "Publish bundle", + "cancelRequestFailed": "Orca could not send the cancellation request. The upload may still finish." }, "SkillCard": { "d25a1b8ae6": "Share skill", @@ -4670,6 +4671,23 @@ "showMore": "Show more" }, "install": { + "authorizing": "Authorizing package access…", + "installing": "Downloading, verifying, and installing…", + "selectSkill": "Select at least one skill.", + "chooseWorkspace": "Choose a workspace.", + "reconnectBeforeInstalling": "Reconnect your Orca account before installing.", + "bundleVerificationFailed": "Installation failed before Orca could verify the requested bundle.", + "destinationAlreadyFinished": "The destination had already finished this installation.", + "enterShareLink": "Enter an Orca skill share link.", + "shareUnavailable": "This share is unavailable. The link may be invalid, expired, or revoked.", + "requestedVersionVerificationFailed": "Installation failed before Orca could verify the requested version.", + "versionVerificationFailed": "Orca could not verify the requested version.", + "inspectManagedFailed": "Orca could not inspect managed installs on this machine.", + "reconnectForVersionHistory": "Reconnect your Orca account to load version history.", + "versionHistoryUnavailable": "Version history is unavailable for this skill.", + "bundleSkillsMissing": "This version does not contain any of the installed bundle skills.", + "reconnectBeforeVersionChange": "Reconnect your Orca account before changing versions.", + "removeFailed": "Orca could not safely remove this skill.", "agentsLabel": "Agents", "agentsCanonical": "Always installed for {{value0}}, which read {{value1}}.", "agentsNoneChosen": "No extra agents", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 62165ef4f48..41b300a10e8 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3872,6 +3872,93 @@ } }, "skills": { + "SkillsPage": { + "cb142070b4": "Actualizar", + "a68dee6a32": "Buscar skills", + "f43ad6edf3": "Skills", + "ea72d6185b": "No se pudieron analizar las skills", + "dc4c3328ee": "Mostrar archivo", + "9963dff6d3": "No se encontró ninguna descripción.", + "995fde8337": "No se pudo mostrar el archivo de la skill", + "4acd6d68ec": "No se encontraron skills", + "6a62a0168c": "Sin coincidencias", + "08a321a984": "Ninguna skill coincide con la búsqueda y los filtros actuales.", + "cd7893fbc1": "Analizando skills", + "35b9a724a0": "Disponible", + "c13b82793c": "Gestionar instalaciones", + "aee7b99cc6": "Instalar desde un enlace", + "filterProvider": "Filtrar por agente", + "filterSource": "Filtrar por origen", + "allSources": "Todo", + "clearFilters": "Borrar filtros", + "closeSkills": "Cerrar skills", + "closeTooltip": "Cerrar · Esc", + "moreActions": "Más acciones", + "sharedLinks": "Enlaces compartidos", + "emptyCopy": "Las carpetas de skills analizadas están vacías. Instala un paquete compartido o actualiza después de añadir una skill.", + "retry": "Reintentar", + "remoteShareNotice": "Estas skills están en {{host}}. Abre Skills en esa máquina para compartirlas.", + "viewSwitch": "Mostrar", + "searchLinks": "Buscar enlaces", + "deleteSkills": "Eliminar skills…" + }, + "SkillShareSelectionControls": { + "01c5a15e02": "Compartir skills" + }, + "SkillRow": { + "updatedUnknown": "Sin fecha", + "pathCopied": "Ruta copiada", + "copyPath": "Copiar ruta", + "detailPath": "Ruta", + "skillActions": "Acciones para {{value0}}", + "viewDetails": "Ver detalles", + "deleteSkill": "Eliminar…" + }, + "SkillsList": { "listLabel": "Skills" }, + "sourceStatus": { + "missing": "Carpeta no encontrada", + "remoteRepo": "Repositorio remoto — sin analizar", + "unavailable": "Sin analizar" + }, + "sources": { "heading": "Carpetas de skills" }, + "sourceKind": { + "home": "Inicio", + "workspace": "Espacio de trabajo", + "bundled": "Incluida", + "plugin": "Plugin" + }, + "count": { + "skillOne": "{{count}} skill", + "skillOther": "{{count}} skills", + "sourceOne": "{{count}} origen", + "sourceOther": "{{count}} orígenes", + "fileOne": "{{count}} archivo", + "fileOther": "{{count}} archivos", + "resultOne": "{{count}} resultado", + "resultOther": "{{count}} resultados", + "selected": "{{count}} seleccionadas", + "shareOne": "Compartir {{count}} skill", + "shareOther": "Compartir {{count}} skills", + "linkOne": "{{count}} enlace", + "linkOther": "{{count}} enlaces" + }, + "filter": { + "allAgents": "Todos los agentes", + "sharedAgent": "Compartido (.agents)" + }, + "SkillsSelectionHeader": { + "exit": "Salir de la selección", + "exitTooltip": "Salir de la selección · Esc", + "title": "Seleccionar skills para compartir", + "selectAll": "Seleccionar las {{count}} aptas", + "clear": "Borrar", + "deleteTitle": "Seleccionar skills para eliminar" + }, + "SkillDetailDialog": { + "agents": "Agentes", + "updated": "Actualizado", + "copy": "Copiar" + }, "SkillFreshnessNudge": { "titleOne": "Una skill de Orca instalada está desactualizada", "titleMany": "{{value0}} skills de Orca instaladas están desactualizadas", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 3f2f4d4491f..d9466efd0c1 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3872,6 +3872,54 @@ } }, "skills": { + "SkillsPage": { + "cb142070b4": "更新", + "a68dee6a32": "スキルを検索", + "f43ad6edf3": "スキル", + "ea72d6185b": "スキルをスキャンできませんでした", + "dc4c3328ee": "ファイルを表示", + "9963dff6d3": "説明が見つかりません。", + "995fde8337": "スキルファイルを表示できませんでした", + "4acd6d68ec": "スキルが見つかりません", + "6a62a0168c": "一致する項目はありません", + "08a321a984": "現在の検索条件とフィルターに一致するスキルはありません。", + "cd7893fbc1": "スキルをスキャン中", + "35b9a724a0": "利用可能", + "c13b82793c": "インストールを管理", + "aee7b99cc6": "リンクからインストール", + "filterProvider": "エージェントで絞り込み", + "filterSource": "ソースで絞り込み", + "allSources": "すべて", + "clearFilters": "フィルターをクリア", + "closeSkills": "スキルを閉じる", + "closeTooltip": "閉じる · Esc", + "moreActions": "その他の操作", + "sharedLinks": "共有リンク", + "emptyCopy": "スキャンしたスキルフォルダーは空です。共有バンドルをインストールするか、スキルを追加後に更新してください。", + "retry": "再試行", + "remoteShareNotice": "これらのスキルは {{host}} にあります。共有するには、そのマシンでスキルを開いてください。", + "viewSwitch": "表示", + "searchLinks": "リンクを検索", + "deleteSkills": "スキルを削除…" + }, + "SkillShareSelectionControls": { "01c5a15e02": "スキルを共有" }, + "SkillRow": { + "updatedUnknown": "日付なし", + "pathCopied": "パスをコピーしました", + "copyPath": "パスをコピー", + "detailPath": "パス", + "skillActions": "{{value0}} の操作", + "viewDetails": "詳細を表示", + "deleteSkill": "削除…" + }, + "SkillsList": { "listLabel": "スキル" }, + "sourceStatus": { "missing": "フォルダーが見つかりません", "remoteRepo": "リモートリポジトリ — 未スキャン", "unavailable": "未スキャン" }, + "sources": { "heading": "スキルフォルダー" }, + "sourceKind": { "home": "ホーム", "workspace": "ワークスペース", "bundled": "バンドル済み", "plugin": "プラグイン" }, + "count": { "skillOne": "{{count}} 件のスキル", "skillOther": "{{count}} 件のスキル", "sourceOne": "{{count}} 件のソース", "sourceOther": "{{count}} 件のソース", "fileOne": "{{count}} 個のファイル", "fileOther": "{{count}} 個のファイル", "resultOne": "{{count}} 件の結果", "resultOther": "{{count}} 件の結果", "selected": "{{count}} 件を選択", "shareOne": "{{count}} 件のスキルを共有", "shareOther": "{{count}} 件のスキルを共有", "linkOne": "{{count}} 件のリンク", "linkOther": "{{count}} 件のリンク" }, + "filter": { "allAgents": "すべてのエージェント", "sharedAgent": "共有 (.agents)" }, + "SkillsSelectionHeader": { "exit": "選択を終了", "exitTooltip": "選択を終了 · Esc", "title": "共有するスキルを選択", "selectAll": "対象の {{count}} 件をすべて選択", "clear": "クリア", "deleteTitle": "削除するスキルを選択" }, + "SkillDetailDialog": { "agents": "エージェント", "updated": "更新日", "copy": "コピー" }, "SkillFreshnessNudge": { "titleOne": "インストール済みの Orca スキルが古くなっています", "titleMany": "インストール済みの Orca スキル {{value0}} 件が古くなっています", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index bf5238cbc34..f154990358d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3877,6 +3877,19 @@ } }, "skills": { + "SkillsPage": { + "cb142070b4": "새로 고침", "a68dee6a32": "스킬 검색", "f43ad6edf3": "스킬", "ea72d6185b": "스킬을 검색하지 못했습니다", "dc4c3328ee": "파일 표시", "9963dff6d3": "설명을 찾을 수 없습니다.", "995fde8337": "스킬 파일을 표시하지 못했습니다", "4acd6d68ec": "스킬을 찾을 수 없습니다", "6a62a0168c": "일치하는 항목 없음", "08a321a984": "현재 검색 및 필터와 일치하는 스킬이 없습니다.", "cd7893fbc1": "스킬 검색 중", "35b9a724a0": "사용 가능", "c13b82793c": "설치 관리", "aee7b99cc6": "링크에서 설치", "filterProvider": "에이전트별 필터", "filterSource": "소스별 필터", "allSources": "전체", "clearFilters": "필터 지우기", "closeSkills": "스킬 닫기", "closeTooltip": "닫기 · Esc", "moreActions": "추가 작업", "sharedLinks": "공유 링크", "emptyCopy": "검색한 스킬 폴더가 비어 있습니다. 공유 번들을 설치하거나 스킬을 추가한 후 새로 고치세요.", "retry": "다시 시도", "remoteShareNotice": "이 스킬은 {{host}}에 있습니다. 공유하려면 해당 머신에서 스킬을 여세요.", "viewSwitch": "표시", "searchLinks": "링크 검색", "deleteSkills": "스킬 삭제…" + }, + "SkillShareSelectionControls": { "01c5a15e02": "스킬 공유" }, + "SkillRow": { "updatedUnknown": "날짜 없음", "pathCopied": "경로 복사됨", "copyPath": "경로 복사", "detailPath": "경로", "skillActions": "{{value0}} 작업", "viewDetails": "세부 정보 보기", "deleteSkill": "삭제…" }, + "SkillsList": { "listLabel": "스킬" }, + "sourceStatus": { "missing": "폴더를 찾을 수 없음", "remoteRepo": "원격 리포지토리 — 검색 안 됨", "unavailable": "검색 안 됨" }, + "sources": { "heading": "스킬 폴더" }, + "sourceKind": { "home": "홈", "workspace": "워크스페이스", "bundled": "번들", "plugin": "플러그인" }, + "count": { "skillOne": "스킬 {{count}}개", "skillOther": "스킬 {{count}}개", "sourceOne": "소스 {{count}}개", "sourceOther": "소스 {{count}}개", "fileOne": "파일 {{count}}개", "fileOther": "파일 {{count}}개", "resultOne": "결과 {{count}}개", "resultOther": "결과 {{count}}개", "selected": "{{count}}개 선택됨", "shareOne": "스킬 {{count}}개 공유", "shareOther": "스킬 {{count}}개 공유", "linkOne": "링크 {{count}}개", "linkOther": "링크 {{count}}개" }, + "filter": { "allAgents": "모든 에이전트", "sharedAgent": "공유됨 (.agents)" }, + "SkillsSelectionHeader": { "exit": "선택 나가기", "exitTooltip": "선택 나가기 · Esc", "title": "공유할 스킬 선택", "selectAll": "가능한 {{count}}개 모두 선택", "clear": "지우기", "deleteTitle": "삭제할 스킬 선택" }, + "SkillDetailDialog": { "agents": "에이전트", "updated": "업데이트됨", "copy": "복사" }, "SkillFreshnessNudge": { "titleOne": "설치된 Orca 스킬이 오래되었습니다", "titleMany": "설치된 Orca 스킬 {{value0}}개가 오래되었습니다", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1f41bf675dc..f68271bf57f 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3887,6 +3887,19 @@ } }, "skills": { + "SkillsPage": { + "cb142070b4": "刷新", "a68dee6a32": "搜索技能", "f43ad6edf3": "技能", "ea72d6185b": "无法扫描技能", "dc4c3328ee": "显示文件", "9963dff6d3": "未找到说明。", "995fde8337": "无法显示技能文件", "4acd6d68ec": "未找到技能", "6a62a0168c": "无匹配项", "08a321a984": "没有技能匹配当前搜索和筛选条件。", "cd7893fbc1": "正在扫描技能", "35b9a724a0": "可用", "c13b82793c": "管理安装", "aee7b99cc6": "从链接安装", "filterProvider": "按 Agent 筛选", "filterSource": "按来源筛选", "allSources": "全部", "clearFilters": "清除筛选", "closeSkills": "关闭技能", "closeTooltip": "关闭 · Esc", "moreActions": "更多操作", "sharedLinks": "共享链接", "emptyCopy": "扫描的技能文件夹为空。请安装共享包,或添加技能后刷新。", "retry": "重试", "remoteShareNotice": "这些技能位于 {{host}}。请在该机器上打开“技能”以进行共享。", "viewSwitch": "显示", "searchLinks": "搜索链接", "deleteSkills": "删除技能…" + }, + "SkillShareSelectionControls": { "01c5a15e02": "共享技能" }, + "SkillRow": { "updatedUnknown": "无日期", "pathCopied": "路径已复制", "copyPath": "复制路径", "detailPath": "路径", "skillActions": "{{value0}} 的操作", "viewDetails": "查看详情", "deleteSkill": "删除…" }, + "SkillsList": { "listLabel": "技能" }, + "sourceStatus": { "missing": "未找到文件夹", "remoteRepo": "远程仓库 — 未扫描", "unavailable": "未扫描" }, + "sources": { "heading": "技能文件夹" }, + "sourceKind": { "home": "主目录", "workspace": "工作区", "bundled": "内置", "plugin": "插件" }, + "count": { "skillOne": "{{count}} 个技能", "skillOther": "{{count}} 个技能", "sourceOne": "{{count}} 个来源", "sourceOther": "{{count}} 个来源", "fileOne": "{{count}} 个文件", "fileOther": "{{count}} 个文件", "resultOne": "{{count}} 个结果", "resultOther": "{{count}} 个结果", "selected": "已选择 {{count}} 个", "shareOne": "共享 {{count}} 个技能", "shareOther": "共享 {{count}} 个技能", "linkOne": "{{count}} 个链接", "linkOther": "{{count}} 个链接", "deleteOne": "删除 {{count}} 个技能", "deleteOther": "删除 {{count}} 个技能", "deletedOne": "已删除 {{count}} 个技能", "deletedOther": "已删除 {{count}} 个技能", "deleteFolderOne": "{{count}} 个文件夹", "deleteFolderOther": "{{count}} 个文件夹", "deleteLinkOne": "{{count}} 个链接", "deleteLinkOther": "{{count}} 个链接" }, + "filter": { "allAgents": "所有 Agent", "sharedAgent": "共享 (.agents)" }, + "SkillsSelectionHeader": { "exit": "退出选择", "exitTooltip": "退出选择 · Esc", "title": "选择要共享的技能", "selectAll": "选择全部 {{count}} 个符合条件的项目", "clear": "清除", "deleteTitle": "选择要删除的技能" }, + "SkillDetailDialog": { "agents": "Agent", "updated": "已更新", "copy": "复制" }, "SkillFreshnessNudge": { "titleOne": "已安装的 Orca 技能已过期", "titleMany": "{{value0}} 个已安装的 Orca 技能已过期", @@ -3984,16 +3997,6 @@ "oneLocation": "1 location", "manyLocations": "{{value0}} locations" }, - "count": { - "deleteOne": "删除 {{count}} 个技能", - "deleteOther": "删除 {{count}} 个技能", - "deletedOne": "已删除 {{count}} 个技能", - "deletedOther": "已删除 {{count}} 个技能", - "deleteFolderOne": "{{count}} 个文件夹", - "deleteFolderOther": "{{count}} 个文件夹", - "deleteLinkOne": "{{count}} 个链接", - "deleteLinkOther": "{{count}} 个链接" - }, "host": { "local": "此机器", "remote": "已连接的运行时" From 0e10fc59258aa3e07e31f33d1dc0024f42389647 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:09:22 -0700 Subject: [PATCH 019/152] fix(browser): retire helpers with page owners (#16564) --- config/reliability-gates.jsonc | 138 ++++++++++++- .../serve-signal-exit-diagnostic.test.ts | 102 +++++++++- src/cli/runtime/serve-update-supervisor.ts | 24 ++- ...t-browser-bridge-session-lifecycle.test.ts | 175 +++++++++++++++- src/main/browser/agent-browser-bridge.ts | 62 ++++-- src/main/browser/browser-backend.ts | 2 +- ...ffscreen-browser-backend-lifecycle.test.ts | 189 ++++++++++++++++++ src/main/browser/offscreen-browser-backend.ts | 57 +++++- src/main/index.ts | 29 ++- src/main/quit-teardown-deadline.ts | 6 +- .../startup/desktop-startup-ordering.test.ts | 34 ++++ .../startup/serve-signal-handlers.test.ts | 19 ++ src/main/startup/serve-signal-handlers.ts | 12 ++ .../window/main-window-close-lifecycle.ts | 3 +- src/shared/quit-teardown-deadline.ts | 3 + 15 files changed, 793 insertions(+), 62 deletions(-) create mode 100644 src/main/startup/serve-signal-handlers.test.ts create mode 100644 src/main/startup/serve-signal-handlers.ts create mode 100644 src/shared/quit-teardown-deadline.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 482f9808656..0826c5dea46 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -2094,15 +2094,16 @@ "providers": ["local", "daemon", "ssh"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "daemon", "ssh"], - "coverageNotes": "Deterministic unit coverage exercises activation gating, single-instance ownership, quit policy, local/remote CLI status, headless binding persistence, local daemon identity, SSH identity transfer, the promoted renderer's agent-resume accounting, and the macOS serve update handoff from staged installer through atomic bundle replacement and target-version readiness. A macOS Electron journey covers headless promotion and persistent PTY identity. A disposable locally signed Electron canary exercised real ShipIt and a temporary LaunchAgent with the compiled production supervisor; full packaged Orca and Linux/Windows serve updates remain uncollected.", + "coverageNotes": "Deterministic unit coverage exercises activation gating, single-instance ownership, quit policy, local/remote CLI status, headless binding persistence, local daemon identity, SSH identity transfer, the promoted renderer's agent-resume accounting, and the macOS serve update handoff from staged installer through atomic bundle replacement and target-version readiness. Supervisor settlement coverage prevents a late handoff-completion failure from rearming termination after the replacement child exits. A macOS Electron journey covers headless promotion and persistent PTY identity. A disposable locally signed Electron canary exercised real ShipIt and a temporary LaunchAgent with the compiled production supervisor; full packaged Orca and Linux/Windows serve updates remain uncollected.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/8457", "https://github.com/stablyai/orca/issues/9563" ], - "invariant": "A safely promotable headless serve process is the single app owner. Desktop activation preserves its daemon-backed sessions. On macOS, a CLI-supervised serve update keeps the node-mode parent alive across ShipIt's atomic bundle swap, restarts with the original serve arguments only after the target bundle is present, and clears handoff state only after that target version reports runtime readiness. Unsupported or failed handoffs leave the current serving owner intact or recover it once without an install retry loop.", - "oracle": "Unit tests coalesce early activation, preserve daemon and SSH identity, and reproduce the update race with a staged target, old serving child, persistent CLI parent, atomic .app replacement, and replacement readiness message. They assert the parent does not exit for launchd to respawn the old app, the native updater does not launch an interactive GUI, the replacement version is verified before handoff completion, mismatches become durable failures without retries, and unsupported/preflight-failed installs do not invoke native quit or PTY cleanup. A joined lock-owner/activation/hydration contract asserts that a forced relaunch opens exactly one window and that the renderer promoted inside the serve process launches zero agent resumes, creates no replacement tab or startup command, and leaves every surviving session record untouched. The Electron journey independently verifies headless promotion retains owner/runtime/daemon/PTY identity and terminal I/O.", + "invariant": "A safely promotable headless serve process is the single app owner. Desktop activation preserves its daemon-backed sessions. On macOS, a CLI-supervised serve update keeps the node-mode parent alive across ShipIt's atomic bundle swap, restarts with the original serve arguments only after the target bundle is present, and clears handoff state only after that target version reports runtime readiness. Once a supervised child exits or fails to start, no late handoff completion may signal it or arm force-kill escalation. Unsupported or failed handoffs leave the current serving owner intact or recover it once without an install retry loop.", + "oracle": "Unit tests coalesce early activation, preserve daemon and SSH identity, and reproduce the update race with a staged target, old serving child, persistent CLI parent, atomic .app replacement, and replacement readiness message. They assert the parent does not exit for launchd to respawn the old app, the native updater does not launch an interactive GUI, the replacement version is verified before handoff completion, mismatches become durable failures without retries, and unsupported/preflight-failed installs do not invoke native quit or PTY cleanup. Force handoff completion to fail after the replacement child exits and require zero later child signals and zero escalation timers. A joined lock-owner/activation/hydration contract asserts that a forced relaunch opens exactly one window and that the renderer promoted inside the serve process launches zero agent resumes, creates no replacement tab or startup command, and leaves every surviving session record untouched. The Electron journey independently verifies headless promotion retains owner/runtime/daemon/PTY identity and terminal I/O.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/cli/runtime/launch.test.ts src/main/serve-update-handoff.test.ts src/main/updater.headless-serve-install.test.ts src/main/updater.test.ts src/main/updater.mac-install.test.ts src/main/window/attach-main-window-services.test.ts src/main/startup/serve-desktop-activation-wiring.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/cli/runtime/serve-signal-exit-diagnostic.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/startup/serve-desktop-activation.test.ts src/main/startup/serve-desktop-activation-wiring.test.ts src/main/startup/single-instance-lock.test.ts src/main/startup/window-all-closed-quit-policy.test.ts src/cli/runtime-client.test.ts src/cli/runtime/websocket-transport.test.ts src/main/runtime/orca-runtime.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/serve-desktop-promotion-session-continuity.test.ts", "pnpm exec electron-vite build --mode e2e", @@ -2112,6 +2113,7 @@ "src/main/updater.headless-serve-install.test.ts", "src/main/serve-update-handoff.test.ts", "src/cli/runtime/launch.test.ts", + "src/cli/runtime/serve-signal-exit-diagnostic.test.ts", "src/main/startup/serve-desktop-activation.test.ts", "src/main/startup/serve-desktop-activation-wiring.test.ts", "src/main/startup/single-instance-lock.test.ts", @@ -2144,6 +2146,12 @@ "a replacement version mismatch or readiness timeout is persisted and exits without an in-process retry loop" ] }, + { + "file": "src/cli/runtime/serve-signal-exit-diagnostic.test.ts", + "assertions": [ + "late update handoff failure cannot rearm termination after child exit" + ] + }, { "file": "src/main/serve-update-handoff.test.ts", "assertions": [ @@ -16222,6 +16230,130 @@ ], "demotionRule": "Demote if a live parked PTY loses its exact graph leaf, a retired PTY remains published, multi-pane identity drifts, or the routed client round trip flakes without a diagnosed cause." }, + { + "id": "agent-browser.owner-boundary-cleanup", + "title": "Headless browser helpers retire with their owning page and runtime", + "maturity": "experimental", + "protection": "partial", + "owner": "browser-runtime", + "layer": "electron-main-headless-browser-lifecycle", + "surfaces": [ + "headless offscreen browser page close", + "offscreen renderer destruction", + "app quit", + "headless serve SIGINT/SIGTERM" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "remote-runtime", "ssh", "wsl"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local"], + "coverageNotes": "The owner-boundary contract injects an isolated BrowserManager, offscreen WebContents, and AgentBrowserBridge. It proves explicit close removes the page from command routing before awaiting exact named-session retirement, runs retirement on unexpected renderer destruction, preserves unrelated pages, joins pending creation/retirement during shutdown, rejects command admission after terminal cleanup begins, retries Electron quit after a vetoed signal, preserves Windows shared-console graceful Ctrl-C, reserves the full renderer-acknowledgement and committed-teardown budgets before supervisor force-kill, bounds process-swap retirement to five seconds, and caps cleanup concurrency at four. A built-CLI macOS headless serve run used an isolated profile, folder workspace, socket directory, and port: exact PPID-1 helper/session identity disappeared within five seconds after page close and after Ctrl-C runtime shutdown, while an unrelated page survived close and reconnect. Linux cgroup and paired/SSH journeys remain gaps.", + "motivatingLinks": [ + "https://linear.app/stably/issue/STA-5400", + "https://github.com/stablyai/orca/issues/16367" + ], + "invariant": "For each Orca-owned page identity, after its owner closes or is destroyed and a five-second third-party shutdown grace expires, no live helper session named orca-tab- may remain; unrelated live page identities must survive, and runtime quit must join the bounded cleanup attempt. The serve supervisor may force-kill only after the renderer-acknowledgement deadline, committed teardown deadline, and bounded scheduling margin have all elapsed.", + "oracle": "Create two isolated offscreen pages and register their WebContents. Close one, block onPageClosed for its stable page ID, and require unregisterGuest to have already removed that page from command routing while the unrelated page remains live. Emit destroyed and require the same exact retirement. Race shutdown with creation, pending retirement, and process-swap destruction; require no replacement session or command after terminal cleanup starts, require shutdown to remain pending until retirement settles, require the swap close command to use the five-second cleanup timeout, and require at most four concurrent close commands. Deliver repeated signals and require every attempt to reach Electron quit; under Windows shared-console semantics require the child to handle Ctrl-C without an immediate child.kill. Advance the supervisor clock through the renderer-acknowledgement and committed teardown deadlines and require no SIGKILL until the bounded scheduling margin elapses. In direct built-CLI serve, inspect exact session/PID/socket identity plus RSS/fd inventory before close and after a five-second grace; reconnect between commands and stop via Ctrl-C. No process-name kill or global sweep is permitted.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/cli/runtime/serve-signal-exit-diagnostic.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/agent-browser-bridge-session-lifecycle.test.ts src/main/browser/agent-browser-bridge-tab-routing.test.ts src/main/startup/serve-signal-handlers.test.ts src/main/startup/desktop-startup-ordering.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/browser" + ], + "testFiles": [ + "src/main/browser/offscreen-browser-backend-lifecycle.test.ts", + "src/main/browser/agent-browser-bridge-session-lifecycle.test.ts", + "src/main/browser/agent-browser-bridge-tab-routing.test.ts", + "src/main/startup/serve-signal-handlers.test.ts", + "src/main/startup/desktop-startup-ordering.test.ts", + "src/cli/runtime/serve-signal-exit-diagnostic.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/browser/offscreen-browser-backend-lifecycle.test.ts", + "assertions": [ + "explicit close unregisters the page before awaiting exact owner cleanup while unrelated and same-ID replacement pages survive", + "unexpected offscreen renderer destruction invokes and joins exact helper-owner cleanup", + "backend shutdown joins every pending owner cleanup with concurrency capped at four" + ] + }, + { + "file": "src/main/browser/agent-browser-bridge-session-lifecycle.test.ts", + "assertions": [ + "owner cleanup uses a five-second close-command timeout", + "process-swap retirement uses the same five-second cleanup timeout", + "runtime shutdown includes sessions whose creation is still pending", + "runtime shutdown rejects late command and replacement-session admission", + "runtime-wide helper cleanup concurrency is capped at four" + ] + }, + { + "file": "src/main/browser/agent-browser-bridge-tab-routing.test.ts", + "assertions": [ + "closing a tab retires the exact named agent-browser session" + ] + }, + { + "file": "src/main/startup/serve-signal-handlers.test.ts", + "assertions": [ + "every repeated serve signal retries Electron quit after a possible renderer veto", + "SIGINT and SIGTERM listeners remain installed during quit draining" + ] + }, + { + "file": "src/main/startup/desktop-startup-ordering.test.ts", + "assertions": [ + "agent-browser cleanup is joined by the committed quit teardown barrier", + "repeatable serve signal handling is registered before readiness" + ] + }, + { + "file": "src/cli/runtime/serve-signal-exit-diagnostic.test.ts", + "assertions": [ + "serve supervisor force-kill grace covers renderer acknowledgement, committed teardown, and scheduling margin", + "Windows shared-console Ctrl-C is not forwarded as an immediate child termination" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "result": "passed", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/cli/runtime/serve-signal-exit-diagnostic.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/agent-browser-bridge-session-lifecycle.test.ts src/main/browser/agent-browser-bridge-tab-routing.test.ts src/main/startup/serve-signal-handlers.test.ts src/main/startup/desktop-startup-ordering.test.ts", + "durationSeconds": 0.44, + "summary": "Candidate passed 63 lifecycle, bridge, routing, signal, and quit-order tests plus 1,469 browser tests. The production-reverted control was red; disabling the final swap-timeout/admission controls failed 3 of 15 bridge lifecycle tests; removing the pending-retirement join, page-routing fence, repeat-signal behavior, or Windows shared-console guard failed its exact focused oracle. Setting the supervisor scheduling margin to zero reproduced SIGKILL at the combined 30-second renderer-acknowledgement and teardown boundary. Direct built-CLI serve observed exact PPID-1 helpers at 10-11 MiB RSS and 16-18 fd rows. Closing one page removed only its helper/session within five seconds while the other page survived reconnect. With the signal fix disabled, Ctrl-C left exact helpers alive after the listener exited; the final candidate emptied session inventory and removed app/helper PIDs and port within five seconds." + } + ], + "runtimeBudget": { + "p95Seconds": 2, + "scope": "bounded offscreen lifecycle contracts and bridge cleanup ordering" + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Fresh deterministic coverage has local candidate evidence only." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The pristine current-main control failed explicit close, unexpected destruction, backend shutdown, pending creation, bounded concurrency, joined quit, and repeated-signal assertions; the candidate passed the byte-identical oracle. Disabling only process-swap cleanup timeout and terminal command admission failed 3 of 15 bridge lifecycle tests. Live candidate-with-signal-fix-disabled also retained exact helpers after Ctrl-C, while the final candidate removed them." + }, + "performanceBudget": { + "required": true, + "evidence": "Cleanup is event-driven and exact-page scoped: no polling, process-name scan, idle timer, or new wire field. Owner and residual session drains cap close subprocess concurrency at four, sequence headless ownership cleanup before the residual bridge sweep, and reuse the existing bounded app teardown deadline." + }, + "promotionCriteria": [ + "Run the same oracle on Linux headless serve with bundled agent-browser PID/session and RSS/fd inventory.", + "Exercise runtime restart, transport loss, reconnect, concurrent tabs, and paired/SSH host ownership without cross-page cleanup.", + "Collect repeated open/close/reconnect evidence showing bounded helper count and no stale PID-1-owned Orca identities." + ], + "knownGaps": [ + "No 64 GiB Linux cgroup stress was manufactured; the reported production incident remains un-soaked.", + "The real Electron headless serve oracle ran on macOS; physical Linux/SSH/WSL and paired-client journeys remain unvalidated.", + "A close-command timeout or error is treated as best-effort and does not escalate to a process kill; helper disappearance on that failure path remains unverifiable without exact PID ownership.", + "Four-way close batches remain bounded but can exceed the 20-second app quit barrier above 16 worst-case five-second retirements.", + "The patch does not add startup stale-process recovery or an idle timeout; those require an exact persisted ownership ledger and are intentionally out of scope." + ], + "demotionRule": "Demote if a closed or destroyed page leaves its exact named helper session, if quit can exit before helper retirement settles, if unrelated live pages are retired, or if cleanup regresses to a global process-name kill or timer-only sweep." + }, { "id": "terminal-session.remote-pane-layout-retry", "title": "Remote pane layouts retry identical state after reconnect", diff --git a/src/cli/runtime/serve-signal-exit-diagnostic.test.ts b/src/cli/runtime/serve-signal-exit-diagnostic.test.ts index 2379bb58854..f5d348798c3 100644 --- a/src/cli/runtime/serve-signal-exit-diagnostic.test.ts +++ b/src/cli/runtime/serve-signal-exit-diagnostic.test.ts @@ -1,8 +1,19 @@ import { EventEmitter } from 'node:events' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { serveSignalExitError } from './serve-signal-exit-diagnostic' -import { superviseForegroundServe } from './serve-update-supervisor' +import { + SERVE_CHILD_FORCE_KILL_GRACE_MS, + SERVE_CHILD_FORCE_KILL_SCHEDULING_MARGIN_MS, + superviseForegroundServe +} from './serve-update-supervisor' import { RuntimeClientError } from './types' +import { + QUIT_RENDERER_ACK_TIMEOUT_MS, + WILL_QUIT_TEARDOWN_DEADLINE_MS +} from '../../shared/quit-teardown-deadline' class FakeChildProcess extends EventEmitter { kill = vi.fn() @@ -17,7 +28,13 @@ function setPlatform(platform: NodeJS.Platform): void { function superviseUntilExit(code: number | null, signal: NodeJS.Signals | null): Promise { const child = new FakeChildProcess() - const supervised = superviseForegroundServe({ + const supervised = superviseChild(child) + child.emit('exit', code, signal) + return supervised +} + +function superviseChild(child: FakeChildProcess): Promise { + return superviseForegroundServe({ executable: '/Applications/Orca.app/Contents/MacOS/Orca', childArgs: ['--serve'], spawnOptions: {}, @@ -26,12 +43,12 @@ function superviseUntilExit(code: number | null, signal: NodeJS.Signals | null): child: child as never, expectedHandoff: null }) - child.emit('exit', code, signal) - return supervised } afterEach(() => { Object.defineProperty(process, 'platform', originalPlatform) + vi.restoreAllMocks() + vi.useRealTimers() }) describe('serveSignalExitError', () => { @@ -74,6 +91,83 @@ describe('serveSignalExitError', () => { }) describe('superviseForegroundServe signal exits', () => { + it('lets pre-commit and committed Electron quit deadlines finish before force-killing serve', async () => { + setPlatform('linux') + vi.useFakeTimers() + const child = new FakeChildProcess() + const supervised = superviseChild(child) + + expect(SERVE_CHILD_FORCE_KILL_GRACE_MS).toBe( + QUIT_RENDERER_ACK_TIMEOUT_MS + + WILL_QUIT_TEARDOWN_DEADLINE_MS + + SERVE_CHILD_FORCE_KILL_SCHEDULING_MARGIN_MS + ) + expect(SERVE_CHILD_FORCE_KILL_GRACE_MS).toBeLessThanOrEqual(35_000) + + process.emit('SIGTERM', 'SIGTERM') + expect(child.kill).toHaveBeenCalledOnce() + expect(child.kill).toHaveBeenLastCalledWith('SIGTERM') + + await vi.advanceTimersByTimeAsync(QUIT_RENDERER_ACK_TIMEOUT_MS + WILL_QUIT_TEARDOWN_DEADLINE_MS) + expect(child.kill).toHaveBeenCalledOnce() + + await vi.advanceTimersByTimeAsync(SERVE_CHILD_FORCE_KILL_SCHEDULING_MARGIN_MS) + expect(child.kill).toHaveBeenLastCalledWith('SIGKILL') + expect(child.kill).toHaveBeenCalledTimes(2) + + child.emit('exit', null, 'SIGKILL') + await expect(supervised).rejects.toThrow('Orca serve exited via SIGKILL.') + }) + + it('lets a shared-console Windows child handle Ctrl-C gracefully', async () => { + setPlatform('win32') + vi.useFakeTimers() + const child = new FakeChildProcess() + const supervised = superviseChild(child) + + process.emit('SIGINT', 'SIGINT') + expect(child.kill).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(1) + + child.emit('exit', 0, null) + await expect(supervised).resolves.toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('does not terminate an exited child when update handoff completion fails late', async () => { + vi.useFakeTimers() + const missingParent = await mkdtemp(join(tmpdir(), 'orca-serve-missing-handoff-')) + await rm(missingParent, { recursive: true }) + const child = new FakeChildProcess() + const supervised = superviseForegroundServe({ + executable: '/Applications/Orca.app/Contents/MacOS/Orca', + childArgs: ['--serve'], + spawnOptions: {}, + spawnChild: vi.fn() as never, + handoffPath: join(missingParent, 'handoff.json'), + child: child as never, + expectedHandoff: { + schemaVersion: 1, + phase: 'install-requested', + fromVersion: '1.0.51', + targetVersion: '1.0.61', + servingPid: child.pid + } + }) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + child.emit('message', { + type: 'orca:serve-ready', + version: '1.0.61', + runtimeId: 'runtime-new' + }) + child.emit('exit', 0, null) + + await expect(supervised).resolves.toBe(1) + expect(child.kill).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + it('throws the macOS diagnostic when the child aborts on darwin', async () => { setPlatform('darwin') diff --git a/src/cli/runtime/serve-update-supervisor.ts b/src/cli/runtime/serve-update-supervisor.ts index d179a4354ff..a5a303dc1a2 100644 --- a/src/cli/runtime/serve-update-supervisor.ts +++ b/src/cli/runtime/serve-update-supervisor.ts @@ -6,10 +6,19 @@ import { parseServeUpdateHandoffState, type ServeUpdateHandoffState } from '../../shared/serve-update-handoff' +import { + QUIT_RENDERER_ACK_TIMEOUT_MS, + WILL_QUIT_TEARDOWN_DEADLINE_MS +} from '../../shared/quit-teardown-deadline' import { serveSignalExitError } from './serve-signal-exit-diagnostic' import { waitForMacBundleVersion } from './mac-app-update-bundle' export const SERVE_REPLACEMENT_READY_TIMEOUT_MS = 60_000 +export const SERVE_CHILD_FORCE_KILL_SCHEDULING_MARGIN_MS = 5_000 +export const SERVE_CHILD_FORCE_KILL_GRACE_MS = + QUIT_RENDERER_ACK_TIMEOUT_MS + + WILL_QUIT_TEARDOWN_DEADLINE_MS + + SERVE_CHILD_FORCE_KILL_SCHEDULING_MARGIN_MS type InstallRequestedHandoff = Extract type ServeReadiness = 'not-expected' | 'pending' | 'verified' | 'failed' @@ -111,9 +120,13 @@ function waitForForegroundChild( let readyTimer: ReturnType | null = null let readiness: ServeReadiness = expected ? 'pending' : 'not-expected' let stateWrite = Promise.resolve() + let childSettled = false const terminateChild = (): void => { + if (childSettled) { + return + } child.kill('SIGTERM') - forceKillTimer ??= setTimeout(() => child.kill('SIGKILL'), 5000) + forceKillTimer ??= setTimeout(() => child.kill('SIGKILL'), SERVE_CHILD_FORCE_KILL_GRACE_MS) } const recordReplacementFailure = (reason: string): boolean => { if (!expected || readiness !== 'pending') { @@ -140,8 +153,11 @@ function waitForForegroundChild( terminateChild() } const forwardSignal = (signal: NodeJS.Signals): void => { - child.kill(signal) - forceKillTimer ??= setTimeout(() => child.kill('SIGKILL'), 5000) + // A Windows console delivers Ctrl-C to parent and child; child.kill would terminate the child mid-teardown. + if (process.platform !== 'win32') { + child.kill(signal) + } + forceKillTimer ??= setTimeout(() => child.kill('SIGKILL'), SERVE_CHILD_FORCE_KILL_GRACE_MS) } const handleMessage = (value: unknown): void => { const message = parseServeSupervisorMessage(value) @@ -195,10 +211,12 @@ function waitForForegroundChild( }, SERVE_REPLACEMENT_READY_TIMEOUT_MS) } const handleExit = (code: number | null, signal: NodeJS.Signals | null): void => { + childSettled = true cleanup() void stateWrite.then(() => resolveWait({ code, signal, readiness })) } child.once('error', (error) => { + childSettled = true recordReplacementFailure(`Could not start the replacement process: ${String(error)}`) cleanup() child.off('exit', handleExit) diff --git a/src/main/browser/agent-browser-bridge-session-lifecycle.test.ts b/src/main/browser/agent-browser-bridge-session-lifecycle.test.ts index e24977be614..4ef3d24fa25 100644 --- a/src/main/browser/agent-browser-bridge-session-lifecycle.test.ts +++ b/src/main/browser/agent-browser-bridge-session-lifecycle.test.ts @@ -140,6 +140,43 @@ describe('AgentBrowserBridge', () => { expect(lastArgs).toContain('--cdp') }) + it('bounds owner cleanup independently from command execution timeouts', async () => { + succeedWith({ snapshot: 'initial' }) + await bridge.snapshot() + execFileMock.mockClear() + + succeedWith(null) + await bridge.onPageClosed('tab-1') + + const closeCall = execFileMock.mock.calls.find((call: unknown[]) => + (call[1] as string[]).includes('close') + ) + expect(closeCall?.[2]).toMatchObject({ timeout: 5_000 }) + }) + + it('uses the cleanup timeout when a target swap retires its session', async () => { + succeedWith({ snapshot: 'initial' }) + await bridge.snapshot() + execFileMock.mockClear() + + succeedWith(null) + await ( + bridge as unknown as { + restartSessionForTarget: ( + sessionName: string, + browserPageId: string, + webContentsId: number, + options: { recreate: boolean } + ) => Promise + } + ).restartSessionForTarget('orca-tab-tab-1', 'tab-1', 100, { recreate: false }) + + const closeCall = execFileMock.mock.calls.find((call: unknown[]) => + (call[1] as string[]).includes('close') + ) + expect(closeCall?.[2]).toMatchObject({ timeout: 5_000 }) + }) + it('waits for pending session destruction before recreating the same session', async () => { succeedWith({ snapshot: 'initial' }) await bridge.snapshot() @@ -418,22 +455,140 @@ describe('AgentBrowserBridge', () => { // ── destroyAllSessions ── - it('destroys all active sessions', async () => { + it('makes runtime-wide session destruction terminal', async () => { succeedWith({ snapshot: 'tree' }) await bridge.snapshot() - // Should have one session now - succeedWith(null) // for the 'close' call + succeedWith(null) await bridge.destroyAllSessions() + execFileMock.mockClear() - // Next command should re-create session with --cdp - succeedWith({ snapshot: 'fresh' }) - await bridge.snapshot() + await expect(bridge.snapshot()).rejects.toMatchObject({ + code: 'browser_owner_unavailable', + message: 'Browser runtime is shutting down' + }) + expect(execFileMock).not.toHaveBeenCalled() + }) - const snapshotCalls = execFileMock.mock.calls.filter((c: unknown[]) => - (c[1] as string[]).includes('snapshot') + it('bounds concurrent helper retirements during runtime shutdown', async () => { + const sessions = (bridge as unknown as { sessions: Map }).sessions + for (let index = 0; index < 6; index++) { + sessions.set(`orca-tab-tab-${index}`, { + proxy: { stop: vi.fn(async () => {}) }, + cdpEndpoint: `ws://127.0.0.1:${9200 + index}`, + initialized: true, + consecutiveTimeouts: 0, + activeInterceptPatterns: [], + activeCapture: false, + webContentsId: 100 + index, + activeProcess: null + }) + } + + let activeRetirements = 0 + let peakRetirements = 0 + const releases: (() => void)[] = [] + execFileMock.mockImplementation( + (_bin: string, _args: string[], _opts: unknown, cb: ExecFileCallback) => { + activeRetirements++ + peakRetirements = Math.max(peakRetirements, activeRetirements) + releases.push(() => { + activeRetirements-- + cb(null, JSON.stringify({ success: true, data: null }), '') + }) + return { kill: vi.fn() } + } ) - const lastSnapshotArgs = snapshotCalls.at(-1)![1] as string[] - expect(lastSnapshotArgs).toContain('--cdp') + + const shutdown = bridge.destroyAllSessions() + await vi.waitFor(() => expect(execFileMock).toHaveBeenCalledTimes(4)) + releases.splice(0).forEach((release) => release()) + await vi.waitFor(() => expect(execFileMock).toHaveBeenCalledTimes(6)) + releases.splice(0).forEach((release) => release()) + await shutdown + + expect(peakRetirements).toBe(4) + }) + + it('destroys a session that finishes creating during runtime shutdown', async () => { + const commandCalls: string[][] = [] + let releaseStaleClose: (() => void) | null = null + execFileMock.mockImplementation( + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { + commandCalls.push(args) + if (args.includes('close') && !releaseStaleClose) { + releaseStaleClose = () => { + cb(null, JSON.stringify({ success: true, data: null }), '') + } + return { kill: vi.fn() } + } + cb(null, JSON.stringify({ success: true, data: null }), '') + return { kill: vi.fn() } + } + ) + + const ensurePromise = ( + bridge as unknown as { + ensureSession: ( + sessionName: string, + browserPageId: string, + webContentsId: number + ) => Promise + } + ).ensureSession('orca-tab-tab-1', 'tab-1', 100) + await vi.waitFor(() => expect(releaseStaleClose).not.toBeNull()) + + const destroyAllPromise = bridge.destroyAllSessions() + releaseStaleClose!() + await ensurePromise + await destroyAllPromise + + const sessions = (bridge as unknown as { sessions: Map }).sessions + const proxy = CdpWsProxyMock.instances[0] as { stop: ReturnType } + expect(commandCalls.filter((args) => args.includes('close'))).toHaveLength(2) + expect(sessions.size).toBe(0) + expect(proxy.stop).toHaveBeenCalledTimes(1) + }) + + it('does not recreate a session after shutdown observes its pending retirement', async () => { + succeedWith({ snapshot: 'initial' }) + await bridge.snapshot() + execFileMock.mockClear() + + let releaseClose: (() => void) | null = null + execFileMock.mockImplementation( + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { + if (!args.includes('close')) { + throw new Error(`unexpected agent-browser args ${args.join(' ')}`) + } + releaseClose = () => cb(null, JSON.stringify({ success: true, data: null }), '') + return { kill: vi.fn() } + } + ) + + const restart = ( + bridge as unknown as { + restartSessionForTarget: ( + sessionName: string, + browserPageId: string, + webContentsId: number + ) => Promise + } + ).restartSessionForTarget('orca-tab-tab-1', 'tab-1', 100) + await vi.waitFor(() => expect(releaseClose).not.toBeNull()) + + const shutdown = bridge.destroyAllSessions() + releaseClose!() + + await expect(restart).rejects.toMatchObject({ + code: 'browser_owner_unavailable', + message: 'Browser runtime is shutting down' + }) + await shutdown + + const sessions = (bridge as unknown as { sessions: Map }).sessions + expect(sessions.size).toBe(0) + expect(CdpWsProxyMock.instances).toHaveLength(1) + expect(execFileMock).toHaveBeenCalledTimes(1) }) }) diff --git a/src/main/browser/agent-browser-bridge.ts b/src/main/browser/agent-browser-bridge.ts index f7eb19b5adf..10a64dc16ce 100644 --- a/src/main/browser/agent-browser-bridge.ts +++ b/src/main/browser/agent-browser-bridge.ts @@ -49,6 +49,7 @@ import type { } from '../../shared/runtime-types' import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text' import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' +import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency' import { iterateBrowserTextInsertionChunks } from './browser-text-insertion' import { createAgentBrowserProcessEnvironment } from './agent-browser-process-environment' @@ -57,6 +58,8 @@ const EXEC_TIMEOUT_MS = 90_000 const CONSECUTIVE_TIMEOUT_LIMIT = 3 const WAIT_PROCESS_TIMEOUT_GRACE_MS = 1_000 const STALE_SESSION_CLOSE_TIMEOUT_MS = 3_000 +const AGENT_BROWSER_CLEANUP_TIMEOUT_MS = 5_000 +const AGENT_BROWSER_CLEANUP_CONCURRENCY = 4 const EMBEDDED_NAVIGATION_TIMEOUT_MS = 30_000 export const AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES = 8 * 1024 export const AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES = AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES @@ -85,6 +88,10 @@ type ResolvedBrowserCommandTarget = { webContentsId: number } +type AgentBrowserCleanupOptions = { + closeTimeoutMs?: number +} + export type BrowserMouseModifier = 'cmd' | 'ctrl' | 'alt' | 'shift' function focusedValueSetExpression( @@ -587,6 +594,7 @@ export class AgentBrowserBridge { // Why: `agent-browser close` is async, keyed by session name — recreating before it finishes lets the old teardown close the new session. private readonly pendingSessionDestruction = new Map>() private readonly cancelledProcesses = new WeakSet() + private shutdownStarted = false constructor( private readonly browserManager: BrowserManager, @@ -678,13 +686,18 @@ export class AgentBrowserBridge { this.activeWebContentsId = nextWorktreeActiveWebContentsId } if (browserPageId) { - const sessionName = `orca-tab-${browserPageId}` - await this.destroySession(sessionName) - this.pendingInterceptRestore.delete(sessionName) + await this.onPageClosed(browserPageId) } this.options.onTabsChanged?.(owningWorktreeId) } + /** Retire a helper by its stable page identity when WebContents mapping is gone. */ + async onPageClosed(browserPageId: string): Promise { + const sessionName = `orca-tab-${browserPageId}` + await this.destroySession(sessionName) + this.pendingInterceptRestore.delete(sessionName) + } + async onProcessSwap( browserPageId: string, newWebContentsId: number, @@ -2044,12 +2057,18 @@ export class AgentBrowserBridge { // ── Session lifecycle ── - async destroyAllSessions(): Promise { - const promises: Promise[] = [] - for (const sessionName of this.sessions.keys()) { - promises.push(this.destroySession(sessionName)) - } - await Promise.allSettled(promises) + async destroyAllSessions(options?: AgentBrowserCleanupOptions): Promise { + this.shutdownStarted = true + const sessionNames = new Set([ + ...this.sessions.keys(), + ...this.pendingSessionCreation.keys(), + ...this.pendingSessionDestruction.keys() + ]) + await mapSettledWithConcurrency( + [...sessionNames], + AGENT_BROWSER_CLEANUP_CONCURRENCY, + (sessionName) => this.destroySession(sessionName, options) + ) this.pendingInterceptRestore.clear() } @@ -2073,12 +2092,14 @@ export class AgentBrowserBridge { execute: (sessionName: string, target: ResolvedBrowserCommandTarget) => Promise, options: EnqueueTargetedCommandOptions = {} ): Promise { + this.assertCommandAdmission() const target = this.resolveCommandTarget(worktreeId, browserPageId, options.requireScopedTarget) const sessionName = `orca-tab-${target.browserPageId}` if (options.ensureSession !== false) { await this.ensureSession(sessionName, target.browserPageId, target.webContentsId) } + this.assertCommandAdmission() return new Promise((resolve, reject) => { let queue = this.commandQueues.get(sessionName) @@ -2302,6 +2323,7 @@ export class AgentBrowserBridge { if (pendingDestruction) { await pendingDestruction } + this.assertCommandAdmission() if (this.sessions.has(sessionName)) { return @@ -2311,6 +2333,7 @@ export class AgentBrowserBridge { const pending = this.pendingSessionCreation.get(sessionName) if (pending) { await pending + this.assertCommandAdmission() return } @@ -2381,7 +2404,9 @@ export class AgentBrowserBridge { const destroy = (async (): Promise => { try { - await this.runAgentBrowserRaw(sessionName, ['--session', sessionName, 'close']) + await this.runAgentBrowserRaw(sessionName, ['--session', sessionName, 'close'], { + timeoutMs: AGENT_BROWSER_CLEANUP_TIMEOUT_MS + }) } catch { // Session may already be dead. } @@ -2400,7 +2425,10 @@ export class AgentBrowserBridge { } } - private async destroySession(sessionName: string): Promise { + private async destroySession( + sessionName: string, + options: AgentBrowserCleanupOptions = { closeTimeoutMs: AGENT_BROWSER_CLEANUP_TIMEOUT_MS } + ): Promise { const pendingDestruction = this.pendingSessionDestruction.get(sessionName) if (pendingDestruction) { await pendingDestruction @@ -2443,7 +2471,11 @@ export class AgentBrowserBridge { const destroy = (async (): Promise => { try { // Why: each tab has its own named session — close without --session leaves this tab's daemon running. - await this.runAgentBrowserRaw(sessionName, ['--session', sessionName, 'close']) + await this.runAgentBrowserRaw( + sessionName, + ['--session', sessionName, 'close'], + options.closeTimeoutMs === undefined ? undefined : { timeoutMs: options.closeTimeoutMs } + ) } catch { // Session may already be dead } @@ -2474,6 +2506,12 @@ export class AgentBrowserBridge { } } + private assertCommandAdmission(): void { + if (this.shutdownStarted) { + throw new BrowserError('browser_owner_unavailable', 'Browser runtime is shutting down') + } + } + private async execAgentBrowser( sessionName: string, commandArgs: string[], diff --git a/src/main/browser/browser-backend.ts b/src/main/browser/browser-backend.ts index 939684e49b3..0b3999777d1 100644 --- a/src/main/browser/browser-backend.ts +++ b/src/main/browser/browser-backend.ts @@ -20,5 +20,5 @@ export type BrowserBackend = { closeTab(browserPageId: string): Promise /** Tear down every page this backend owns (process shutdown). Optional — * renderer-hosted backends are torn down with their window. */ - destroyAll?(): void + destroyAll?(): void | Promise } diff --git a/src/main/browser/offscreen-browser-backend-lifecycle.test.ts b/src/main/browser/offscreen-browser-backend-lifecycle.test.ts index 2d6631ea7a7..52a555c9f5f 100644 --- a/src/main/browser/offscreen-browser-backend-lifecycle.test.ts +++ b/src/main/browser/offscreen-browser-backend-lifecycle.test.ts @@ -93,4 +93,193 @@ describe('OffscreenBrowserBackend lifecycle', () => { expect(vi.getTimerCount()).toBe(0) vi.useRealTimers() }) + + it('unregisters a closing page before awaiting owner retirement', async () => { + const browserManager = { + registerOffscreenGuest: vi.fn(), + unregisterGuest: vi.fn() + } + let releaseOwnerRetirement!: () => void + const ownerRetirementBlocked = new Promise((resolve) => { + releaseOwnerRetirement = resolve + }) + const onPageClosed = vi.fn(() => ownerRetirementBlocked) + const backend = new OffscreenBrowserBackend(browserManager as never, { + getAgentBrowserBridge: () => ({ onPageClosed }) + }) + + await backend.createTab({ browserPageId: 'page-1', url: 'about:blank', worktreeId: 'wt' }) + await backend.createTab({ browserPageId: 'page-2', url: 'about:blank', worktreeId: 'wt' }) + const close = backend.closeTab('page-1') + await vi.waitFor(() => expect(onPageClosed).toHaveBeenCalledWith('page-1')) + const pageWasUnregisteredBeforeRetirement = browserManager.unregisterGuest.mock.calls.some( + ([pageId]) => pageId === 'page-1' + ) + releaseOwnerRetirement() + await close + + expect(onPageClosed).toHaveBeenCalledOnce() + expect(onPageClosed).not.toHaveBeenCalledWith('page-2') + expect(backend.getWebContentsId('page-2')).toBe(2) + expect(pageWasUnregisteredBeforeRetirement).toBe(true) + }) + + it('preserves a replacement page when the old window finishes closing', async () => { + const browserManager = { + registerOffscreenGuest: vi.fn(), + unregisterGuest: vi.fn() + } + let releaseOwnerRetirement!: () => void + const ownerRetirementBlocked = new Promise((resolve) => { + releaseOwnerRetirement = resolve + }) + const onPageClosed = vi.fn(() => ownerRetirementBlocked) + const backend = new OffscreenBrowserBackend(browserManager as never, { + getAgentBrowserBridge: () => ({ onPageClosed }) + }) + + await backend.createTab({ browserPageId: 'page-1', url: 'about:blank', worktreeId: 'wt' }) + const close = backend.closeTab('page-1') + await vi.waitFor(() => expect(onPageClosed).toHaveBeenCalledWith('page-1')) + await backend.createTab({ browserPageId: 'page-1', url: 'about:blank', worktreeId: 'wt' }) + + releaseOwnerRetirement() + await close + + expect(backend.getWebContentsId('page-1')).toBe(2) + expect(browserManager.unregisterGuest).toHaveBeenCalledTimes(1) + }) + + it('retires the helper when an offscreen renderer is destroyed unexpectedly', async () => { + const browserManager = { + registerOffscreenGuest: vi.fn(), + unregisterGuest: vi.fn() + } + const onPageClosed = vi.fn(async () => {}) + const backend = new OffscreenBrowserBackend(browserManager as never, { + getAgentBrowserBridge: () => ({ onPageClosed }) + }) + + await backend.createTab({ browserPageId: 'page-1', url: 'about:blank', worktreeId: 'wt' }) + mocks.windows[0].webContents.emit('destroyed') + await vi.waitFor(() => expect(onPageClosed).toHaveBeenCalledWith('page-1')) + }) + + it('cleans every helper owner during backend shutdown', async () => { + const browserManager = { + registerOffscreenGuest: vi.fn(), + unregisterGuest: vi.fn() + } + const onPageClosed = vi.fn(async () => {}) + const backend = new OffscreenBrowserBackend(browserManager as never, { + getAgentBrowserBridge: () => ({ onPageClosed }) + }) + + await backend.createTab({ browserPageId: 'page-1', url: 'about:blank', worktreeId: 'wt' }) + await backend.createTab({ browserPageId: 'page-2', url: 'about:blank', worktreeId: 'wt' }) + await backend.destroyAll() + + expect(onPageClosed).toHaveBeenCalledTimes(2) + expect(browserManager.unregisterGuest).toHaveBeenCalledWith('page-1') + expect(browserManager.unregisterGuest).toHaveBeenCalledWith('page-2') + }) + + it('rejects a concurrent create while shutdown is draining owned pages', async () => { + const browserManager = { + registerOffscreenGuest: vi.fn(), + unregisterGuest: vi.fn() + } + let releaseOwnerRetirement!: () => void + const ownerRetirementBlocked = new Promise((resolve) => { + releaseOwnerRetirement = resolve + }) + const onPageClosed = vi.fn(() => ownerRetirementBlocked) + const backend = new OffscreenBrowserBackend(browserManager as never, { + getAgentBrowserBridge: () => ({ onPageClosed }) + }) + + await backend.createTab({ browserPageId: 'page-1', url: 'about:blank', worktreeId: 'wt' }) + const shutdown = backend.destroyAll() + await vi.waitFor(() => expect(onPageClosed).toHaveBeenCalledWith('page-1')) + + await expect( + backend.createTab({ browserPageId: 'page-2', url: 'about:blank', worktreeId: 'wt' }) + ).rejects.toThrow('Offscreen browser backend is shutting down') + + releaseOwnerRetirement() + await shutdown + expect(mocks.windows).toHaveLength(1) + expect(browserManager.unregisterGuest).toHaveBeenCalledWith('page-1') + expect(browserManager.unregisterGuest).not.toHaveBeenCalledWith('page-2') + }) + + it('joins owner retirement started by an unexpected renderer destroy', async () => { + const browserManager = { + registerOffscreenGuest: vi.fn(), + unregisterGuest: vi.fn() + } + let releaseOwnerRetirement!: () => void + const ownerRetirementBlocked = new Promise((resolve) => { + releaseOwnerRetirement = resolve + }) + const onPageClosed = vi.fn(() => ownerRetirementBlocked) + const backend = new OffscreenBrowserBackend(browserManager as never, { + getAgentBrowserBridge: () => ({ onPageClosed }) + }) + + await backend.createTab({ browserPageId: 'page-1', url: 'about:blank', worktreeId: 'wt' }) + mocks.windows[0].webContents.emit('destroyed') + await vi.waitFor(() => expect(onPageClosed).toHaveBeenCalledWith('page-1')) + + const shutdown = backend.destroyAll() + const outcome = await Promise.race([ + shutdown.then(() => 'settled'), + new Promise((resolve) => setImmediate(() => resolve('pending'))) + ]) + expect(outcome).toBe('pending') + + releaseOwnerRetirement() + await shutdown + }) + + it('bounds concurrent helper retirements during shutdown', async () => { + const browserManager = { + registerOffscreenGuest: vi.fn(), + unregisterGuest: vi.fn() + } + let activeRetirements = 0 + let peakRetirements = 0 + const releases: (() => void)[] = [] + const onPageClosed = vi.fn( + () => + new Promise((resolve) => { + activeRetirements++ + peakRetirements = Math.max(peakRetirements, activeRetirements) + releases.push(() => { + activeRetirements-- + resolve() + }) + }) + ) + const backend = new OffscreenBrowserBackend(browserManager as never, { + getAgentBrowserBridge: () => ({ onPageClosed }) + }) + + for (let index = 0; index < 6; index++) { + await backend.createTab({ + browserPageId: `page-${index}`, + url: 'about:blank', + worktreeId: 'wt' + }) + } + + const shutdown = backend.destroyAll() + await vi.waitFor(() => expect(onPageClosed).toHaveBeenCalledTimes(4)) + releases.splice(0).forEach((release) => release()) + await vi.waitFor(() => expect(onPageClosed).toHaveBeenCalledTimes(6)) + releases.splice(0).forEach((release) => release()) + await shutdown + + expect(peakRetirements).toBe(4) + }) }) diff --git a/src/main/browser/offscreen-browser-backend.ts b/src/main/browser/offscreen-browser-backend.ts index 3ca96a3748f..e17a2923aa0 100644 --- a/src/main/browser/offscreen-browser-backend.ts +++ b/src/main/browser/offscreen-browser-backend.ts @@ -2,8 +2,10 @@ import { randomUUID } from 'node:crypto' import { BrowserWindow } from 'electron' import { ORCA_BROWSER_PARTITION } from '../../shared/constants' import { ORCA_BROWSER_GUEST_WEB_PREFERENCES } from '../../shared/browser-guest-web-preferences' +import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency' import type { BrowserBackend, BrowserBackendCreateTab } from './browser-backend' import type { BrowserManager } from './browser-manager' +import type { AgentBrowserBridge } from './agent-browser-bridge' import { browserSessionRegistry } from './browser-session-registry' // Why: headless orca serve has no renderer window to host a , so each @@ -16,13 +18,26 @@ import { browserSessionRegistry } from './browser-session-registry' const DEFAULT_VIEWPORT_WIDTH = 1280 const DEFAULT_VIEWPORT_HEIGHT = 800 const LOAD_TIMEOUT_MS = 30_000 +const OWNER_RETIREMENT_CONCURRENCY = 4 export class OffscreenBrowserBackend implements BrowserBackend { private readonly windowsByPageId = new Map() + // Shutdown is terminal for this backend; rejecting creates closes the race + // where destroyAll snapshots ownership and a new page appears afterward. + private shutdownStarted = false + private readonly pendingOwnerRetirements = new Set>() - constructor(private readonly browserManager: BrowserManager) {} + constructor( + private readonly browserManager: BrowserManager, + private readonly options: { + getAgentBrowserBridge?: () => Pick | null + } = {} + ) {} async createTab(params: BrowserBackendCreateTab): Promise<{ browserPageId: string }> { + if (this.shutdownStarted) { + throw new Error('Offscreen browser backend is shutting down') + } const browserPageId = params.browserPageId ?? randomUUID() if (this.windowsByPageId.has(browserPageId)) { throw new Error(`Browser page ${browserPageId} already exists`) @@ -55,6 +70,12 @@ export class OffscreenBrowserBackend implements BrowserBackend { // teardown), drop the registry entry so commands fail cleanly instead of // resolving a dead WebContents. win.webContents.once('destroyed', () => { + // Explicit close removes the page first and performs awaited cleanup; + // only an unexpected destruction still owns the bridge retirement here. + if (this.windowsByPageId.get(browserPageId) !== win) { + return + } + void this.retirePageOwner(browserPageId) this.windowsByPageId.delete(browserPageId) this.browserManager.unregisterGuest(browserPageId) }) @@ -88,8 +109,14 @@ export class OffscreenBrowserBackend implements BrowserBackend { const win = this.windowsByPageId.get(browserPageId) this.windowsByPageId.delete(browserPageId) this.browserManager.unregisterGuest(browserPageId) - if (win && !win.isDestroyed()) { - win.destroy() + try { + if (win) { + await this.retirePageOwner(browserPageId) + } + } finally { + if (win && !win.isDestroyed()) { + win.destroy() + } } } @@ -98,14 +125,24 @@ export class OffscreenBrowserBackend implements BrowserBackend { return win && !win.isDestroyed() ? win.webContents.id : null } - destroyAll(): void { - for (const [pageId, win] of this.windowsByPageId) { - this.browserManager.unregisterGuest(pageId) - if (!win.isDestroyed()) { - win.destroy() - } + async destroyAll(): Promise { + this.shutdownStarted = true + const pageIds = [...this.windowsByPageId.keys()] + await mapSettledWithConcurrency(pageIds, OWNER_RETIREMENT_CONCURRENCY, (pageId) => + this.closeTab(pageId) + ) + await Promise.all(this.pendingOwnerRetirements) + } + + private retirePageOwner(browserPageId: string): Promise { + const bridge = this.options.getAgentBrowserBridge?.() + if (!bridge) { + return Promise.resolve() } - this.windowsByPageId.clear() + const retirement = bridge.onPageClosed(browserPageId).catch(() => {}) + this.pendingOwnerRetirements.add(retirement) + void retirement.finally(() => this.pendingOwnerRetirements.delete(retirement)) + return retirement } private async loadUrl(win: BrowserWindow, url: string): Promise { diff --git a/src/main/index.ts b/src/main/index.ts index f2657430ad2..edc26214121 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -222,6 +222,7 @@ import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl' import { probeWindowsInstallDirAcl } from './startup/windows-install-dir-acl-probe' import { neutralizeLegacyTerminalShimDir } from './pty/legacy-terminal-shim-dir' import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy' +import { registerServeSignalHandlers } from './startup/serve-signal-handlers' import { createServeDesktopActivationGate, settleServeDesktopActivation as settleServeDesktopActivationGate @@ -2088,15 +2089,6 @@ async function printServeReady(options: ServeOptions): Promise { notifyServeSupervisorReady(runtime.getRuntimeId()) } -function installServeSignalHandlers(): void { - const quit = (): void => { - // Why: route SIGINT/SIGTERM through Electron's normal quit so runtime metadata, daemon checkpoints, and telemetry flush. - app.quit() - } - process.once('SIGINT', quit) - 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) @@ -3295,7 +3287,11 @@ void app.whenReady().then(async () => { await runtime.reconcileLegacyWorkerTerminals() // 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)) + runtime.setOffscreenBrowserBackend( + new OffscreenBrowserBackend(browserManager, { + getAgentBrowserBridge: () => agentBrowserBridge + }) + ) } // 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: [] }) @@ -3304,7 +3300,8 @@ void app.whenReady().then(async () => { throw error }) settleServeDesktopActivation() - installServeSignalHandlers() + // Why: every attempt must reach app.quit(); a page beforeunload can veto an earlier signal. + registerServeSignalHandlers(process, () => app.quit()) // Why: headless serve has no renderer to run the normal cli:install flow; do it here for macOS/Linux only (Windows-excluded: install() only mutates registry PATH, not child terminals). if (process.platform === 'darwin' || process.platform === 'linux') { try { @@ -3515,10 +3512,11 @@ app.on('will-quit', (e) => { // Why: cancels relay restart/reinstall timers and kills wsl.exe children deterministically, not via stdio-pipe teardown. wslHookRelayManager.disposeAll() const statsFlush = stats?.flushAsync() ?? Promise.resolve() - // 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?.() + // Why: retire headless page owners first, then sweep residual helper sessions without duplicate close fanout. + const browserShutdown = (async (): Promise => { + await runtime?.getOffscreenBrowserBackend()?.destroyAll?.() + await runtime?.getAgentBrowserBridge()?.destroyAllSessions() + })() // Why (review P2-4): local SSH browser routes own loopback listeners and, on the // system-ssh path, `ssh -N -D` children that would otherwise outlive the app. const localSshRouteShutdown = import('./browser/local-ssh-browser-route') @@ -3570,6 +3568,7 @@ app.on('will-quit', (e) => { // temp+rename swap means a write cut short by the deadline leaves the old file intact. settleTeardownWithinDeadline([ { name: 'daemon', promise: daemonTeardown }, + { name: 'browser', promise: browserShutdown }, { name: 'runtime-rpc', promise: rpcStopAndClear }, { name: 'watchers', promise: watcherShutdown }, { name: 'emulator', promise: emulatorShutdown }, diff --git a/src/main/quit-teardown-deadline.ts b/src/main/quit-teardown-deadline.ts index 33f5031bdd0..6a712e0aa66 100644 --- a/src/main/quit-teardown-deadline.ts +++ b/src/main/quit-teardown-deadline.ts @@ -3,9 +3,9 @@ // socket) can leave one unsettled forever and make Force Quit the only way // out (#9447). Racing a deadline guarantees quit always completes. -// Why: generous enough for daemon checkpoint writes on a slow disk; small -// enough that a wedged teardown never needs Force Quit. -export const WILL_QUIT_TEARDOWN_DEADLINE_MS = 20_000 +import { WILL_QUIT_TEARDOWN_DEADLINE_MS } from '../shared/quit-teardown-deadline' + +export { WILL_QUIT_TEARDOWN_DEADLINE_MS } from '../shared/quit-teardown-deadline' export type NamedQuitTeardown = { name: string diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index 2e7453707b5..af4c7d609ca 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -270,6 +270,40 @@ describe('startup ordering', () => { expect(disposeIndex).toBeGreaterThan(commitIndex) }) + it('joins agent-browser cleanup before the committed quit exits', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const willQuitStart = source.indexOf("app.on('will-quit'") + const windowAllClosedStart = source.indexOf("app.on('window-all-closed'", willQuitStart) + const willQuit = source.slice(willQuitStart, windowAllClosedStart) + const cleanupStart = willQuit.indexOf('const browserShutdown') + const offscreenCleanupStart = willQuit.indexOf( + 'runtime?.getOffscreenBrowserBackend()?.destroyAll?.()' + ) + const residualCleanupStart = willQuit.indexOf( + 'runtime?.getAgentBrowserBridge()?.destroyAllSessions()' + ) + const barrierStart = willQuit.indexOf('settleTeardownWithinDeadline([') + + expect(willQuitStart).toBeGreaterThanOrEqual(0) + expect(windowAllClosedStart).toBeGreaterThan(willQuitStart) + expect(cleanupStart).toBeGreaterThanOrEqual(0) + expect(offscreenCleanupStart).toBeGreaterThan(cleanupStart) + expect(residualCleanupStart).toBeGreaterThan(offscreenCleanupStart) + expect(barrierStart).toBeGreaterThan(cleanupStart) + expect(willQuit.slice(barrierStart)).toContain("{ name: 'browser', promise: browserShutdown }") + }) + + it('registers repeatable serve signal handling before headless startup completes', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const serveStart = source.indexOf('if (serveOptions) {') + const signalHandlers = source.indexOf('registerServeSignalHandlers(process', serveStart) + const serveReady = source.indexOf('await printServeReady(serveOptions)', serveStart) + + expect(serveStart).toBeGreaterThanOrEqual(0) + expect(signalHandlers).toBeGreaterThan(serveStart) + expect(signalHandlers).toBeLessThan(serveReady) + }) + it('starts the automation scheduler before headless serve reports ready', () => { const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const serveStart = source.indexOf('if (serveOptions) {') diff --git a/src/main/startup/serve-signal-handlers.test.ts b/src/main/startup/serve-signal-handlers.test.ts new file mode 100644 index 00000000000..36250cd4146 --- /dev/null +++ b/src/main/startup/serve-signal-handlers.test.ts @@ -0,0 +1,19 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import { registerServeSignalHandlers } from './serve-signal-handlers' + +describe('registerServeSignalHandlers', () => { + it('retries a vetoed quit on every delivered signal', () => { + const signalSource = new EventEmitter() + const quitApplication = vi.fn() + + registerServeSignalHandlers(signalSource, quitApplication) + signalSource.emit('SIGINT') + signalSource.emit('SIGINT') + signalSource.emit('SIGTERM') + + expect(quitApplication).toHaveBeenCalledTimes(3) + expect(signalSource.listenerCount('SIGINT')).toBe(1) + expect(signalSource.listenerCount('SIGTERM')).toBe(1) + }) +}) diff --git a/src/main/startup/serve-signal-handlers.ts b/src/main/startup/serve-signal-handlers.ts new file mode 100644 index 00000000000..3022d935ac5 --- /dev/null +++ b/src/main/startup/serve-signal-handlers.ts @@ -0,0 +1,12 @@ +type ServeSignalSource = { + on(event: 'SIGINT' | 'SIGTERM', listener: () => void): unknown +} + +export function registerServeSignalHandlers( + signalSource: ServeSignalSource, + quitApplication: () => void +): void { + // Keep both listeners installed so duplicate delivery cannot fall through to default termination. + signalSource.on('SIGINT', quitApplication) + signalSource.on('SIGTERM', quitApplication) +} diff --git a/src/main/window/main-window-close-lifecycle.ts b/src/main/window/main-window-close-lifecycle.ts index cbfeafd1357..0bf1c04ef88 100644 --- a/src/main/window/main-window-close-lifecycle.ts +++ b/src/main/window/main-window-close-lifecycle.ts @@ -1,4 +1,5 @@ import { ipcMain, Menu, Notification, type BrowserWindow } from 'electron' +import { QUIT_RENDERER_ACK_TIMEOUT_MS } from '../../shared/quit-teardown-deadline' import { translateMain } from '../i18n/main-i18n' import type { Store } from '../persistence' import { resolveWindowCloseAction } from './window-close-decision' @@ -7,7 +8,7 @@ import type { MainWindowFocusLifecycle } from './main-window-focus-lifecycle' import type { MainWindowStateLifecycle } from './main-window-state-lifecycle' import { syncTrafficLightPosition } from './main-window-visual-lifecycle' -export const WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS = 10_000 +export const WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS = QUIT_RENDERER_ACK_TIMEOUT_MS export function installMainWindowCloseLifecycle(args: { focus: MainWindowFocusLifecycle diff --git a/src/shared/quit-teardown-deadline.ts b/src/shared/quit-teardown-deadline.ts new file mode 100644 index 00000000000..b42b02b20fd --- /dev/null +++ b/src/shared/quit-teardown-deadline.ts @@ -0,0 +1,3 @@ +export const QUIT_RENDERER_ACK_TIMEOUT_MS = 10_000 +// Generous enough for slow-disk checkpoints while keeping a wedged quit bounded. +export const WILL_QUIT_TEARDOWN_DEADLINE_MS = 20_000 From ac76e0dd06b9fcf3e7bb5210de129c91ab416da0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:20:36 -0700 Subject: [PATCH 020/152] fix(runtime): cancel pending driver timers on desktop reclaim (#16337) * fix(runtime): cancel pending driver timers on desktop reclaim * fix(runtime): centralize pending driver cancellation * fix(runtime): complete pending driver cancellation extraction * test(runtime): cover desktop reclaim mutation branches --- .../mobile-subscribe-integration.test.ts | 81 +++++++++++++++++-- src/main/runtime/orca-runtime.ts | 42 ++++------ 2 files changed, 90 insertions(+), 33 deletions(-) diff --git a/src/main/runtime/mobile-subscribe-integration.test.ts b/src/main/runtime/mobile-subscribe-integration.test.ts index cee4fcfe79f..292cc13a816 100644 --- a/src/main/runtime/mobile-subscribe-integration.test.ts +++ b/src/main/runtime/mobile-subscribe-integration.test.ts @@ -840,15 +840,80 @@ describe('mobile subscribe integration', () => { await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) runtime.handleMobileUnsubscribe('pty-1', 'client-a') - // No subscribers, indefinite hold — PTY stays at phone dims. - await vi.advanceTimersByTimeAsync(60_000) - expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) - expect(runtime.isMobileSubscriberActive('pty-1')).toBe(false) - - // Manual reclaim: PTY restored to desktop dims via the held branch. - const ok = await runtime.reclaimTerminalForDesktop('pty-1') - expect(ok).toBe(true) + await runtime.reclaimTerminalForDesktop('pty-1') expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + + await vi.advanceTimersByTimeAsync(250) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' }) + }) + + it('reclaim cancels the soft-leave timer in the remote-layout branch', async () => { + const { runtime } = createRuntime() + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-a', 'viewer-a', 120, 32) + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + + expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(true) + await vi.advanceTimersByTimeAsync(250) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' }) + }) + + it('reclaim cancels pending restore timers on the active-subscriber branch', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + await runtime.handleMobileSubscribe('pty-1', 'client-b', { cols: 40, rows: 18 }) + + const pendingRestore = Reflect.get(runtime, 'pendingRestoreTimers') as Map + pendingRestore.set('pty-1', { timer: setTimeout(() => {}, 60_000), clientId: 'client-b' }) + const pendingSoft = Reflect.get(runtime, 'pendingSoftLeavers') as Map + expect(pendingSoft.has('pty-1')).toBe(true) + await runtime.reclaimTerminalForDesktop('pty-1') + expect(pendingRestore.has('pty-1')).toBe(false) + expect(pendingSoft.has('pty-1')).toBe(false) + }) + + it('reclaim cancels pending restore timers on the orphan-driver branch', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + ;(Reflect.get(runtime, 'terminalFitOverrides') as Map).delete('pty-1') + + const pendingRestore = Reflect.get(runtime, 'pendingRestoreTimers') as Map + const pendingSoft = Reflect.get(runtime, 'pendingSoftLeavers') as Map + await runtime.reclaimTerminalForDesktop('pty-1') + expect(pendingRestore.has('pty-1')).toBe(false) + expect(pendingSoft.has('pty-1')).toBe(false) + }) + + it('reclaim cancels pending restore timers when no driver lock remains', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + ;(Reflect.get(runtime, 'terminalFitOverrides') as Map).delete('pty-1') + ;(Reflect.get(runtime, 'currentDriver') as Map).set('pty-1', { + kind: 'idle' + }) + + const pendingRestore = Reflect.get(runtime, 'pendingRestoreTimers') as Map + const pendingSoft = Reflect.get(runtime, 'pendingSoftLeavers') as Map + expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(false) + expect(pendingRestore.has('pty-1')).toBe(false) + expect(pendingSoft.has('pty-1')).toBe(false) + }) + + it('reclaim revokes soft-leave grace admission for mobile input', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + + const claim = runtime.beginMobileInputFloor('pty-1', 'client-a') + expect(claim).not.toBeNull() + claim?.rollback() + + await runtime.reclaimTerminalForDesktop('pty-1') + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' }) + expect(runtime.beginMobileInputFloor('pty-1', 'client-a')).toBeNull() }) it('reclaimTerminalForDesktop prefers fresh desktop geometry for a held PTY', async () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index a50cef258e6..db9be9e7aa0 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -15821,16 +15821,7 @@ export class OrcaRuntimeService { this.layouts.delete(ptyId) this.layoutQueues.delete(ptyId) this.freshSubscribeGuard.delete(ptyId) - const pendingRestore = this.pendingRestoreTimers.get(ptyId) - if (pendingRestore) { - clearTimeout(pendingRestore.timer) - this.pendingRestoreTimers.delete(ptyId) - } - const pendingSoft = this.pendingSoftLeavers.get(ptyId) - if (pendingSoft) { - clearTimeout(pendingSoft.timer) - this.pendingSoftLeavers.delete(ptyId) - } + this.cancelPendingDriverMutations(ptyId) // Why: a cold restore can respawn under the same session id within the // delayed-Enter window; the armed Enter would inject \r into the // replacement and stamp rows it never received. @@ -16487,6 +16478,7 @@ export class OrcaRuntimeService { // frame. Returns `true` whenever there was a lock to reclaim, `false` only // when there was nothing to reclaim. async reclaimTerminalForDesktop(ptyId: string): Promise { + this.cancelPendingDriverMutations(ptyId) if (this.isMobileSubscriberActive(ptyId)) { this.setMobileDisplayMode(ptyId, 'desktop') await this.applyMobileDisplayMode(ptyId) @@ -16506,16 +16498,6 @@ export class OrcaRuntimeService { } const heldOverride = this.terminalFitOverrides.get(ptyId) if (heldOverride && this.hasRemoteDesktopLayoutState(ptyId)) { - const pending = this.pendingRestoreTimers.get(ptyId) - if (pending) { - clearTimeout(pending.timer) - this.pendingRestoreTimers.delete(ptyId) - } - const softLeaver = this.pendingSoftLeavers.get(ptyId) - if (softLeaver) { - clearTimeout(softLeaver.timer) - this.pendingSoftLeavers.delete(ptyId) - } // Why: applyRemoteDesktopLayout no-ops while the driver still reads mobile. this.setDriver(ptyId, { kind: 'idle' }) // Why: best-effort, like the local held branch below. A host whose resize @@ -16528,11 +16510,6 @@ export class OrcaRuntimeService { return true } if (heldOverride) { - const pending = this.pendingRestoreTimers.get(ptyId) - if (pending) { - clearTimeout(pending.timer) - this.pendingRestoreTimers.delete(ptyId) - } // Why: with no subscribers, resolveDesktopRestoreTarget can fall through // to current PTY size — which is at phone dims (wrong). Prefer a fresh // desktop renderer measurement when one exists; otherwise use the @@ -16557,6 +16534,21 @@ export class OrcaRuntimeService { return false } + // Why: teardown and desktop reclaim supersede delayed mobile mutations, + // revoking soft-leave grace admission for input floors. + private cancelPendingDriverMutations(ptyId: string): void { + const pendingRestore = this.pendingRestoreTimers.get(ptyId) + if (pendingRestore) { + clearTimeout(pendingRestore.timer) + this.pendingRestoreTimers.delete(ptyId) + } + const pendingSoft = this.pendingSoftLeavers.get(ptyId) + if (pendingSoft) { + clearTimeout(pendingSoft.timer) + this.pendingSoftLeavers.delete(ptyId) + } + } + // Why: the shared "banner must be gone now" step for an explicit desktop // take-back. Releases the presence lock (driver → desktop) and, if the // best-effort resize left a fit-override held (resize didn't converge), From 81f89a705c5bf334b7ce1086db62fbeafccf1d47 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:23:07 -0700 Subject: [PATCH 021/152] fix(terminal): bound WebGL context-loss retries on tab reveal (#16338) * fix(terminal): retry bounded WebGL recovery on tab reveal * test(terminal): cover reveal repaint and pruned diagnostics * fix(terminal): make WebGL diagnostics pure and cover reveal refusal * fix(terminal): correct WebGL retry comments --- .../lib/pane-manager/pane-manager-types.ts | 3 + .../pane-manager/pane-rendering-control.ts | 12 ++-- .../pane-rendering-diagnostics.ts | 2 + .../pane-manager/pane-reveal-repaint.test.ts | 15 +++++ .../pane-terminal-gpu-acceleration.ts | 2 + .../pane-webgl-context-loss-policy.ts | 38 ++++++++++++ .../pane-webgl-context-recovery.test.ts | 59 ++++++++++++++++++- .../lib/pane-manager/pane-webgl-reattach.ts | 14 ++++- .../lib/pane-manager/pane-webgl-renderer.ts | 9 +-- 9 files changed, 143 insertions(+), 11 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/pane-webgl-context-loss-policy.ts diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index 62c69836841..49ceabb75f1 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -114,6 +114,7 @@ export type PaneRenderingDiagnostics = { gpuRenderingEnabled: boolean webglAttachmentDeferred: boolean webglDisabledAfterContextLoss: boolean + webglContextLossesInWindow?: number webglAttachFailedSinceRecovery: boolean hasComplexScriptOutput: boolean terminalWebglAutoDecision: TerminalWebglAutoDecision @@ -142,6 +143,8 @@ export type ManagedPaneInternal = { gpuRenderingEnabled: boolean webglAttachmentDeferred: boolean webglDisabledAfterContextLoss: boolean + // Shared history bounds context-loss retries across resume and settled reveal. + webglContextLossTimestamps?: number[] // Hidden retained renderers rebuild at the resume boundary, never behind the hidden surface. webglRebuildDeferred?: boolean // Why per-pane: one pane's failed WebGL attach must not strand every other diff --git a/src/renderer/src/lib/pane-manager/pane-rendering-control.ts b/src/renderer/src/lib/pane-manager/pane-rendering-control.ts index ed56a1d65ec..6709a7a1f98 100644 --- a/src/renderer/src/lib/pane-manager/pane-rendering-control.ts +++ b/src/renderer/src/lib/pane-manager/pane-rendering-control.ts @@ -10,7 +10,11 @@ import { presentPaneViewport, resetWebglTextureAtlas } from './pane-webgl-renderer' -import { rebuildAttachedWebgl, reattachWebglIfNeeded } from './pane-webgl-reattach' +import { + clearPaneWebglContextLossForRetry, + rebuildAttachedWebgl, + reattachWebglIfNeeded +} from './pane-webgl-reattach' import { releaseHiddenWebglRetention, tryRetainHiddenPanesWebgl @@ -81,13 +85,11 @@ export function resumePaneRendering( releaseHiddenWebglRetention(retentionOwner) } for (const pane of panes) { - // Why: resume (worktree foreground, window wake) is the WebGL retry - // boundary — Chromium may have restored the GPU process since a context - // loss, and bounding retries to resume events cannot loop on live loss. clearTerminalWebglAttachBackoff(pane) const rebuildDeferred = pane.webglRebuildDeferred === true pane.webglAttachmentDeferred = false - pane.webglDisabledAfterContextLoss = false + // Reveal can retry before the next resume, so both paths share the bounded loss policy. + clearPaneWebglContextLossForRetry(pane) pane.webglRebuildDeferred = false if (pane.webglAddon && isPaneWebglContextLost(pane)) { disposeWebgl(pane) diff --git a/src/renderer/src/lib/pane-manager/pane-rendering-diagnostics.ts b/src/renderer/src/lib/pane-manager/pane-rendering-diagnostics.ts index 15faad3136c..4d3d32a40cc 100644 --- a/src/renderer/src/lib/pane-manager/pane-rendering-diagnostics.ts +++ b/src/renderer/src/lib/pane-manager/pane-rendering-diagnostics.ts @@ -1,5 +1,6 @@ import type { ManagedPaneInternal, PaneRenderingDiagnostics } from './pane-manager-types' import { getTerminalWebglAutoDecision } from './terminal-webgl-auto-policy' +import { countPaneWebglContextLosses } from './pane-webgl-context-loss-policy' export function collectPaneRenderingDiagnostics( panes: Map @@ -10,6 +11,7 @@ export function collectPaneRenderingDiagnostics( gpuRenderingEnabled: pane.gpuRenderingEnabled, webglAttachmentDeferred: pane.webglAttachmentDeferred, webglDisabledAfterContextLoss: pane.webglDisabledAfterContextLoss, + webglContextLossesInWindow: countPaneWebglContextLosses(pane), webglAttachFailedSinceRecovery: pane.webglAttachFailedSinceRecovery === true, hasComplexScriptOutput: pane.hasComplexScriptOutput, terminalWebglAutoDecision: getTerminalWebglAutoDecision(), diff --git a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts index 8604a0fd6dc..50c5784dd30 100644 --- a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts @@ -235,5 +235,20 @@ describe('schedulePaneRevealRepaint', () => { expect(pane.webglAddon).not.toBeNull() expect(pane.terminal.refresh).toHaveBeenCalled() }) + + it('reattaches a pane that lost WebGL while hidden when its tab is revealed', () => { + const pane = createPane() + pane.webglDisabledAfterContextLoss = true + pane.webglContextLossTimestamps = [Date.now()] + + schedulePaneRevealPresent(() => [pane]) + flushFrame() + flushFrame() + + expect(pane.webglDisabledAfterContextLoss).toBe(false) + expect(pane.webglAddon).not.toBeNull() + expect(pane.terminal.refresh).toHaveBeenCalledTimes(2) + expect(pane.terminal.refresh).toHaveBeenNthCalledWith(2, 0, 23) + }) }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts b/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts index 86b474196f7..099e3a40c82 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts @@ -6,6 +6,7 @@ import { shouldUseTerminalWebgl } from './pane-webgl-renderer' import { safeFit } from './pane-tree-ops' +import { resetPaneWebglContextLosses } from './pane-webgl-context-loss-policy' export function applyTerminalGpuAcceleration( panes: Iterable, @@ -26,6 +27,7 @@ export function applyTerminalGpuAcceleration( // renderer; context-loss and attach-failure latches from the old mode // should not pin DOM. pane.webglDisabledAfterContextLoss = false + resetPaneWebglContextLosses(pane) pane.webglAttachFailedSinceRecovery = false } if (!shouldUseTerminalWebgl(pane)) { diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-context-loss-policy.ts b/src/renderer/src/lib/pane-manager/pane-webgl-context-loss-policy.ts new file mode 100644 index 00000000000..be81b824a40 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-webgl-context-loss-policy.ts @@ -0,0 +1,38 @@ +import type { ManagedPaneInternal } from './pane-manager-types' + +/** Retry a pane after a few transient losses, but stop retrying a persistently unstable context. */ +export const WEBGL_CONTEXT_LOSS_RETRY_LIMIT = 3 +export const WEBGL_CONTEXT_LOSS_RETRY_WINDOW_MS = 60_000 + +function recentContextLosses(pane: ManagedPaneInternal, now: number): number[] { + const cutoff = now - WEBGL_CONTEXT_LOSS_RETRY_WINDOW_MS + return (pane.webglContextLossTimestamps ?? []).filter((timestamp) => timestamp > cutoff) +} + +export function prunePaneWebglContextLosses(pane: ManagedPaneInternal, now = Date.now()): number { + const losses = recentContextLosses(pane, now) + pane.webglContextLossTimestamps = losses + return losses.length +} + +export function countPaneWebglContextLosses(pane: ManagedPaneInternal, now = Date.now()): number { + return recentContextLosses(pane, now).length +} + +export function recordPaneWebglContextLoss(pane: ManagedPaneInternal, now = Date.now()): number { + const losses = recentContextLosses(pane, now) + losses.push(now) + pane.webglContextLossTimestamps = losses + return losses.length +} + +export function canRetryPaneWebglAfterContextLoss( + pane: ManagedPaneInternal, + now = Date.now() +): boolean { + return prunePaneWebglContextLosses(pane, now) < WEBGL_CONTEXT_LOSS_RETRY_LIMIT +} + +export function resetPaneWebglContextLosses(pane: ManagedPaneInternal): void { + pane.webglContextLossTimestamps = undefined +} diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts index 128057e463d..2897c1627de 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts @@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { setTerminalWebglDiagnosticRecorder } from '../../../../shared/terminal-webgl-diagnostics' import type { ManagedPaneInternal } from './pane-manager-types' import { resumePaneRendering, suspendPaneRendering } from './pane-rendering-control' +import { collectPaneRenderingDiagnostics } from './pane-rendering-diagnostics' +import { schedulePaneRevealPresent } from './pane-reveal-repaint' import { attachWebgl, resetTerminalWebglSuggestion } from './pane-webgl-renderer' import { rebuildAttachedWebgl } from './pane-webgl-reattach' @@ -168,6 +170,44 @@ describe('terminal WebGL context recovery', () => { expect(pane.webglAddon).toBeNull() }) + it('stops resume retries after repeated losses in the retry window', () => { + const pane = createPane() + + attachWebgl(pane) + fireContextLoss(pane) + resumePaneRendering([pane]) + fireContextLoss(pane) + resumePaneRendering([pane]) + fireContextLoss(pane) + + expect(pane.webglContextLossTimestamps).toHaveLength(3) + expect(pane.webglDisabledAfterContextLoss).toBe(true) + expect(pane.webglAddon).toBeNull() + + resumePaneRendering([pane]) + + expect(pane.webglDisabledAfterContextLoss).toBe(true) + expect(pane.webglAddon).toBeNull() + expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(3) + }) + + it('stops reveal retries after repeated losses in the retry window', () => { + const pane = createPane() + + attachWebgl(pane) + fireContextLoss(pane) + resumePaneRendering([pane]) + fireContextLoss(pane) + resumePaneRendering([pane]) + fireContextLoss(pane) + + schedulePaneRevealPresent(() => [pane]) + + expect(pane.webglDisabledAfterContextLoss).toBe(true) + expect(pane.webglAddon).toBeNull() + expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(3) + }) + // Why exact keys: a GPU death loses every pane's context at once and the // crash ring coalesces the repeats, so the population has to survive on the // payload. It must use the same names the fit-retry crumb uses, or one ring @@ -186,8 +226,25 @@ describe('terminal WebGL context recovery', () => { expect(recorded).toEqual([ { kind: 'webgl-context-loss', - detail: { paneId: 1, livePanes: expect.any(Number), livePaneManagers: expect.any(Number) } + detail: { + paneId: 1, + lossesInWindow: 1, + livePanes: expect.any(Number), + livePaneManagers: expect.any(Number) + } } ]) }) + + it('reports only recent context losses in rendering diagnostics', () => { + const now = 1_700_000_000_000 + vi.spyOn(Date, 'now').mockReturnValue(now) + const pane = createPane() + pane.webglContextLossTimestamps = [now - 60_001, now - 1_000] + + const [diagnostics] = collectPaneRenderingDiagnostics(new Map([[pane.id, pane]])) + + expect(diagnostics.webglContextLossesInWindow).toBe(1) + expect(pane.webglContextLossTimestamps).toEqual([now - 60_001, now - 1_000]) + }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-reattach.ts b/src/renderer/src/lib/pane-manager/pane-webgl-reattach.ts index 98c5c10e079..1bb79d6f6e0 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-reattach.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-reattach.ts @@ -1,8 +1,20 @@ import type { ManagedPaneInternal } from './pane-manager-types' import { attachWebgl, clearTerminalWebglAttachBackoff, disposeWebgl } from './pane-webgl-renderer' +import { canRetryPaneWebglAfterContextLoss } from './pane-webgl-context-loss-policy' + +export function clearPaneWebglContextLossForRetry(pane: ManagedPaneInternal): boolean { + if (!pane.webglDisabledAfterContextLoss) { + return true + } + if (!canRetryPaneWebglAfterContextLoss(pane)) { + return false + } + pane.webglDisabledAfterContextLoss = false + return true +} export function reattachWebglIfNeeded(pane: ManagedPaneInternal): void { - if (pane.gpuRenderingEnabled && !pane.webglAddon && !pane.webglDisabledAfterContextLoss) { + if (pane.gpuRenderingEnabled && !pane.webglAddon && clearPaneWebglContextLossForRetry(pane)) { attachWebgl(pane) } } diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts index 463bc76e53a..e15becd0cff 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -14,6 +14,7 @@ import { import { safeFit, safeFitAndThen } from './pane-fit' import { setPaneFitWebglAttachHook } from './pane-fit-webgl-attach-signal' import { repairPaneWebglCanvasDprMismatch } from './terminal-canvas-dpr-repair' +import { recordPaneWebglContextLoss } from './pane-webgl-context-loss-policy' export const ENABLE_WEBGL_RENDERER = true let suggestedRendererType: 'dom' | undefined @@ -340,15 +341,15 @@ export function attachWebgl(pane: ManagedPaneInternal): void { // once, and the crash-report ring coalesces repeats, so the count has to // be in the payload rather than in the number of crumbs. const census = getLivePaneCensus() + const lossesInWindow = recordPaneWebglContextLoss(pane) recordTerminalWebglDiagnostic('webgl-context-loss', { paneId: pane.id, + lossesInWindow, livePanes: census.panes, livePaneManagers: census.managers }) - // Why: Chromium starts reclaiming terminal contexts under pressure. - // Recreating WebGL for this pane can loop context loss and leave xterm - // visually blank, so keep the pane on the DOM renderer until the next - // rendering resume (worktree foreground / window wake) retries it. + // Why: context loss switches this pane to DOM until the next resume or + // settled reveal; the bounded loss window refuses unstable retries. pane.webglDisabledAfterContextLoss = true disposeWebgl(pane, { refreshDimensions: true }) }) From 614d2d4a28b828a1d9c949d74fe5a69d187a8069 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:33:11 -0700 Subject: [PATCH 022/152] fix(cmd+j): always enable See more for soft preview hints (#16661) The leading preview section now shows an actionable 'See more' button even when all rows fit within the hard cap, letting users expand and browse more tabs without scrolling past the worktrees section. --- .../src/components/WorktreeJumpPalette.tsx | 12 +++------ ...jump-palette-interleaved-sections.test.tsx | 27 ++++++++++++++++--- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 3c3a7fce32d..2cbe63a9576 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -2303,14 +2303,10 @@ function WorktreeJumpPaletteContent({ pushLeadingHeader() appendPaletteListEntries(entries, multiPrimaryLayout.leadingPreview as PaletteItem[]) // Soft more for the leading section (rows resuming below + hard-cap tail). - // Why: only actionable when rows are actually hidden — with the whole - // section already rendered below, expanding would just reshuffle rows. - pushOverflowHint( - leadingHintId, - multiPrimaryLayout.leadingMoreCount, - multiPrimaryLayout.leadingHardOverflowCount > 0 - ? () => handleExpandSection(leadingSectionKey) - : undefined + // Why: reveals the next batch into the leading preview so the user can + // keep browsing tabs without having to scroll past the worktrees section. + pushOverflowHint(leadingHintId, multiPrimaryLayout.leadingMoreCount, () => + handleExpandSection(leadingSectionKey) ) pushTrailingHeader() // Floor first, then remaining leading rows, then trailing rest — same order diff --git a/src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx b/src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx index 3f2c3025407..62055b5027b 100644 --- a/src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx +++ b/src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx @@ -456,7 +456,7 @@ describe('WorktreeJumpPalette interleaved primary sections', () => { expect(testContainer.textContent).toContain('10 more') }) - it('leaves the soft preview hint non-actionable when no rows are hidden', async () => { + it('expands soft preview when clicking See more even when all rows fit within the hard cap', async () => { await renderPalette(perfTabsPaletteProps(30)) await act(async () => { @@ -464,12 +464,33 @@ describe('WorktreeJumpPalette interleaved primary sections', () => { }) await flushEffects() - // All 30 tabs render (6 preview + 24 remainder), so expanding would only reorder rows. + // 6 preview tabs, 24 more follow below the worktrees section expect(testContainer.textContent).toContain('24 more') const seeMoreBtn = Array.from(testContainer.querySelectorAll('button')).find((btn) => btn.textContent?.includes('See more') ) - expect(seeMoreBtn).toBeUndefined() + expect(seeMoreBtn).toBeDefined() + + // Click See more: preview expands to 6 + 20 = 26 tabs, leaving 4 more + await act(async () => { + seeMoreBtn?.click() + }) + await flushEffects() + + expect(testContainer.textContent).toContain('4 more') + + const seeMoreBtn2 = Array.from(testContainer.querySelectorAll('button')).find((btn) => + btn.textContent?.includes('See more') + ) + expect(seeMoreBtn2).toBeDefined() + + // Click See more again: preview expands to 26 + 20 = 46 tabs (fits all 30), hint disappears + await act(async () => { + seeMoreBtn2?.click() + }) + await flushEffects() + + expect(testContainer.textContent).not.toContain('more') }) it('resets expanded section caps when query changes', async () => { From 015f904fca143c630291910d4096688b39e46ebb Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:42:54 -0700 Subject: [PATCH 023/152] fix(codex): stop re-scanning all Codex session history on every launch (#16251) (#16593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(codex): stop re-scanning all Codex session history on every launch (#16251) A launch deleted the backfill completion marker, and a marker could never be written while a Codex pane was open, so every launch re-derived "needs full scan" and walked the entire .codex/sessions tree — on Windows with a large history that read as a hung window. - v4 marker keeps a durable full-history baseline plus a bounded set of pending dates. v3 is read as a baseline, so upgrades pay no full scan. - A launch now marks dates pending instead of deleting the marker, and a full pass certifies the baseline even while a pane is still running; the live pane's own date just stays pending. - Pending dates are persisted, so an abnormal exit or a cross-midnight pane recovers a bounded window instead of a full walk. - A date-limited pass can only extend an existing baseline, never create one, so it can no longer certify history it never looked at. - Marker and index-heal target roots compare through normalizeRuntimePathForComparison, so Windows spellings of one directory stop invalidating each other. - Both append-only ledgers stream instead of readFileSync + whole-file JSON.parse, keeping the main thread responsive on large histories. * fix(codex): keep the backfill marker's full-scan demand durable Review follow-ups on the v4 backfill marker: - markCodexSessionBackfillMarkerPending no longer erases a persisted needsFullScan; the demand survives until a generation-current full walk retires it, and the function now reports it so the launch path folds it into its own in-memory flag (as @rumoii's #16252 does). - A full pass settles the whole pending set instead of subtracting the empty set, so a date a full walk provably covered stops forcing an extra bounded pass on every startup. - isCodexSessionBackfillDate does a real calendar check, so a corrupted marker cannot carry 2026/99/99. No age or future bound: the same guard gates rollout publication and a clock-skewed directory holds real sessions. - 'scans only the current date once a baseline exists' now has a second date directory, so it fails on a full walk instead of passing either way. --- ...untime-home-real-home-lane-routing.test.ts | 24 +- .../codex-accounts/runtime-home-service.ts | 28 +- ...untime-home-session-migration-pass.test.ts | 149 ++++++++ .../codex/codex-session-backfill-audit.ts | 55 +-- src/main/codex/codex-session-backfill-date.ts | 20 +- .../codex/codex-session-backfill-fs-mocks.ts | 117 ++++++ .../codex-session-backfill-marker.test.ts | 250 +++++++++++++ .../codex/codex-session-backfill-marker.ts | 279 +++++++++++--- .../codex-session-backfill-scan-dates.test.ts | 102 +++++ .../codex-session-backfill-scan-dates.ts | 112 ++++++ .../codex/codex-session-backfill-types.ts | 10 +- src/main/codex/codex-session-backfill.test.ts | 349 +++++++++--------- src/main/codex/codex-session-backfill.ts | 59 ++- .../codex-session-index-heal-state.test.ts | 154 ++++++++ .../codex/codex-session-index-heal-state.ts | 52 +-- src/main/codex/codex-session-index-heal.ts | 2 +- src/main/codex/codex-session-ledger-stream.ts | 55 +++ .../codex-session-migration-scheduler.test.ts | 87 ++++- .../codex-session-migration-scheduler.ts | 65 ++-- src/main/index.ts | 3 +- 20 files changed, 1565 insertions(+), 407 deletions(-) create mode 100644 src/main/codex-accounts/runtime-home-session-migration-pass.test.ts create mode 100644 src/main/codex/codex-session-backfill-fs-mocks.ts create mode 100644 src/main/codex/codex-session-backfill-marker.test.ts create mode 100644 src/main/codex/codex-session-backfill-scan-dates.test.ts create mode 100644 src/main/codex/codex-session-backfill-scan-dates.ts create mode 100644 src/main/codex/codex-session-index-heal-state.test.ts create mode 100644 src/main/codex/codex-session-ledger-stream.ts diff --git a/src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts b/src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts index 9bd55bba6e4..c8c3c407c0c 100644 --- a/src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts +++ b/src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts @@ -18,6 +18,18 @@ import { testState, writePaneRegistry } from './runtime-home-service-test-harness' +import { hasCompletedCodexSessionBackfillMarker } from '../codex/codex-session-backfill-marker' +import { getCodexSessionBackfillDate } from '../codex/codex-session-backfill-scan-dates' + +function expectBaselineKeptWithLaunchDatePending(markerPath: string): void { + const marker = JSON.parse(readFileSync(markerPath, 'utf-8')) as { + pendingScanDates?: unknown + } + expect(marker.pendingScanDates).toEqual([getCodexSessionBackfillDate()]) + expect( + hasCompletedCodexSessionBackfillMarker(markerPath, join(getSystemCodexHomePath(), 'sessions')) + ).toBe(true) +} vi.mock('electron', () => ({ app: { @@ -54,7 +66,9 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - expect(existsSync(markerPath)).toBe(false) + expect( + hasCompletedCodexSessionBackfillMarker(markerPath, join(getSystemCodexHomePath(), 'sessions')) + ).toBe(false) service.finishHostSystemDefaultSessionMigrationPass() expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe( true @@ -82,7 +96,7 @@ describe('CodexRuntimeHomeService', () => { expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe( false ) - expect(existsSync(markerPath)).toBe(false) + expectBaselineKeptWithLaunchDatePending(markerPath) service.prepareForCodexLaunch() expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe( false @@ -101,7 +115,7 @@ describe('CodexRuntimeHomeService', () => { expect(service.beginHostSystemDefaultSessionMigrationLaunch(null, { reattached: true })).toBe( false ) - expect(existsSync(markerPath)).toBe(false) + expectBaselineKeptWithLaunchDatePending(markerPath) store.updateSettings({ codexSessionSourceHome: { host: join(testState.fakeHomeDir, 'moved-history'), wsl: {} } }) @@ -140,7 +154,9 @@ describe('CodexRuntimeHomeService', () => { mkdirSync(join(testState.userDataDir, 'codex-session-backfill'), { recursive: true }) writeFileSync(markerPath, '{}\n', 'utf-8') expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - expect(existsSync(markerPath)).toBe(false) + expect( + hasCompletedCodexSessionBackfillMarker(markerPath, join(getSystemCodexHomePath(), 'sessions')) + ).toBe(false) expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe( true ) diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 3049a2a70b6..1d6f80dbafc 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -71,8 +71,10 @@ import { getDefaultWslDistro, getWslHome } from '../wsl' import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' import { hasCompletedCodexSessionBackfillMarker, - invalidateCodexSessionBackfillMarker + markCodexSessionBackfillMarkerPending } from '../codex/codex-session-backfill-marker' +import { getCodexSessionBackfillDate } from '../codex/codex-session-backfill-scan-dates' +import type { CodexSessionBackfillDate } from '../codex/codex-session-backfill-types' import { resolveCodexSessionBackfillPaths } from '../codex/codex-session-backfill' import { ManagedCodexHomeTemporarilyUnavailableError, @@ -305,18 +307,30 @@ export class CodexRuntimeHomeService { ) } - prepareHostSystemDefaultSessionMigrationPass(): boolean { + prepareHostSystemDefaultSessionMigrationPass( + scanDates: readonly CodexSessionBackfillDate[] = [] + ): boolean { const paths = resolveCodexSessionBackfillPaths( resolveHostCodexSessionSourceHome(this.store.getSettings()) ) + const target = normalizeRuntimePathForComparison(paths.systemSessionsRoot) if ( this.hostSystemDefaultSessionMigrationPending && - this.pendingHostSystemDefaultSessionMigrationTarget !== paths.systemSessionsRoot + this.pendingHostSystemDefaultSessionMigrationTarget !== target ) { this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = true - this.pendingHostSystemDefaultSessionMigrationTarget = paths.systemSessionsRoot + this.pendingHostSystemDefaultSessionMigrationTarget = target } - invalidateCodexSessionBackfillMarker(paths.markerPath) + // Why: the launch creates rollouts for these dates; record them durably so a + // force-quit recovers a bounded window instead of re-walking all history. + const markerOwesFullScan = markCodexSessionBackfillMarkerPending( + paths.markerPath, + paths.systemSessionsRoot, + scanDates.length > 0 ? scanDates : [getCodexSessionBackfillDate()] + ) + // Why: the marker is the only place an overflowed pending window survives a + // restart, so its demand has to reach this pass rather than die in the file. + this.pendingHostSystemDefaultSessionMigrationNeedsFullScan ||= markerOwesFullScan return this.pendingHostSystemDefaultSessionMigrationNeedsFullScan } @@ -526,7 +540,9 @@ export class CodexRuntimeHomeService { ) this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = !hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot) - this.pendingHostSystemDefaultSessionMigrationTarget = paths.systemSessionsRoot + this.pendingHostSystemDefaultSessionMigrationTarget = normalizeRuntimePathForComparison( + paths.systemSessionsRoot + ) this.hostSystemDefaultSessionMigrationPending = true } return this.prepareHostSystemDefaultSessionMigrationPass() diff --git a/src/main/codex-accounts/runtime-home-session-migration-pass.test.ts b/src/main/codex-accounts/runtime-home-session-migration-pass.test.ts new file mode 100644 index 00000000000..640c35a8e60 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-session-migration-pass.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import type * as NodeOs from 'node:os' +import { join } from 'node:path' +import { createSettings } from './runtime-home-settings-test-fixtures' +import { + createStore, + getRuntimeCodexHomePath, + setupRuntimeHomeTest, + teardownRuntimeHomeTest, + testState +} from './runtime-home-service-test-harness' +import { getCodexSessionBackfillDate } from '../codex/codex-session-backfill-scan-dates' +import type { CodexSessionBackfillDate } from '../codex/codex-session-backfill-types' + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.userDataDir + } +})) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +const CUSTOM_HISTORY_HOME = 'C:\\Users\\Me\\.codex' + +function getMarkerPath(): string { + return join(testState.userDataDir, 'codex-session-backfill', 'backfill-complete.json') +} + +function writeBaselineMarker(systemCodexHomePath: string, needsFullScan = false): void { + mkdirSync(join(testState.userDataDir, 'codex-session-backfill'), { recursive: true }) + writeFileSync( + getMarkerPath(), + `${JSON.stringify({ + version: 4, + systemSessionsRoot: join(systemCodexHomePath, 'sessions'), + coverage: 'full', + baselineScannedFiles: 5, + pendingScanDates: [], + needsFullScan, + summary: { scannedFiles: 5 } + })}\n`, + 'utf-8' + ) +} + +function readPendingScanDates(): unknown { + return (JSON.parse(readFileSync(getMarkerPath(), 'utf-8')) as { pendingScanDates?: unknown }) + .pendingScanDates +} + +describe('host system default session migration pass preparation', () => { + beforeEach(() => { + setupRuntimeHomeTest() + }) + + afterEach(() => { + teardownRuntimeHomeTest() + }) + + it('records the launch date and keeps the baseline instead of deleting it', async () => { + writeBaselineMarker(CUSTOM_HISTORY_HOME) + const store = createStore( + createSettings({ codexSessionSourceHome: { host: CUSTOM_HISTORY_HOME, wsl: {} } }) + ) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(service.prepareHostSystemDefaultSessionMigrationPass()).toBe(false) + + expect(readPendingScanDates()).toEqual([getCodexSessionBackfillDate()]) + expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ coverage: 'full' }) + }) + + it('persists every date a scheduled pass reports so a force-quit stays bounded', async () => { + writeBaselineMarker(CUSTOM_HISTORY_HOME) + const store = createStore( + createSettings({ codexSessionSourceHome: { host: CUSTOM_HISTORY_HOME, wsl: {} } }) + ) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const spannedDates: CodexSessionBackfillDate[] = [ + ['2026', '08', '05'], + ['2026', '08', '06'] + ] + + service.prepareHostSystemDefaultSessionMigrationPass(spannedDates) + + expect(readPendingScanDates()).toEqual(spannedDates) + }) + + it('does not demand a full scan when the same history home is spelled differently', async () => { + writeBaselineMarker(CUSTOM_HISTORY_HOME) + const store = createStore( + createSettings({ codexSessionSourceHome: { host: CUSTOM_HISTORY_HOME, wsl: {} } }) + ) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe( + false + ) + + store.updateSettings({ + codexSessionSourceHome: { host: 'c:/users/me/.codex', wsl: {} } + }) + + expect(service.prepareHostSystemDefaultSessionMigrationPass()).toBe(false) + }) + + it('carries a full-scan demand persisted by an earlier launch into this pass', async () => { + writeBaselineMarker(CUSTOM_HISTORY_HOME, true) + const store = createStore( + createSettings({ codexSessionSourceHome: { host: CUSTOM_HISTORY_HOME, wsl: {} } }) + ) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(service.prepareHostSystemDefaultSessionMigrationPass()).toBe(true) + + // Recording this launch must not erase the demand the marker still carries. + expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ + needsFullScan: true + }) + }) + + it('still demands a full scan when the history home really moves', async () => { + writeBaselineMarker(CUSTOM_HISTORY_HOME) + const store = createStore( + createSettings({ codexSessionSourceHome: { host: CUSTOM_HISTORY_HOME, wsl: {} } }) + ) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe( + false + ) + + store.updateSettings({ + codexSessionSourceHome: { host: 'C:\\Users\\Me\\moved-codex', wsl: {} } + }) + + expect(service.prepareHostSystemDefaultSessionMigrationPass()).toBe(true) + }) +}) diff --git a/src/main/codex/codex-session-backfill-audit.ts b/src/main/codex/codex-session-backfill-audit.ts index 1bec593821a..8f84966f0cb 100644 --- a/src/main/codex/codex-session-backfill-audit.ts +++ b/src/main/codex/codex-session-backfill-audit.ts @@ -1,9 +1,9 @@ import { createHash, randomUUID } from 'node:crypto' -import { createReadStream, type Stats } from 'node:fs' +import type { Stats } from 'node:fs' import { appendFile, mkdir } from 'node:fs/promises' import { dirname } from 'node:path' -import { createInterface } from 'node:readline' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { streamCodexSessionLedgerRecords } from './codex-session-ledger-stream' import type { CodexSessionBackfillSummary } from './codex-session-backfill-types' export type CodexSessionBackfillAuditWriter = (record: Record) => Promise @@ -68,38 +68,23 @@ export async function readCodexSessionBackfillAuditCoverage( diagnosticEventIds: new Set(), hasRunSummary: false } - const input = createReadStream(auditLogPath, { encoding: 'utf-8' }) - const lines = createInterface({ input, crlfDelay: Infinity }) - try { - for await (const raw of lines) { - try { - const parsed: unknown = JSON.parse(raw) - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - continue - } - const record = parsed as Record - coverage.hasRunSummary ||= record.action === 'run-summary' - if ( - typeof record.action === 'string' && - HEAL_AUDIT_ACTIONS.has(record.action) && - typeof record.fileEventId === 'string' - ) { - coverage.fileEventIds.add(record.fileEventId) - } - if ( - typeof record.action === 'string' && - DIAGNOSTIC_AUDIT_ACTIONS.has(record.action) && - typeof record.diagnosticEventId === 'string' - ) { - coverage.diagnosticEventIds.add(record.diagnosticEventId) - } - } catch { - // Torn audit tails are quarantined by the writer's leading newline. - } + for await (const record of streamCodexSessionLedgerRecords(auditLogPath, { + throwOnReadFailure: true + })) { + coverage.hasRunSummary ||= record.action === 'run-summary' + if ( + typeof record.action === 'string' && + HEAL_AUDIT_ACTIONS.has(record.action) && + typeof record.fileEventId === 'string' + ) { + coverage.fileEventIds.add(record.fileEventId) } - } catch (error) { - if (!isNotFoundError(error)) { - throw error + if ( + typeof record.action === 'string' && + DIAGNOSTIC_AUDIT_ACTIONS.has(record.action) && + typeof record.diagnosticEventId === 'string' + ) { + coverage.diagnosticEventIds.add(record.diagnosticEventId) } } return coverage @@ -192,7 +177,3 @@ export async function recordExistingCodexSessionForHeal( ...(fileEventId ? { fileEventId } : {}) }) } - -function isNotFoundError(error: unknown): boolean { - return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' -} diff --git a/src/main/codex/codex-session-backfill-date.ts b/src/main/codex/codex-session-backfill-date.ts index 8c2157d9d61..9cd39e6ddd8 100644 --- a/src/main/codex/codex-session-backfill-date.ts +++ b/src/main/codex/codex-session-backfill-date.ts @@ -1,30 +1,18 @@ import { join, relative, sep } from 'node:path' +import { isCodexSessionBackfillDate } from './codex-session-backfill-scan-dates' import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing' import type { CodexSessionBackfillDate, CodexSessionBackfillOptions } from './codex-session-backfill-types' -export function getCodexSessionBackfillDate(date = new Date()): CodexSessionBackfillDate { - return [ - String(date.getUTCFullYear()).padStart(4, '0'), - String(date.getUTCMonth() + 1).padStart(2, '0'), - String(date.getUTCDate()).padStart(2, '0') - ] -} - export function isCodexSessionRolloutPath(sessionsRoot: string, filePath: string): boolean { const pathParts = relative(sessionsRoot, filePath).split(sep) if (pathParts.length !== 4) { return false } const [year, month, day, fileName] = pathParts - return ( - /^\d{4}$/.test(year) && - /^\d{2}$/.test(month) && - /^\d{2}$/.test(day) && - /^rollout-.+\.jsonl$/.test(fileName) - ) + return isCodexSessionBackfillDate([year, month, day]) && /^rollout-.+\.jsonl$/.test(fileName) } export async function* listCodexSessionBackfillFilesForDates( @@ -54,9 +42,7 @@ function resolveCodexSessionBackfillDateRoots( return [sessionsRoot] } return scanDates - .filter( - ([year, month, day]) => /^\d{4}$/.test(year) && /^\d{2}$/.test(month) && /^\d{2}$/.test(day) - ) + .filter(isCodexSessionBackfillDate) .map(([year, month, day]) => join(sessionsRoot, year, month, day)) } diff --git a/src/main/codex/codex-session-backfill-fs-mocks.ts b/src/main/codex/codex-session-backfill-fs-mocks.ts new file mode 100644 index 00000000000..40f352cc337 --- /dev/null +++ b/src/main/codex/codex-session-backfill-fs-mocks.ts @@ -0,0 +1,117 @@ +import type * as NodeFs from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' + +// Fault-injection doubles for the backfill tests. Kept out of the spec files so +// several suites can drive the same failure modes from one switchboard. + +export const fsMockState = { + failLink: false, + failLinkTransiently: false, + failLinkPermission: false, + raceTargetIntoExistence: false, + failMarkerRm: false, + failMarkerReplacement: false, + failAuditMkdirOnce: false, + failAuditWrites: false, + failMkdirPath: null as string | null, + failDirectoryPath: null as string | null, + failLstatPath: null as string | null +} + +export function resetCodexSessionBackfillFsMocks(): void { + fsMockState.failLink = false + fsMockState.failLinkTransiently = false + fsMockState.failLinkPermission = false + fsMockState.raceTargetIntoExistence = false + fsMockState.failMarkerRm = false + fsMockState.failMarkerReplacement = false + fsMockState.failAuditMkdirOnce = false + fsMockState.failAuditWrites = false + fsMockState.failMkdirPath = null + fsMockState.failDirectoryPath = null + fsMockState.failLstatPath = null +} + +function errnoError(message: string, code: string): NodeJS.ErrnoException { + const error = new Error(message) as NodeJS.ErrnoException + error.code = code + return error +} + +function isMarkerPath(value: unknown): boolean { + return ( + String(value).includes('codex-session-backfill') && + String(value).endsWith('backfill-complete.json') + ) +} + +export function createNodeFsMock(actual: typeof NodeFs): typeof NodeFs { + return { + ...actual, + existsSync: (...args: Parameters) => + args[0] === fsMockState.failLstatPath ? false : actual.existsSync(...args), + rmSync: (...args: Parameters) => { + if (fsMockState.failMarkerRm && isMarkerPath(args[0])) { + throw errnoError('EACCES: marker removal failed', 'EACCES') + } + return actual.rmSync(...args) + }, + renameSync: (...args: Parameters) => { + if (fsMockState.failMarkerReplacement && isMarkerPath(args[1])) { + throw errnoError('EACCES: marker replacement failed', 'EACCES') + } + return actual.renameSync(...args) + } + } +} + +export function createNodeFsPromisesMock(actual: typeof NodeFsPromises): typeof NodeFsPromises { + return { + ...actual, + mkdir: (...args: Parameters) => { + if (args[0] === fsMockState.failMkdirPath) { + throw errnoError('EACCES: target directory inaccessible', 'EACCES') + } + if (fsMockState.failAuditMkdirOnce && String(args[0]).includes('codex-session-backfill')) { + fsMockState.failAuditMkdirOnce = false + throw errnoError('EACCES: transient audit directory failure', 'EACCES') + } + return actual.mkdir(...args) + }, + appendFile: (...args: Parameters) => { + if (fsMockState.failAuditWrites && String(args[0]).includes('codex-session-backfill')) { + throw errnoError('ENOSPC: audit write failed', 'ENOSPC') + } + return actual.appendFile(...args) + }, + lstat: (...args: Parameters) => { + if (args[0] === fsMockState.failLstatPath) { + throw errnoError('EACCES: path inaccessible', 'EACCES') + } + return actual.lstat(...args) + }, + link: async (...args: Parameters) => { + if (fsMockState.raceTargetIntoExistence && String(args[0]).includes('codex-runtime-home')) { + fsMockState.raceTargetIntoExistence = false + await actual.writeFile(args[1], 'concurrent target\n', 'utf-8') + throw errnoError('EEXIST: concurrent target', 'EEXIST') + } + if (fsMockState.failLink && String(args[0]).includes('codex-runtime-home')) { + throw errnoError('EXDEV: cross-device link', 'EXDEV') + } + if (fsMockState.failLinkTransiently && String(args[0]).includes('codex-runtime-home')) { + throw errnoError('EIO: transient hardlink failure', 'EIO') + } + if (fsMockState.failLinkPermission && String(args[0]).includes('codex-runtime-home')) { + throw errnoError('EACCES: hardlink permission denied', 'EACCES') + } + return actual.link(...args) + }, + opendir: (...args: Parameters) => { + if (args[0] === fsMockState.failDirectoryPath) { + throw errnoError('EACCES: directory unreadable', 'EACCES') + } + return actual.opendir(...args) + } + } as typeof NodeFsPromises +} diff --git a/src/main/codex/codex-session-backfill-marker.test.ts b/src/main/codex/codex-session-backfill-marker.test.ts new file mode 100644 index 00000000000..5540bd53a4e --- /dev/null +++ b/src/main/codex/codex-session-backfill-marker.test.ts @@ -0,0 +1,250 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + captureCodexSessionBackfillMarkerGeneration, + hasCompletedCodexSessionBackfillMarker, + markCodexSessionBackfillMarkerPending, + readCodexSessionBackfillBaseline, + writeCodexSessionBackfillMarker +} from './codex-session-backfill-marker' +import { + getCodexSessionBackfillDate, + getCodexSessionBackfillDatesBetween +} from './codex-session-backfill-scan-dates' +import type { + CodexSessionBackfillDate, + CodexSessionBackfillSummary +} from './codex-session-backfill-types' + +const WINDOWS_ROOT = 'C:\\Users\\Me\\.codex\\sessions' +const TODAY = getCodexSessionBackfillDate() +const LAUNCH_DATE: CodexSessionBackfillDate = ['2026', '08', '05'] + +let stateDir: string +let markerPath: string + +function createSummary(scannedFiles = 3): CodexSessionBackfillSummary { + return { + stopped: false, + scannedFiles, + linkedFiles: scannedFiles, + copiedFiles: 0, + skippedExistingFiles: 0, + skippedUnexpectedFiles: 0, + skippedSymlinkFiles: 0, + skippedUnsupportedFilesystemFiles: 0, + failedDirectories: 0, + failedFiles: 0, + failedHealAuditRecords: 0 + } +} + +function writeFullBaseline( + root: string, + options: { coveredScanDates?: readonly CodexSessionBackfillDate[]; retain?: boolean } = {} +): void { + writeCodexSessionBackfillMarker( + markerPath, + root, + createSummary(), + captureCodexSessionBackfillMarkerGeneration(), + { + coverage: 'full', + coveredScanDates: options.coveredScanDates ?? [], + retainPendingScanDates: options.retain + } + ) +} + +/** More dates than MAX_PENDING_SCAN_DATES, so the marker gives up on bounding. */ +function overflowingDates(): CodexSessionBackfillDate[] { + return getCodexSessionBackfillDatesBetween( + new Date(Date.UTC(2026, 6, 1)), + new Date(Date.UTC(2026, 7, 9)) + ) +} + +function readMarker(): Record { + return JSON.parse(readFileSync(markerPath, 'utf-8')) as Record +} + +beforeEach(() => { + stateDir = mkdtempSync(join(tmpdir(), 'orca-codex-marker-')) + markerPath = join(stateDir, 'backfill-complete.json') +}) + +afterEach(() => { + rmSync(stateDir, { recursive: true, force: true }) +}) + +describe('codex session backfill marker', () => { + it('treats Windows spellings of one directory as the same target', () => { + writeFullBaseline(WINDOWS_ROOT) + + for (const alias of ['C:/Users/Me/.codex/sessions', 'c:\\users\\me\\.codex\\sessions']) { + expect(hasCompletedCodexSessionBackfillMarker(markerPath, alias)).toBe(true) + } + expect( + hasCompletedCodexSessionBackfillMarker(markerPath, 'C:\\Users\\Me\\other-codex\\sessions') + ).toBe(false) + }) + + it('reads a legacy v3 marker as a certified baseline with nothing pending', () => { + writeFileSync( + markerPath, + `${JSON.stringify({ + version: 3, + systemSessionsRoot: WINDOWS_ROOT, + completedAt: Date.now(), + summary: { scannedFiles: 12 } + })}\n`, + 'utf-8' + ) + + expect(readCodexSessionBackfillBaseline(markerPath, 'c:/users/me/.codex/sessions')).toEqual({ + pendingScanDates: [] + }) + }) + + it('keeps honoring the v3 empty-source guard', () => { + writeFileSync( + markerPath, + `${JSON.stringify({ + version: 3, + systemSessionsRoot: WINDOWS_ROOT, + summary: { scannedFiles: 0 } + })}\n`, + 'utf-8' + ) + + expect(readCodexSessionBackfillBaseline(markerPath, WINDOWS_ROOT)).toBeNull() + }) + + it('does not treat a bounded pass that scanned nothing as an empty source', () => { + writeFullBaseline(WINDOWS_ROOT) + writeCodexSessionBackfillMarker( + markerPath, + WINDOWS_ROOT, + createSummary(0), + captureCodexSessionBackfillMarkerGeneration(), + { coverage: 'bounded', coveredScanDates: [LAUNCH_DATE] } + ) + + expect(readMarker()).toMatchObject({ baselineScannedFiles: 3 }) + expect(hasCompletedCodexSessionBackfillMarker(markerPath, WINDOWS_ROOT)).toBe(true) + }) + + it('refuses to let a bounded pass invent a baseline it never verified', () => { + writeCodexSessionBackfillMarker( + markerPath, + WINDOWS_ROOT, + createSummary(), + captureCodexSessionBackfillMarkerGeneration(), + { coverage: 'bounded', coveredScanDates: [LAUNCH_DATE] } + ) + + expect(hasCompletedCodexSessionBackfillMarker(markerPath, WINDOWS_ROOT)).toBe(false) + }) + + it('records a launch date without destroying the historical baseline', () => { + writeFullBaseline(WINDOWS_ROOT) + + markCodexSessionBackfillMarkerPending(markerPath, 'C:/Users/Me/.codex/sessions', [LAUNCH_DATE]) + + expect(readMarker()).toMatchObject({ coverage: 'full', pendingScanDates: [LAUNCH_DATE] }) + expect(readCodexSessionBackfillBaseline(markerPath, WINDOWS_ROOT)).toEqual({ + pendingScanDates: [LAUNCH_DATE] + }) + }) + + it('leaves a marker for a different history untouched', () => { + writeFullBaseline(WINDOWS_ROOT) + + markCodexSessionBackfillMarkerPending(markerPath, 'D:\\other\\.codex\\sessions', [LAUNCH_DATE]) + + expect(readMarker()).toMatchObject({ systemSessionsRoot: WINDOWS_ROOT, pendingScanDates: [] }) + }) + + it('keeps a racing launch date pending when an older pass publishes', () => { + writeFullBaseline(WINDOWS_ROOT) + const staleGeneration = captureCodexSessionBackfillMarkerGeneration() + markCodexSessionBackfillMarkerPending(markerPath, WINDOWS_ROOT, [LAUNCH_DATE]) + + writeCodexSessionBackfillMarker(markerPath, WINDOWS_ROOT, createSummary(), staleGeneration, { + coverage: 'bounded', + coveredScanDates: [LAUNCH_DATE] + }) + + expect(readMarker()).toMatchObject({ pendingScanDates: [LAUNCH_DATE] }) + }) + + it('keeps the pane date pending while the pane is still live', () => { + writeFullBaseline(WINDOWS_ROOT, { coveredScanDates: [LAUNCH_DATE], retain: true }) + + expect(readMarker()).toMatchObject({ launchActive: true, pendingScanDates: [LAUNCH_DATE] }) + }) + + it('settles every pending date once a full walk covers them', () => { + writeFullBaseline(WINDOWS_ROOT) + markCodexSessionBackfillMarkerPending(markerPath, WINDOWS_ROOT, [LAUNCH_DATE]) + + writeFullBaseline(WINDOWS_ROOT) + + // The walk looked at every date, so nothing is left to revisit. + expect(readMarker()).toMatchObject({ pendingScanDates: [] }) + }) + + it('demands a full walk once the pending window outgrows its bound', () => { + writeFullBaseline(WINDOWS_ROOT) + + expect( + markCodexSessionBackfillMarkerPending(markerPath, WINDOWS_ROOT, overflowingDates()) + ).toBe(true) + + expect(readMarker()).toMatchObject({ needsFullScan: true, pendingScanDates: [] }) + expect(hasCompletedCodexSessionBackfillMarker(markerPath, WINDOWS_ROOT)).toBe(false) + }) + + it('keeps owing that full walk when the next launch records its own date', () => { + writeFullBaseline(WINDOWS_ROOT) + markCodexSessionBackfillMarkerPending(markerPath, WINDOWS_ROOT, overflowingDates()) + + expect(markCodexSessionBackfillMarkerPending(markerPath, WINDOWS_ROOT, [TODAY])).toBe(true) + + expect(readMarker()).toMatchObject({ needsFullScan: true, pendingScanDates: [] }) + expect(hasCompletedCodexSessionBackfillMarker(markerPath, WINDOWS_ROOT)).toBe(false) + }) + + it('keeps the full-walk demand when a later launch overtook the walk', () => { + writeFullBaseline(WINDOWS_ROOT) + const staleGeneration = captureCodexSessionBackfillMarkerGeneration() + markCodexSessionBackfillMarkerPending(markerPath, WINDOWS_ROOT, overflowingDates()) + + writeCodexSessionBackfillMarker(markerPath, WINDOWS_ROOT, createSummary(), staleGeneration, { + coverage: 'full', + coveredScanDates: [] + }) + + // The walk may have passed those dates before their rollouts existed. + expect(readMarker()).toMatchObject({ needsFullScan: true }) + }) + + it('clears the full-walk demand only once a full walk certifies the tree', () => { + writeFullBaseline(WINDOWS_ROOT) + markCodexSessionBackfillMarkerPending(markerPath, WINDOWS_ROOT, overflowingDates()) + + writeFullBaseline(WINDOWS_ROOT) + + expect(readMarker()).toMatchObject({ needsFullScan: false, pendingScanDates: [] }) + expect(hasCompletedCodexSessionBackfillMarker(markerPath, WINDOWS_ROOT)).toBe(true) + }) + + it('persists a launch date even before any baseline exists', () => { + markCodexSessionBackfillMarkerPending(markerPath, WINDOWS_ROOT, [TODAY]) + + expect(readMarker()).toMatchObject({ coverage: 'bounded', pendingScanDates: [TODAY] }) + expect(hasCompletedCodexSessionBackfillMarker(markerPath, WINDOWS_ROOT)).toBe(false) + }) +}) diff --git a/src/main/codex/codex-session-backfill-marker.ts b/src/main/codex/codex-session-backfill-marker.ts index 8c2c9fb9c50..b802a666980 100644 --- a/src/main/codex/codex-session-backfill-marker.ts +++ b/src/main/codex/codex-session-backfill-marker.ts @@ -1,91 +1,264 @@ import { mkdirSync, readFileSync, rmSync } from 'node:fs' import { dirname } from 'node:path' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { writeFileAtomically } from '../codex-accounts/fs-utils' -import type { CodexSessionBackfillSummary } from './codex-session-backfill-types' +import { + expandCodexSessionBackfillDatesThroughToday, + getCodexSessionBackfillDate, + mergeCodexSessionBackfillDates, + parseCodexSessionBackfillDates, + subtractCodexSessionBackfillDates +} from './codex-session-backfill-scan-dates' +import type { + CodexSessionBackfillDate, + CodexSessionBackfillSummary +} from './codex-session-backfill-types' // Why: bump to re-run the backfill for every host after a layout or semantics // change; the run itself stays skip-existing so re-runs never overwrite. -const CODEX_SESSION_BACKFILL_MARKER_VERSION = 3 +const CODEX_SESSION_BACKFILL_MARKER_VERSION = 4 +// Why: v3 was only ever written after a certified full-tree walk, so it reads +// as a v4 baseline and existing installs never pay one more full scan. +const MARKER_BASELINE_VERSIONS: ReadonlySet = new Set([3, 4]) +// Why: bounds abnormal-exit recovery; past this many dates a full walk is the +// cheaper certainty. +const MAX_PENDING_SCAN_DATES = 31 + let markerInvalidationGeneration = 0 +export type CodexSessionBackfillBaseline = { + /** Dates whose managed rollouts may not be published yet, widened through today. */ + pendingScanDates: readonly CodexSessionBackfillDate[] +} + export function captureCodexSessionBackfillMarkerGeneration(): number { return markerInvalidationGeneration } +/** + * Reads the certified full-history baseline for this target, if one exists. + * + * Null means no usable baseline, which is the only state that justifies a + * full-tree walk. A baseline with pending dates still needs a bounded pass. + */ +export function readCodexSessionBackfillBaseline( + markerPath: string, + systemSessionsRoot: string, + today: CodexSessionBackfillDate = getCodexSessionBackfillDate() +): CodexSessionBackfillBaseline | null { + const record = readMarkerRecord(markerPath) + if (!record?.hasBaseline || !matchesTargetRoot(record, systemSessionsRoot)) { + return null + } + // Why: an empty source can become populated after an early migration run; + // let the incremental async walk verify it without blocking the main thread. + if (record.baselineScannedFiles === 0 || record.needsFullScan) { + return null + } + // Why: only a pane that was still running when the pass ended can have written + // dates nobody recorded — a pane held open across midnight, then force-quit. + const pendingScanDates = record.launchActive + ? expandCodexSessionBackfillDatesThroughToday( + record.pendingScanDates, + today, + MAX_PENDING_SCAN_DATES + ) + : record.pendingScanDates + return pendingScanDates ? { pendingScanDates } : null +} + export function hasCompletedCodexSessionBackfillMarker( markerPath: string, systemSessionsRoot: string ): boolean { - try { - const parsed: unknown = JSON.parse(readFileSync(markerPath, 'utf-8')) - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return false - } - const marker = parsed as { - version?: unknown - systemSessionsRoot?: unknown - summary?: { scannedFiles?: unknown } - } - // Why: changing the configured real Codex home must backfill the new - // target instead of honoring a marker written for a different history. - const markerMatchesTarget = - marker.version === CODEX_SESSION_BACKFILL_MARKER_VERSION && - marker.systemSessionsRoot === systemSessionsRoot - if (!markerMatchesTarget) { - return false - } - // Why: an empty source can become populated after an early migration run; - // let the incremental async walk verify it without blocking the main thread. - return marker.summary?.scannedFiles !== 0 - } catch { - return false - } + return readCodexSessionBackfillBaseline(markerPath, systemSessionsRoot) !== null } export function writeCodexSessionBackfillMarker( markerPath: string, systemSessionsRoot: string, summary: CodexSessionBackfillSummary, - expectedGeneration: number + expectedGeneration: number, + options: { + /** A full pass certifies the whole tree; a bounded one may only extend that. */ + coverage: 'full' | 'bounded' + coveredScanDates: readonly CodexSessionBackfillDate[] + /** A live Codex pane keeps writing into its own date, so it stays pending. */ + retainPendingScanDates?: boolean + } ): void { - // Why: a launch can invalidate this pass before its delayed replacement begins. - if (expectedGeneration !== markerInvalidationGeneration) { + const record = readMarkerRecord(markerPath) + const current = record && matchesTargetRoot(record, systemSessionsRoot) ? record : null + // Why: a date-limited pass cannot certify the dates it never looked at, so + // without an existing baseline it must publish nothing at all. + if (options.coverage !== 'full' && !current?.hasBaseline) { return } - mkdirSync(dirname(markerPath), { recursive: true }) - writeFileAtomically( - markerPath, - `${JSON.stringify( - { - version: CODEX_SESSION_BACKFILL_MARKER_VERSION, - systemSessionsRoot, - completedAt: Date.now(), - summary - }, - null, - 2 - )}\n` - ) + // Why: a launch that began during this pass can have written rollouts the + // walk had already gone past, so its dates must survive as pending. + const generationCurrent = expectedGeneration === markerInvalidationGeneration + const clearCovered = generationCurrent && options.retainPendingScanDates !== true + const currentPendingScanDates = current?.pendingScanDates ?? [] + // Why: a full walk speaks for every date, so it settles the whole pending set + // rather than only the dates a bounded pass was asked to look at. + const coveredScanDates = + options.coverage === 'full' ? currentPendingScanDates : options.coveredScanDates + const pendingScanDates = clearCovered + ? subtractCodexSessionBackfillDates(currentPendingScanDates, coveredScanDates) + : mergeCodexSessionBackfillDates(currentPendingScanDates, options.coveredScanDates) + writeMarkerRecord(markerPath, { + ...current?.raw, + version: CODEX_SESSION_BACKFILL_MARKER_VERSION, + systemSessionsRoot, + // Why: only reached with a baseline in hand, so this records that a baseline + // exists, not the scope of this particular pass. + coverage: 'full', + completedAt: Date.now(), + launchActive: options.retainPendingScanDates === true, + // Why: only a full walk can speak for the whole tree, so a bounded pass + // that legitimately scanned nothing must not read back as an empty source. + baselineScannedFiles: + options.coverage === 'full' ? summary.scannedFiles : current?.baselineScannedFiles, + summary, + // Why: only a full walk that no later launch overtook can retire the demand. + ...describePendingScanDates( + pendingScanDates, + current?.needsFullScan === true && !(generationCurrent && options.coverage === 'full') + ) + }) } -export function invalidateCodexSessionBackfillMarker(markerPath: string): void { +/** + * Records dates a launch may still be writing into, keeping the baseline. + * + * Why not delete: the marker is the only record that the full history was ever + * published. Dropping it makes every launch re-walk the whole sessions tree. + * + * Returns whether a full walk is still owed for this target, so the caller can + * fold that demand into its own in-memory state instead of losing it. + */ +export function markCodexSessionBackfillMarkerPending( + markerPath: string, + systemSessionsRoot: string, + scanDates: readonly CodexSessionBackfillDate[] +): boolean { + // Why: an older in-flight pass must not clear dates recorded after it started. markerInvalidationGeneration += 1 + const record = readMarkerRecord(markerPath) + // Why: a marker for a different history says nothing about this target, so + // only a full walk can certify it. + if (record && !matchesTargetRoot(record, systemSessionsRoot)) { + return true + } + const pendingScanDates = mergeCodexSessionBackfillDates(record?.pendingScanDates, scanDates) + const pending = describePendingScanDates(pendingScanDates, record?.needsFullScan === true) + if (pendingScanDates.length === record?.pendingScanDates.length) { + return record.needsFullScan + } try { - // Why: a managed-lane system-default launch can create new source - // rollouts, so a prior one-time marker must not suppress the next opt-in. - rmSync(markerPath, { force: true }) + writeMarkerRecord(markerPath, { + version: CODEX_SESSION_BACKFILL_MARKER_VERSION, + systemSessionsRoot, + coverage: 'bounded', + // Why: defaults only — an existing record keeps its own version and + // coverage, which is what preserves a v3 marker's implicit baseline. + ...record?.raw, + ...pending + }) } catch (error) { - console.warn('[codex-session-backfill] Failed to invalidate completion marker:', error) + console.warn('[codex-session-backfill] Failed to record pending scan dates:', error) try { - writeFileAtomically( - markerPath, - `${JSON.stringify({ version: 0, invalidatedAt: Date.now() })}\n` - ) + // Why: fail closed — a full rescan costs one slow pass, while a silently + // unrecorded launch date hides its rollouts forever. + rmSync(markerPath, { force: true }) } catch (fallbackError) { throw new AggregateError( [error, fallbackError], - 'Failed to invalidate Codex session backfill marker' + 'Failed to record pending Codex session backfill scan dates' ) } + return true + } + return pending.needsFullScan +} + +type CodexSessionBackfillMarkerRecord = { + raw: Record + systemSessionsRoot: string + hasBaseline: boolean + pendingScanDates: CodexSessionBackfillDate[] + needsFullScan: boolean + launchActive: boolean + /** Files the last full-tree walk saw; a bounded pass must not overwrite it. */ + baselineScannedFiles: number | undefined +} + +function readMarkerRecord(markerPath: string): CodexSessionBackfillMarkerRecord | null { + try { + const parsed: unknown = JSON.parse(readFileSync(markerPath, 'utf-8')) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null + } + const marker = parsed as { + version?: unknown + systemSessionsRoot?: unknown + coverage?: unknown + pendingScanDates?: unknown + needsFullScan?: unknown + launchActive?: unknown + baselineScannedFiles?: unknown + summary?: { scannedFiles?: unknown } + } + if ( + typeof marker.version !== 'number' || + !MARKER_BASELINE_VERSIONS.has(marker.version) || + typeof marker.systemSessionsRoot !== 'string' + ) { + return null + } + const baselineScannedFiles = marker.baselineScannedFiles ?? marker.summary?.scannedFiles + return { + raw: parsed as Record, + systemSessionsRoot: marker.systemSessionsRoot, + // v3 predates `coverage` and was only written after a full-tree walk. + hasBaseline: marker.version === 3 || marker.coverage === 'full', + pendingScanDates: parseCodexSessionBackfillDates(marker.pendingScanDates), + needsFullScan: marker.needsFullScan === true, + launchActive: marker.launchActive === true, + // v3 wrote only one summary, and it was always a full-tree one. + baselineScannedFiles: + typeof baselineScannedFiles === 'number' ? baselineScannedFiles : undefined + } + } catch { + return null } } + +/** Why: changing the configured real Codex home must backfill the new target + * instead of honoring a marker written for a different history — and Windows + * spells one directory several ways, so compare normalized. */ +function matchesTargetRoot( + record: CodexSessionBackfillMarkerRecord, + systemSessionsRoot: string +): boolean { + return ( + normalizeRuntimePathForComparison(record.systemSessionsRoot) === + normalizeRuntimePathForComparison(systemSessionsRoot) + ) +} + +/** Why: an unmet full-scan demand outlives the launch that raised it — only a + * full walk may clear it, or the overflowed dates are never revisited. */ +function describePendingScanDates( + pendingScanDates: readonly CodexSessionBackfillDate[], + stillOwesFullScan: boolean +): { pendingScanDates: readonly CodexSessionBackfillDate[]; needsFullScan: boolean } { + return pendingScanDates.length > MAX_PENDING_SCAN_DATES || stillOwesFullScan + ? { pendingScanDates: [], needsFullScan: true } + : { pendingScanDates, needsFullScan: false } +} + +function writeMarkerRecord(markerPath: string, record: Record): void { + mkdirSync(dirname(markerPath), { recursive: true }) + writeFileAtomically(markerPath, `${JSON.stringify(record, null, 2)}\n`) +} diff --git a/src/main/codex/codex-session-backfill-scan-dates.test.ts b/src/main/codex/codex-session-backfill-scan-dates.test.ts new file mode 100644 index 00000000000..f9bf4530733 --- /dev/null +++ b/src/main/codex/codex-session-backfill-scan-dates.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { + compareCodexSessionBackfillDates, + expandCodexSessionBackfillDatesThroughToday, + getCodexSessionBackfillDate, + getCodexSessionBackfillDatesBetween, + isCodexSessionBackfillDate, + mergeCodexSessionBackfillDates, + parseCodexSessionBackfillDates, + subtractCodexSessionBackfillDates +} from './codex-session-backfill-scan-dates' + +describe('codex session backfill scan dates', () => { + it('reads UTC parts so a local evening never lands on the wrong directory', () => { + expect(getCodexSessionBackfillDate(new Date('2026-08-05T23:59:59Z'))).toEqual([ + '2026', + '08', + '05' + ]) + expect(getCodexSessionBackfillDate(new Date('2026-01-02T00:00:00Z'))).toEqual([ + '2026', + '01', + '02' + ]) + }) + + it('rejects anything that is not a zero-padded YYYY/MM/DD triple', () => { + expect(isCodexSessionBackfillDate(['2026', '08', '05'])).toBe(true) + expect(isCodexSessionBackfillDate(['2026', '8', '05'])).toBe(false) + expect(isCodexSessionBackfillDate(['2026', '08'])).toBe(false) + expect(isCodexSessionBackfillDate('2026-08-05')).toBe(false) + }) + + it('rejects dates the calendar never produced', () => { + expect(isCodexSessionBackfillDate(['2026', '99', '99'])).toBe(false) + expect(isCodexSessionBackfillDate(['2026', '02', '30'])).toBe(false) + expect(isCodexSessionBackfillDate(['2025', '02', '29'])).toBe(false) + expect(isCodexSessionBackfillDate(['2026', '00', '10'])).toBe(false) + expect(isCodexSessionBackfillDate(['2024', '02', '29'])).toBe(true) + expect(isCodexSessionBackfillDate(['2026', '12', '31'])).toBe(true) + }) + + it('merges and subtracts date sets by identity, not by reference', () => { + const merged = mergeCodexSessionBackfillDates( + [ + ['2026', '08', '06'], + ['2026', '08', '05'] + ], + [['2026', '08', '06']], + undefined + ) + + expect(merged).toEqual([ + ['2026', '08', '05'], + ['2026', '08', '06'] + ]) + expect(subtractCodexSessionBackfillDates(merged, [['2026', '08', '05']])).toEqual([ + ['2026', '08', '06'] + ]) + expect(compareCodexSessionBackfillDates(merged[0], merged[1])).toBeLessThan(0) + }) + + it('discards unparseable persisted dates instead of scanning bogus roots', () => { + expect(parseCodexSessionBackfillDates([['2026', '08', '05'], 'nope', ['2026'], null])).toEqual([ + ['2026', '08', '05'] + ]) + expect(parseCodexSessionBackfillDates('not an array')).toEqual([]) + }) + + it('walks every date a launch could have spanned, including across a month end', () => { + expect( + getCodexSessionBackfillDatesBetween( + new Date('2026-07-31T23:00:00Z'), + new Date('2026-08-02T01:00:00Z') + ) + ).toEqual([ + ['2026', '07', '31'], + ['2026', '08', '01'], + ['2026', '08', '02'] + ]) + }) + + it('widens a pending set into the contiguous window that ends today', () => { + expect( + expandCodexSessionBackfillDatesThroughToday([['2026', '08', '05']], ['2026', '08', '07'], 31) + ).toEqual([ + ['2026', '08', '05'], + ['2026', '08', '06'], + ['2026', '08', '07'] + ]) + }) + + it('leaves an empty pending set empty rather than inventing today', () => { + expect(expandCodexSessionBackfillDatesThroughToday([], ['2026', '08', '07'], 31)).toEqual([]) + }) + + it('gives up on a window wider than the bound so a full walk can recertify', () => { + expect( + expandCodexSessionBackfillDatesThroughToday([['2026', '01', '01']], ['2026', '08', '07'], 31) + ).toBeNull() + }) +}) diff --git a/src/main/codex/codex-session-backfill-scan-dates.ts b/src/main/codex/codex-session-backfill-scan-dates.ts new file mode 100644 index 00000000000..ca4e1d311f7 --- /dev/null +++ b/src/main/codex/codex-session-backfill-scan-dates.ts @@ -0,0 +1,112 @@ +import type { CodexSessionBackfillDate } from './codex-session-backfill-types' + +// Set algebra over the UTC date directories that make up a bounded backfill +// pass. Kept apart from the walk itself so the durable marker, the scheduler, +// and the pass all agree on identity, ordering, and range expansion. + +export function getCodexSessionBackfillDate(date = new Date()): CodexSessionBackfillDate { + return [ + String(date.getUTCFullYear()).padStart(4, '0'), + String(date.getUTCMonth() + 1).padStart(2, '0'), + String(date.getUTCDate()).padStart(2, '0') + ] +} + +export function isCodexSessionBackfillDate(value: unknown): value is CodexSessionBackfillDate { + if (!Array.isArray(value) || value.length !== 3) { + return false + } + const key = value.join('-') + // Why: shape alone accepts directories the calendar never produces (2026/99/99 + // from a corrupted marker, 2025/02/29); a UTC round-trip rejects them for free. + // Deliberately no age or future bound — this also gates which managed rollouts + // get published, and a clock-skewed future directory holds real sessions. + return ( + /^\d{4}-\d{2}-\d{2}$/.test(key) && + toCodexSessionBackfillDateKey(getCodexSessionBackfillDate(toUtcDate(value))) === key + ) +} + +export function toCodexSessionBackfillDateKey(date: CodexSessionBackfillDate): string { + return date.join('-') +} + +export function compareCodexSessionBackfillDates( + left: CodexSessionBackfillDate, + right: CodexSessionBackfillDate +): number { + return toCodexSessionBackfillDateKey(left).localeCompare(toCodexSessionBackfillDateKey(right)) +} + +/** Deduplicated ascending union; invalid entries are dropped. */ +export function mergeCodexSessionBackfillDates( + ...groups: readonly (readonly CodexSessionBackfillDate[] | undefined)[] +): CodexSessionBackfillDate[] { + const merged = new Map() + for (const group of groups) { + for (const date of group ?? []) { + if (isCodexSessionBackfillDate(date)) { + merged.set(toCodexSessionBackfillDateKey(date), date) + } + } + } + return [...merged.values()].sort(compareCodexSessionBackfillDates) +} + +export function subtractCodexSessionBackfillDates( + dates: readonly CodexSessionBackfillDate[], + removed: readonly CodexSessionBackfillDate[] +): CodexSessionBackfillDate[] { + const removedKeys = new Set(removed.map(toCodexSessionBackfillDateKey)) + return dates.filter((date) => !removedKeys.has(toCodexSessionBackfillDateKey(date))) +} + +/** Reads persisted marker dates; anything unrecognized is discarded. */ +export function parseCodexSessionBackfillDates(value: unknown): CodexSessionBackfillDate[] { + return Array.isArray(value) + ? mergeCodexSessionBackfillDates(value.filter(isCodexSessionBackfillDate)) + : [] +} + +export function getCodexSessionBackfillDatesBetween( + startedAt: Date, + finishedAt: Date +): CodexSessionBackfillDate[] { + const dates: CodexSessionBackfillDate[] = [] + const cursor = toUtcMidnight(startedAt) + const last = toUtcMidnight(finishedAt) + while (cursor <= last) { + dates.push(getCodexSessionBackfillDate(cursor)) + cursor.setUTCDate(cursor.getUTCDate() + 1) + } + return dates +} + +/** + * Widens a pending set into the contiguous range that ends at `today`. + * + * Why: an abnormal exit or a pane held open across midnight leaves activity on + * dates nobody got to record. The gap between the oldest pending date and today + * is the smallest window that provably contains them. Returns null once that + * window outgrows `maxDates`, where a full walk is the cheaper certainty. + */ +export function expandCodexSessionBackfillDatesThroughToday( + dates: readonly CodexSessionBackfillDate[], + today: CodexSessionBackfillDate, + maxDates: number +): CodexSessionBackfillDate[] | null { + if (dates.length === 0) { + return [] + } + const bounds = mergeCodexSessionBackfillDates(dates, [today]) + const range = getCodexSessionBackfillDatesBetween(toUtcDate(bounds[0]), toUtcDate(bounds.at(-1)!)) + return range.length > maxDates ? null : range +} + +function toUtcDate([year, month, day]: readonly string[]): Date { + return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day))) +} + +function toUtcMidnight(date: Date): Date { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())) +} diff --git a/src/main/codex/codex-session-backfill-types.ts b/src/main/codex/codex-session-backfill-types.ts index 92891beaaff..bf830419a0b 100644 --- a/src/main/codex/codex-session-backfill-types.ts +++ b/src/main/codex/codex-session-backfill-types.ts @@ -26,12 +26,12 @@ export type CodexSessionBackfillOptions = CodexSessionBridgeIncrementalOptions & shouldStop?: () => boolean /** Limits a launch-triggered pass to the date directories that can contain its rollouts. */ scanDates?: readonly CodexSessionBackfillDate[] - /** A scheduled launch pass must not be suppressed by a marker it just invalidated. */ + /** Forces recertification of the whole tree, ignoring any existing baseline. */ + fullScanRequired?: boolean + /** A scheduled launch pass runs even when the baseline reports nothing pending. */ ignoreCompletionMarker?: boolean - /** Active launch passes defer global completion until their final exit scan. */ - writeCompletionMarker?: boolean - /** Final launch scans can extend a previously certified full-tree baseline. */ - writeBoundedCompletionMarker?: boolean + /** A live Codex pane keeps writing into its own date, so it stays pending. */ + retainPendingScanDates?: boolean /** Rechecks launch scheduling state immediately before marker publication. */ canWriteCompletionMarker?: () => boolean } diff --git a/src/main/codex/codex-session-backfill.test.ts b/src/main/codex/codex-session-backfill.test.ts index 418ef0d8381..a3dff66c6f0 100644 --- a/src/main/codex/codex-session-backfill.test.ts +++ b/src/main/codex/codex-session-backfill.test.ts @@ -21,129 +21,16 @@ const { homedirMock } = vi.hoisted(() => ({ homedirMock: vi.fn<() => string>() })) -const { fsMockState } = vi.hoisted(() => ({ - fsMockState: { - failLink: false, - failLinkTransiently: false, - failLinkPermission: false, - raceTargetIntoExistence: false, - failMarkerRm: false, - failMarkerReplacement: false, - failAuditMkdirOnce: false, - failAuditWrites: false, - failMkdirPath: null as string | null, - failDirectoryPath: null as string | null, - failLstatPath: null as string | null - } -})) - vi.mock('node:fs', async () => { - const actual = await vi.importActual('node:fs') - return { - ...actual, - existsSync: (...args: Parameters) => { - if (args[0] === fsMockState.failLstatPath) { - return false - } - return actual.existsSync(...args) - }, - rmSync: (...args: Parameters) => { - if ( - fsMockState.failMarkerRm && - String(args[0]).includes('codex-session-backfill') && - String(args[0]).endsWith('backfill-complete.json') - ) { - const error = new Error('EACCES: marker removal failed') as NodeJS.ErrnoException - error.code = 'EACCES' - throw error - } - return actual.rmSync(...args) - }, - renameSync: (...args: Parameters) => { - if ( - fsMockState.failMarkerReplacement && - String(args[1]).includes('codex-session-backfill') && - String(args[1]).endsWith('backfill-complete.json') - ) { - const error = new Error('EACCES: marker replacement failed') as NodeJS.ErrnoException - error.code = 'EACCES' - throw error - } - return actual.renameSync(...args) - } - } + const mocks = await import('./codex-session-backfill-fs-mocks') + return mocks.createNodeFsMock(await vi.importActual('node:fs')) }) vi.mock('node:fs/promises', async () => { - const actual = await vi.importActual('node:fs/promises') - return { - ...actual, - mkdir: (...args: Parameters) => { - if (args[0] === fsMockState.failMkdirPath) { - const error = new Error('EACCES: target directory inaccessible') as NodeJS.ErrnoException - error.code = 'EACCES' - throw error - } - if (fsMockState.failAuditMkdirOnce && String(args[0]).includes('codex-session-backfill')) { - fsMockState.failAuditMkdirOnce = false - const error = new Error( - 'EACCES: transient audit directory failure' - ) as NodeJS.ErrnoException - error.code = 'EACCES' - throw error - } - return actual.mkdir(...args) - }, - appendFile: (...args: Parameters) => { - if (fsMockState.failAuditWrites && String(args[0]).includes('codex-session-backfill')) { - const error = new Error('ENOSPC: audit write failed') as NodeJS.ErrnoException - error.code = 'ENOSPC' - throw error - } - return actual.appendFile(...args) - }, - lstat: (...args: Parameters) => { - if (args[0] === fsMockState.failLstatPath) { - const error = new Error('EACCES: path inaccessible') as NodeJS.ErrnoException - error.code = 'EACCES' - throw error - } - return actual.lstat(...args) - }, - link: async (...args: Parameters) => { - if (fsMockState.raceTargetIntoExistence && String(args[0]).includes('codex-runtime-home')) { - fsMockState.raceTargetIntoExistence = false - await actual.writeFile(args[1], 'concurrent target\n', 'utf-8') - const error = new Error('EEXIST: concurrent target') as NodeJS.ErrnoException - error.code = 'EEXIST' - throw error - } - if (fsMockState.failLink && String(args[0]).includes('codex-runtime-home')) { - const error = new Error('EXDEV: cross-device link') as NodeJS.ErrnoException - error.code = 'EXDEV' - throw error - } - if (fsMockState.failLinkTransiently && String(args[0]).includes('codex-runtime-home')) { - const error = new Error('EIO: transient hardlink failure') as NodeJS.ErrnoException - error.code = 'EIO' - throw error - } - if (fsMockState.failLinkPermission && String(args[0]).includes('codex-runtime-home')) { - const error = new Error('EACCES: hardlink permission denied') as NodeJS.ErrnoException - error.code = 'EACCES' - throw error - } - return actual.link(...args) - }, - opendir: (...args: Parameters) => { - if (args[0] === fsMockState.failDirectoryPath) { - const error = new Error('EACCES: directory unreadable') as NodeJS.ErrnoException - error.code = 'EACCES' - throw error - } - return actual.opendir(...args) - } - } + const mocks = await import('./codex-session-backfill-fs-mocks') + return mocks.createNodeFsPromisesMock( + await vi.importActual('node:fs/promises') + ) }) vi.mock('node:os', async () => { @@ -159,7 +46,15 @@ import { resolveCodexSessionBackfillPaths, startCodexSessionBackfillInBackground } from './codex-session-backfill' -import { invalidateCodexSessionBackfillMarker } from './codex-session-backfill-marker' +import { + markCodexSessionBackfillMarkerPending, + readCodexSessionBackfillBaseline +} from './codex-session-backfill-marker' +import { fsMockState, resetCodexSessionBackfillFsMocks } from './codex-session-backfill-fs-mocks' +import { getCodexSessionBackfillDate } from './codex-session-backfill-scan-dates' +import type { CodexSessionBackfillDate } from './codex-session-backfill-types' + +const FIXTURE_LAUNCH_DATE: CodexSessionBackfillDate = ['2026', '05', '26'] let fakeHomeDir: string let userDataDir: string @@ -212,18 +107,21 @@ function readAuditActions(): string[] { return readBackfillAuditRecords().map((record) => record.action) } +/** Stands in for a Codex pane launch: records its date without dropping the baseline. */ +function markLaunchPending(...scanDates: CodexSessionBackfillDate[]): void { + markCodexSessionBackfillMarkerPending( + getMarkerPath(), + getSystemSessionsRoot(), + scanDates.length > 0 ? scanDates : [FIXTURE_LAUNCH_DATE] + ) +} + +function readMarker(): Record { + return JSON.parse(readFileSync(getMarkerPath(), 'utf-8')) as Record +} + beforeEach(() => { - fsMockState.failLink = false - fsMockState.failLinkTransiently = false - fsMockState.failLinkPermission = false - fsMockState.raceTargetIntoExistence = false - fsMockState.failMarkerRm = false - fsMockState.failMarkerReplacement = false - fsMockState.failAuditMkdirOnce = false - fsMockState.failAuditWrites = false - fsMockState.failMkdirPath = null - fsMockState.failDirectoryPath = null - fsMockState.failLstatPath = null + resetCodexSessionBackfillFsMocks() fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-backfill-home-')) userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-backfill-user-data-')) previousUserDataPath = process.env.ORCA_USER_DATA_PATH @@ -557,18 +455,48 @@ describe('startCodexSessionBackfillInBackground', () => { expect(existsSync(getMarkerPath())).toBe(false) }) - it('defers completion while a launch lease is active', async () => { - writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + it('certifies the historical baseline while a launch lease is still active', async () => { + const today = getCodexSessionBackfillDate() + writeManagedSession(join('2026', '05', '26', 'rollout-history.jsonl'), 'history\n') + writeManagedSession(join(...today, 'rollout-live.jsonl'), 'live\n') + markLaunchPending(today) const active = await startCodexSessionBackfillInBackground({ - writeCompletionMarker: false + ignoreCompletionMarker: true, + retainPendingScanDates: true + }) + + expect(active).toMatchObject({ scannedFiles: 2, linkedFiles: 2 }) + // The baseline is certified despite the open pane; only its date stays pending. + expect(readMarker()).toMatchObject({ + version: 4, + coverage: 'full', + launchActive: true, + pendingScanDates: [today] + }) + expect(readCodexSessionBackfillBaseline(getMarkerPath(), getSystemSessionsRoot())).toEqual({ + pendingScanDates: [today] }) - expect(active).toMatchObject({ linkedFiles: 1 }) - expect(existsSync(getMarkerPath())).toBe(false) const completed = await startCodexSessionBackfillInBackground() - expect(completed).toMatchObject({ skippedExistingFiles: 1 }) - expect(existsSync(getMarkerPath())).toBe(true) + expect(completed).toMatchObject({ scannedFiles: 1, skippedExistingFiles: 1 }) + expect(readMarker()).toMatchObject({ pendingScanDates: [], launchActive: false }) + expect(await startCodexSessionBackfillInBackground()).toBeNull() + }) + + it('scans only the current date once a baseline exists', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + await startCodexSessionBackfillInBackground() + // A second date directory: a full walk would report two scanned files. + writeManagedSession(join('2026', '06', '02', 'rollout-other.jsonl'), '{"id":"other"}\n') + markLaunchPending() + + const scanned = await startCodexSessionBackfillInBackground({ + ignoreCompletionMarker: true + }) + + // No scanDates were requested, so only the recorded pending date is walked. + expect(scanned).toMatchObject({ scannedFiles: 1, skippedExistingFiles: 1 }) }) it('rechecks launch state before publishing completion', async () => { @@ -582,54 +510,93 @@ describe('startCodexSessionBackfillInBackground', () => { expect(existsSync(getMarkerPath())).toBe(false) }) - it('keeps an invalidated active pass from recreating the completion marker', async () => { + it('keeps a racing launch date pending without discarding the baseline', async () => { writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') - let invalidated = false + let raceStarted = false const raced = await startCodexSessionBackfillInBackground({ shouldStop: () => { - if (!invalidated) { - invalidated = true - invalidateCodexSessionBackfillMarker(getMarkerPath()) + if (!raceStarted) { + raceStarted = true + markLaunchPending(['2026', '08', '05']) } return false } }) expect(raced).toMatchObject({ linkedFiles: 1, stopped: false }) - expect(existsSync(getMarkerPath())).toBe(false) + // The full walk still certifies history, but the racing launch's date stays pending. + expect(readMarker()).toMatchObject({ + version: 4, + coverage: 'full', + pendingScanDates: [['2026', '08', '05']] + }) const racedAudit = readFileSync(getAuditLogPath(), 'utf-8') + writeManagedSession(join('2026', '08', '05', 'rollout-raced.jsonl'), '{"id":"raced"}\n') const recovered = await startCodexSessionBackfillInBackground() - expect(recovered).toMatchObject({ skippedExistingFiles: 1, failedHealAuditRecords: 0 }) - expect(existsSync(getMarkerPath())).toBe(true) - expect(readFileSync(getAuditLogPath(), 'utf-8')).toBe(racedAudit) + expect(recovered).toMatchObject({ scannedFiles: 1, linkedFiles: 1 }) + expect(readMarker()).toMatchObject({ pendingScanDates: [] }) + expect(readFileSync(getAuditLogPath(), 'utf-8')).not.toBe(racedAudit) }) - it('replaces a stale marker when direct removal fails', async () => { + it('falls back to a full rescan when the pending rewrite fails', async () => { writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') await startCodexSessionBackfillInBackground() - fsMockState.failMarkerRm = true + fsMockState.failMarkerReplacement = true + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - invalidateCodexSessionBackfillMarker(getMarkerPath()) + markLaunchPending(['2026', '08', '05']) - expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 0 }) + // Fail closed: no marker at all beats a marker missing the launch's date. + expect(existsSync(getMarkerPath())).toBe(false) + fsMockState.failMarkerReplacement = false const recovered = await startCodexSessionBackfillInBackground() expect(recovered).toMatchObject({ skippedExistingFiles: 1 }) - expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 3 }) + expect(readMarker()).toMatchObject({ version: 4, coverage: 'full' }) + expect(warnSpy).toHaveBeenCalled() + warnSpy.mockRestore() }) - it('fails launch preparation when a stale marker cannot be invalidated', async () => { + it('fails launch preparation when the baseline can neither be updated nor cleared', async () => { writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') await startCodexSessionBackfillInBackground() fsMockState.failMarkerRm = true fsMockState.failMarkerReplacement = true + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - expect(() => invalidateCodexSessionBackfillMarker(getMarkerPath())).toThrow( - 'Failed to invalidate Codex session backfill marker' + expect(() => markLaunchPending(['2026', '08', '05'])).toThrow( + 'Failed to record pending Codex session backfill scan dates' ) - expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 3 }) + expect(readMarker()).toMatchObject({ version: 4 }) + warnSpy.mockRestore() + }) + + it('reads a legacy v3 marker as a certified baseline', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + mkdirSync(dirname(getMarkerPath()), { recursive: true }) + writeFileSync( + getMarkerPath(), + `${JSON.stringify({ + version: 3, + systemSessionsRoot: getSystemSessionsRoot(), + completedAt: Date.now(), + summary: { scannedFiles: 1 } + })}\n`, + 'utf-8' + ) + + // No full walk on upgrade: the v3 baseline is honored as-is. + expect(await startCodexSessionBackfillInBackground()).toBeNull() + expect(existsSync(join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl'))).toBe( + false + ) + + markLaunchPending() + const bounded = await startCodexSessionBackfillInBackground() + expect(bounded).toMatchObject({ scannedFiles: 1, linkedFiles: 1 }) + expect(readMarker()).toMatchObject({ version: 4, coverage: 'full' }) }) it('writes a completion marker and skips the walk on later runs', async () => { @@ -638,7 +605,7 @@ describe('startCodexSessionBackfillInBackground', () => { const first = await startCodexSessionBackfillInBackground() expect(first).toMatchObject({ linkedFiles: 1, failedFiles: 0 }) expect(existsSync(getMarkerPath())).toBe(true) - expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 3 }) + expect(readMarker()).toMatchObject({ version: 4, coverage: 'full' }) // An ordinary call remains a no-op; only a launch-scheduled pass bypasses the marker. writeManagedSession(join('2026', '07', '01', 'rollout-later.jsonl'), '{"id":"later"}\n') @@ -655,11 +622,7 @@ describe('startCodexSessionBackfillInBackground', () => { expect(scheduled).toMatchObject({ scannedFiles: 1, linkedFiles: 1 }) }) - it('does not let a bounded pass certify older unscanned history', async () => { - writeManagedSession(join('2026', '05', '26', 'rollout-baseline.jsonl'), 'baseline\n') - await startCodexSessionBackfillInBackground() - - invalidateCodexSessionBackfillMarker(getMarkerPath()) + it('does not let a bounded pass certify history no baseline ever covered', async () => { const missedRelativePath = join('2026', '06', '01', 'rollout-missed.jsonl') writeManagedSession(missedRelativePath, 'missed\n') writeManagedSession(join('2026', '08', '05', 'rollout-launch.jsonl'), 'launch\n') @@ -672,26 +635,61 @@ describe('startCodexSessionBackfillInBackground', () => { expect(existsSync(getMarkerPath())).toBe(false) const recovered = await startCodexSessionBackfillInBackground() - expect(recovered).toMatchObject({ scannedFiles: 3, linkedFiles: 1 }) + expect(recovered).toMatchObject({ scannedFiles: 2, linkedFiles: 1 }) expect(existsSync(join(getSystemSessionsRoot(), missedRelativePath))).toBe(true) - expect(existsSync(getMarkerPath())).toBe(true) + expect(readMarker()).toMatchObject({ version: 4, coverage: 'full' }) }) - it('lets an explicit bounded final pass restore a certified baseline', async () => { + it('lets a bounded launch pass extend a certified baseline', async () => { writeManagedSession(join('2026', '05', '26', 'rollout-baseline.jsonl'), 'baseline\n') await startCodexSessionBackfillInBackground() - invalidateCodexSessionBackfillMarker(getMarkerPath()) + markLaunchPending(['2026', '08', '05']) writeManagedSession(join('2026', '08', '05', 'rollout-launch.jsonl'), 'launch\n') const bounded = await startCodexSessionBackfillInBackground({ ignoreCompletionMarker: true, - scanDates: [['2026', '08', '05']], - writeBoundedCompletionMarker: true + scanDates: [['2026', '08', '05']] }) expect(bounded).toMatchObject({ scannedFiles: 1, linkedFiles: 1 }) - expect(existsSync(getMarkerPath())).toBe(true) + expect(readMarker()).toMatchObject({ coverage: 'full', pendingScanDates: [] }) + expect(await startCodexSessionBackfillInBackground()).toBeNull() + }) + + it('recovers a bounded window after an abnormal exit instead of a full walk', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-baseline.jsonl'), 'baseline\n') + await startCodexSessionBackfillInBackground() + + // A launch records its date, then the app dies before any pass runs. + markLaunchPending(['2026', '08', '05']) + writeManagedSession(join('2026', '06', '01', 'rollout-untouched.jsonl'), 'untouched\n') + writeManagedSession(join('2026', '08', '05', 'rollout-launch.jsonl'), 'launch\n') + + const recovered = await startCodexSessionBackfillInBackground() + + expect(recovered).toMatchObject({ scannedFiles: 1, linkedFiles: 1 }) + expect( + existsSync(join(getSystemSessionsRoot(), '2026', '08', '05', 'rollout-launch.jsonl')) + ).toBe(true) + }) + + it('widens recovery across midnight when a pane was still live', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-baseline.jsonl'), 'baseline\n') + await startCodexSessionBackfillInBackground() + const launchDate = getCodexSessionBackfillDate(new Date(Date.now() - 2 * 24 * 60 * 60 * 1000)) + await startCodexSessionBackfillInBackground({ + scanDates: [launchDate], + ignoreCompletionMarker: true, + retainPendingScanDates: true + }) + + const baseline = readCodexSessionBackfillBaseline(getMarkerPath(), getSystemSessionsRoot()) + + // The pane could have written on every date from its launch through today. + expect(baseline?.pendingScanDates).toHaveLength(3) + expect(baseline?.pendingScanDates.at(0)).toEqual(launchDate) + expect(baseline?.pendingScanDates.at(-1)).toEqual(getCodexSessionBackfillDate()) }) it('records a new heal event when a linked rollout grows in place', async () => { @@ -700,7 +698,7 @@ describe('startCodexSessionBackfillInBackground', () => { await startCodexSessionBackfillInBackground() const firstRecord = readBackfillAuditRecords().find((record) => record.action === 'hardlink') - invalidateCodexSessionBackfillMarker(getMarkerPath()) + markLaunchPending() appendFileSync(managedPath, '{"event":"later"}\n', 'utf-8') await startCodexSessionBackfillInBackground() @@ -720,7 +718,7 @@ describe('startCodexSessionBackfillInBackground', () => { const firstAudit = readFileSync(getAuditLogPath(), 'utf-8') for (let pass = 0; pass < 2; pass += 1) { - invalidateCodexSessionBackfillMarker(getMarkerPath()) + markLaunchPending() const repeated = await startCodexSessionBackfillInBackground() expect(repeated).toMatchObject({ linkedFiles: 0, @@ -745,14 +743,15 @@ describe('startCodexSessionBackfillInBackground', () => { writeManagedSession(firstRelativePath, '{"id":"a"}\n') await startCodexSessionBackfillInBackground() - invalidateCodexSessionBackfillMarker(getMarkerPath()) + markLaunchPending() writeManagedSession(secondRelativePath, '{"id":"b"}\n') fsMockState.failAuditWrites = true const interrupted = await startCodexSessionBackfillInBackground() expect(interrupted).toMatchObject({ linkedFiles: 1, failedHealAuditRecords: 1 }) - expect(existsSync(getMarkerPath())).toBe(false) + // The failed pass certifies nothing, so its date stays queued for the retry. + expect(readMarker()).toMatchObject({ pendingScanDates: [FIXTURE_LAUNCH_DATE] }) expect( readBackfillAuditRecords().filter((record) => ['hardlink', 'copy', 'existing'].includes(record.action) @@ -810,7 +809,7 @@ describe('startCodexSessionBackfillInBackground', () => { rmSync(getMarkerPath(), { recursive: true }) const resumed = await startCodexSessionBackfillInBackground() expect(resumed).toMatchObject({ skippedExistingFiles: 1, failedHealAuditRecords: 0 }) - expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 3 }) + expect(readMarker()).toMatchObject({ version: 4 }) expect(warnSpy).toHaveBeenCalled() warnSpy.mockRestore() }) @@ -841,7 +840,7 @@ describe('startCodexSessionBackfillInBackground', () => { expect(existsSync(getMarkerPath())).toBe(true) const firstAudit = readFileSync(getAuditLogPath(), 'utf-8') - invalidateCodexSessionBackfillMarker(getMarkerPath()) + markLaunchPending() const repeated = await startCodexSessionBackfillInBackground() expect(repeated).toMatchObject({ skippedUnsupportedFilesystemFiles: 1, failedFiles: 0 }) diff --git a/src/main/codex/codex-session-backfill.ts b/src/main/codex/codex-session-backfill.ts index 720469cf2b0..b6a8b5a07f6 100644 --- a/src/main/codex/codex-session-backfill.ts +++ b/src/main/codex/codex-session-backfill.ts @@ -17,10 +17,16 @@ import { } from './codex-session-backfill-date' import { captureCodexSessionBackfillMarkerGeneration, - hasCompletedCodexSessionBackfillMarker, - writeCodexSessionBackfillMarker as writeBackfillMarker + readCodexSessionBackfillBaseline, + writeCodexSessionBackfillMarker as writeBackfillMarker, + type CodexSessionBackfillBaseline } from './codex-session-backfill-marker' +import { + getCodexSessionBackfillDate, + mergeCodexSessionBackfillDates +} from './codex-session-backfill-scan-dates' import type { + CodexSessionBackfillDate, CodexSessionBackfillOptions, CodexSessionBackfillPaths, CodexSessionBackfillSummary @@ -88,30 +94,61 @@ async function runCodexSessionBackfillOncePerHost( ): Promise { const paths = resolveCodexSessionBackfillPaths(systemCodexHomePathOverride) const markerGeneration = captureCodexSessionBackfillMarkerGeneration() - if ( - !options.ignoreCompletionMarker && - hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot) - ) { + const baseline = readCodexSessionBackfillBaseline(paths.markerPath, paths.systemSessionsRoot) + const scanPlan = resolveCodexSessionBackfillScanPlan(baseline, options) + if (!scanPlan) { return null } - const summary = await backfillManagedCodexSessionsIntoSystemHome(paths, options) - // Why: file or heal-queue failures leave the marker unset so the next + const summary = await backfillManagedCodexSessionsIntoSystemHome(paths, { + ...options, + scanDates: scanPlan.scanDates + }) + // Why: file or heal-queue failures leave the pass uncertified so the next // startup retries; skip-existing keeps those retries cheap. if ( !summary.stopped && options.shouldStop?.() !== true && - options.writeCompletionMarker !== false && options.canWriteCompletionMarker?.() !== false && - (options.scanDates === undefined || options.writeBoundedCompletionMarker === true) && summary.failedFiles === 0 && summary.failedDirectories === 0 && summary.failedHealAuditRecords === 0 ) { - writeBackfillMarker(paths.markerPath, paths.systemSessionsRoot, summary, markerGeneration) + writeBackfillMarker(paths.markerPath, paths.systemSessionsRoot, summary, markerGeneration, { + coverage: scanPlan.scanDates ? 'bounded' : 'full', + coveredScanDates: scanPlan.scanDates ?? [], + retainPendingScanDates: options.retainPendingScanDates === true + }) } return summary } +/** + * Decides how much of the sessions tree this pass must walk. + * + * Null means the baseline already covers everything and there is nothing + * pending; an absent `scanDates` means a full walk, which is only ever needed + * when no certified baseline exists (or the caller demands recertification). + */ +function resolveCodexSessionBackfillScanPlan( + baseline: CodexSessionBackfillBaseline | null, + options: CodexSessionBackfillOptions +): { scanDates?: readonly CodexSessionBackfillDate[] } | null { + const requestedScanDates = options.scanDates?.length ? options.scanDates : undefined + if (options.fullScanRequired) { + return {} + } + if (!baseline) { + return { scanDates: requestedScanDates } + } + const scanDates = mergeCodexSessionBackfillDates(baseline.pendingScanDates, requestedScanDates) + if (scanDates.length > 0) { + return { scanDates } + } + // Why: a launch-scheduled pass exists to publish rollouts the running pane is + // creating right now, so with a baseline in hand the current date is enough. + return options.ignoreCompletionMarker ? { scanDates: [getCodexSessionBackfillDate()] } : null +} + /** * Backfills managed-home session rollout files into the real Codex home. * diff --git a/src/main/codex/codex-session-index-heal-state.test.ts b/src/main/codex/codex-session-index-heal-state.test.ts new file mode 100644 index 00000000000..92270911dfe --- /dev/null +++ b/src/main/codex/codex-session-index-heal-state.test.ts @@ -0,0 +1,154 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { appendFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + CODEX_SESSION_INDEX_HEAL_VERSION, + appendHealLedgerRecord, + collectPendingHealThreads, + isHealMarkerCurrent, + writeHealMarker, + type CodexSessionIndexHealPaths +} from './codex-session-index-heal-state' + +const WINDOWS_SESSIONS_ROOT = 'C:\\Users\\Me\\.codex\\sessions' +const THREAD_ID = '019f0000-1111-7222-8333-000000000001' + +let tempRoots: string[] = [] + +afterEach(() => { + for (const root of tempRoots) { + rmSync(root, { recursive: true, force: true }) + } + tempRoots = [] +}) + +function createPaths(systemSessionsRoot = WINDOWS_SESSIONS_ROOT): CodexSessionIndexHealPaths { + const stateDir = mkdtempSync(join(tmpdir(), 'orca-codex-heal-state-')) + tempRoots.push(stateDir) + return { + auditLogPath: join(stateDir, 'audit.jsonl'), + systemSessionsRoot, + healLedgerPath: join(stateDir, 'index-heal-ledger.jsonl'), + healMarkerPath: join(stateDir, 'index-heal-complete.json') + } +} + +function appendAuditRecord(paths: CodexSessionIndexHealPaths, target: string, recordId: string) { + // Mirrors the real writer's leading newline, which quarantines a torn tail. + appendFileSync( + paths.auditLogPath, + `\n${JSON.stringify({ action: 'hardlink', source: '/managed/x.jsonl', target, recordId })}\n` + ) +} + +function windowsRolloutTarget(root: string): string { + return `${root}\\2026\\07\\01\\rollout-2026-07-01T10-00-00-${THREAD_ID}.jsonl` +} + +describe('codex session index heal state', () => { + it('keeps the heal marker current across Windows spellings of one target', () => { + const paths = createPaths() + writeHealMarker(paths, 42, { healedThreads: 1, missingThreads: 0, failedThreads: 0 }) + + for (const alias of ['C:/Users/Me/.codex/sessions', 'c:\\users\\me\\.codex\\sessions']) { + expect(isHealMarkerCurrent({ ...paths, systemSessionsRoot: alias }, 42)).toBe(true) + } + }) + + it('still re-heals when the real target path actually changes', () => { + const paths = createPaths() + writeHealMarker(paths, 42, { healedThreads: 1, missingThreads: 0, failedThreads: 0 }) + + expect( + isHealMarkerCurrent( + { ...paths, systemSessionsRoot: 'C:\\Users\\Me\\moved-codex\\sessions' }, + 42 + ) + ).toBe(false) + }) + + it('treats a heal record as processed regardless of how its root was spelled', async () => { + const paths = createPaths() + appendAuditRecord(paths, windowsRolloutTarget(WINDOWS_SESSIONS_ROOT), 'audit-1') + appendHealLedgerRecord( + { ...paths, systemSessionsRoot: 'c:/users/me/.codex/sessions' }, + THREAD_ID, + 'healed', + 'audit-1' + ) + + expect(await collectPendingHealThreads(paths)).toEqual([]) + }) + + it('re-heals a thread whose record belongs to a different real home', async () => { + const paths = createPaths() + appendAuditRecord(paths, windowsRolloutTarget(WINDOWS_SESSIONS_ROOT), 'audit-1') + appendHealLedgerRecord( + { ...paths, systemSessionsRoot: 'C:\\Users\\Me\\moved-codex\\sessions' }, + THREAD_ID, + 'healed', + 'audit-1' + ) + + expect(await collectPendingHealThreads(paths)).toEqual([ + expect.objectContaining({ threadId: THREAD_ID, auditRecordId: 'audit-1' }) + ]) + }) + + it('re-queues a thread when a later publication event supersedes a healed one', async () => { + const paths = createPaths() + appendAuditRecord(paths, windowsRolloutTarget(WINDOWS_SESSIONS_ROOT), 'audit-1') + appendHealLedgerRecord(paths, THREAD_ID, 'healed', 'audit-1') + appendAuditRecord(paths, windowsRolloutTarget(WINDOWS_SESSIONS_ROOT), 'audit-2') + + expect(await collectPendingHealThreads(paths)).toEqual([ + expect.objectContaining({ threadId: THREAD_ID, auditRecordId: 'audit-2' }) + ]) + }) + + it('leaves the main thread free while walking a large audit ledger', async () => { + const paths = createPaths() + for (let index = 0; index < 20_000; index += 1) { + appendAuditRecord( + paths, + `${WINDOWS_SESSIONS_ROOT}\\2026\\07\\01\\rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-${String(index).padStart(12, '0')}.jsonl`, + `audit-${index}` + ) + } + let ticks = 0 + const ticker = setInterval(() => { + ticks += 1 + }, 1) + + const pending = await collectPendingHealThreads(paths) + clearInterval(ticker) + + expect(pending).toHaveLength(20_000) + // A blocking readFileSync + whole-file JSON.parse would starve every timer. + expect(ticks).toBeGreaterThan(0) + }) + + it('refuses to treat an unreadable audit ledger as an empty work queue', async () => { + const paths = createPaths() + // A directory in the audit's place surfaces EISDIR rather than ENOENT. + mkdirSync(paths.auditLogPath, { recursive: true }) + + await expect(collectPendingHealThreads(paths)).rejects.toThrow() + }) + + it('treats a missing audit ledger as no pending work', async () => { + await expect(collectPendingHealThreads(createPaths())).resolves.toEqual([]) + }) + + it('skips torn ledger lines instead of failing the pass', async () => { + const paths = createPaths() + appendFileSync(paths.auditLogPath, '{"action":"hardlink","target":"/x/rollout') + appendAuditRecord(paths, windowsRolloutTarget(WINDOWS_SESSIONS_ROOT), 'audit-1') + + expect(await collectPendingHealThreads(paths)).toEqual([ + expect.objectContaining({ threadId: THREAD_ID }) + ]) + expect(CODEX_SESSION_INDEX_HEAL_VERSION).toBe(3) + }) +}) diff --git a/src/main/codex/codex-session-index-heal-state.ts b/src/main/codex/codex-session-index-heal-state.ts index ec7b0caf9e0..0a5fd0499c1 100644 --- a/src/main/codex/codex-session-index-heal-state.ts +++ b/src/main/codex/codex-session-index-heal-state.ts @@ -5,6 +5,7 @@ import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { streamCodexSessionLedgerRecords } from './codex-session-ledger-stream' // State files for the session index heal: which backfilled rollouts exist // (the backfill audit ledger), which thread ids this pass already processed @@ -50,10 +51,14 @@ export type HealMarkerSummary = { * Diffs the backfill audit ledger against the heal ledger: every hardlinked or * copied rollout whose thread id has not been processed yet, most recent first. */ -export function collectPendingHealThreads(paths: CodexSessionIndexHealPaths): PendingHealThread[] { - const processed = readProcessedHealThreads(paths) +export async function collectPendingHealThreads( + paths: CodexSessionIndexHealPaths +): Promise { + const processed = await readProcessedHealThreads(paths) const pendingByThreadId = new Map() - for (const line of readJsonlLines(paths.auditLogPath, true)) { + for await (const line of streamCodexSessionLedgerRecords(paths.auditLogPath, { + throwOnReadFailure: true + })) { if (line.action !== 'hardlink' && line.action !== 'copy' && line.action !== 'existing') { continue } @@ -94,18 +99,18 @@ function lastPathSegment(filePath: string): string { return filePath.split(/[\\/]/).at(-1) ?? '' } -function readProcessedHealThreads(paths: CodexSessionIndexHealPaths): { +async function readProcessedHealThreads(paths: CodexSessionIndexHealPaths): Promise<{ healedAuditRecords: Set legacyHealedThreadIds: Set missingAuditRecords: Set legacyMissingThreadIds: Set -} { +}> { const healedAuditRecords = new Set() const legacyHealedThreadIds = new Set() const missingAuditRecords = new Set() const legacyMissingThreadIds = new Set() const expectedRoot = normalizeRuntimePathForComparison(paths.systemSessionsRoot) - for (const line of readJsonlLines(paths.healLedgerPath)) { + for await (const line of streamCodexSessionLedgerRecords(paths.healLedgerPath)) { if ( line.v === CODEX_SESSION_INDEX_HEAL_VERSION && typeof line.threadId === 'string' && @@ -165,35 +170,6 @@ export function appendHealLedgerRecord( } } -function readJsonlLines(filePath: string, throwOnReadFailure = false): Record[] { - let contents: string - try { - contents = readFileSync(filePath, 'utf-8') - } catch (error) { - if (throwOnReadFailure && !isNotFoundError(error)) { - // Why: the audit is the heal work queue. Treating EACCES/EIO as empty - // would write a completion marker that permanently skips every session. - throw error - } - return [] - } - const lines: Record[] = [] - for (const raw of contents.split('\n')) { - if (!raw.trim()) { - continue - } - try { - const parsed: unknown = JSON.parse(raw) - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - lines.push(parsed as Record) - } - } catch { - // Skip torn/corrupt lines; both ledgers are append-only diagnostics. - } - } - return lines -} - export function readAuditLogSize(auditLogPath: string): number { try { return statSync(auditLogPath).size @@ -225,9 +201,13 @@ export function isHealMarkerCurrent( unsupportedAt?: unknown retryableFailureAt?: unknown } + // Why: one Windows directory has several spellings (drive case, separators), + // so a raw compare re-drives the whole heal for what is the same target. if ( marker.version !== CODEX_SESSION_INDEX_HEAL_VERSION || - marker.systemSessionsRoot !== paths.systemSessionsRoot + typeof marker.systemSessionsRoot !== 'string' || + normalizeRuntimePathForComparison(marker.systemSessionsRoot) !== + normalizeRuntimePathForComparison(paths.systemSessionsRoot) ) { return false } diff --git a/src/main/codex/codex-session-index-heal.ts b/src/main/codex/codex-session-index-heal.ts index baaba13358b..645d96a76b4 100644 --- a/src/main/codex/codex-session-index-heal.ts +++ b/src/main/codex/codex-session-index-heal.ts @@ -118,7 +118,7 @@ export async function runCodexSessionIndexHeal( } } - const pending = collectPendingHealThreads(paths) + const pending = await collectPendingHealThreads(paths) const summary: CodexSessionIndexHealSummary = { outcome: 'completed', pendingThreads: pending.length, diff --git a/src/main/codex/codex-session-ledger-stream.ts b/src/main/codex/codex-session-ledger-stream.ts new file mode 100644 index 00000000000..b9c817bfa21 --- /dev/null +++ b/src/main/codex/codex-session-ledger-stream.ts @@ -0,0 +1,55 @@ +import { createReadStream } from 'node:fs' +import { createInterface } from 'node:readline' + +/** + * Streams an append-only JSONL ledger one record at a time. + * + * Why: the backfill audit holds one line per published session file and neither + * ledger is ever compacted, so a large Codex history makes a `readFileSync` plus + * whole-file `JSON.parse` a multi-megabyte block of the Electron main thread. + * Reading chunk by chunk keeps the window responsive while the pass runs. + */ +export async function* streamCodexSessionLedgerRecords( + filePath: string, + options: { throwOnReadFailure?: boolean } = {} +): AsyncGenerator> { + const lines = createInterface({ + input: createReadStream(filePath, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + try { + for await (const raw of lines) { + const record = parseLedgerRecord(raw) + if (record) { + yield record + } + } + } catch (error) { + if (options.throwOnReadFailure && !isNotFoundError(error)) { + // Why: the audit is the heal work queue. Treating EACCES/EIO as empty + // would write a completion marker that permanently skips every session. + throw error + } + } finally { + lines.close() + } +} + +function parseLedgerRecord(raw: string): Record | null { + if (!raw.trim()) { + return null + } + try { + const parsed: unknown = JSON.parse(raw) + // Torn tails are quarantined by the writer's leading newline; skip them. + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : null + } catch { + return null + } +} + +function isNotFoundError(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' +} diff --git a/src/main/codex/codex-session-migration-scheduler.test.ts b/src/main/codex/codex-session-migration-scheduler.test.ts index b495afb8e0e..d57041bc4e5 100644 --- a/src/main/codex/codex-session-migration-scheduler.test.ts +++ b/src/main/codex/codex-session-migration-scheduler.test.ts @@ -94,6 +94,57 @@ describe('createCodexSessionMigrationScheduler', () => { ) }) + it('publishes the baseline while a Codex pane is still open', async () => { + vi.setSystemTime(new Date('2026-08-05T10:00:00Z')) + const prepareScheduledRun = vi.fn() + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + prepareScheduledRun, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.beginLaunch('pty-1') + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledOnce()) + + const options = startBackfill.mock.calls[0]?.[0] + // The open pane only holds its own date pending; publication is not blocked. + expect(options?.retainPendingScanDates).toBe(true) + expect(options?.canWriteCompletionMarker?.()).toBe(true) + // Preparation is handed the dates so it can persist them before the walk. + expect(prepareScheduledRun).toHaveBeenCalledWith([['2026', '08', '05']]) + }) + + it('hands preparation every date a cross-midnight launch spanned', async () => { + vi.setSystemTime(new Date('2026-08-05T23:59:59Z')) + const prepareScheduledRun = vi.fn() + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + prepareScheduledRun, + startBackfill: vi.fn().mockResolvedValue({ stopped: false }), + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.beginLaunch('pty-1') + vi.setSystemTime(new Date('2026-08-07T01:00:00Z')) + scheduler.finishLaunch('pty-1') + await vi.advanceTimersByTimeAsync(1_000) + + expect(prepareScheduledRun).toHaveBeenLastCalledWith([ + ['2026', '08', '05'], + ['2026', '08', '06'], + ['2026', '08', '07'] + ]) + }) + it('keeps launch passes full when no completed baseline can cover older failures', async () => { const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) const scheduler = createCodexSessionMigrationScheduler({ @@ -375,7 +426,7 @@ describe('createCodexSessionMigrationScheduler', () => { await vi.advanceTimersByTimeAsync(1_000) await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledOnce()) expect(startBackfill).toHaveBeenLastCalledWith( - expect.objectContaining({ writeCompletionMarker: false }), + expect.objectContaining({ retainPendingScanDates: true }), undefined ) expect(finishScheduledRun).not.toHaveBeenCalled() @@ -392,8 +443,8 @@ describe('createCodexSessionMigrationScheduler', () => { ['2026', '08', '07'] ], ignoreCompletionMarker: true, - writeCompletionMarker: true, - writeBoundedCompletionMarker: true + retainPendingScanDates: false, + fullScanRequired: false }), undefined ) @@ -421,7 +472,7 @@ describe('createCodexSessionMigrationScheduler', () => { await vi.advanceTimersByTimeAsync(1_000) await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledOnce()) expect(startBackfill).toHaveBeenLastCalledWith( - expect.objectContaining({ scanDates: undefined, writeBoundedCompletionMarker: false }), + expect.objectContaining({ scanDates: undefined, fullScanRequired: true }), undefined ) @@ -429,7 +480,7 @@ describe('createCodexSessionMigrationScheduler', () => { await vi.advanceTimersByTimeAsync(1_000) await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2)) expect(startBackfill).toHaveBeenLastCalledWith( - expect.objectContaining({ scanDates: undefined, writeBoundedCompletionMarker: false }), + expect.objectContaining({ scanDates: undefined, fullScanRequired: true }), undefined ) await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce()) @@ -485,7 +536,7 @@ describe('createCodexSessionMigrationScheduler', () => { await vi.advanceTimersByTimeAsync(1_000) expect(startBackfill).toHaveBeenCalledWith( - expect.objectContaining({ scanDates: undefined, writeBoundedCompletionMarker: false }), + expect.objectContaining({ scanDates: undefined, fullScanRequired: true }), undefined ) await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce()) @@ -510,8 +561,8 @@ describe('createCodexSessionMigrationScheduler', () => { expect(startBackfill).toHaveBeenCalledWith( expect.objectContaining({ scanDates: expect.any(Array), - writeCompletionMarker: false, - writeBoundedCompletionMarker: false + retainPendingScanDates: true, + fullScanRequired: false }), undefined ) @@ -536,8 +587,8 @@ describe('createCodexSessionMigrationScheduler', () => { expect(startBackfill).toHaveBeenCalledWith( expect.objectContaining({ scanDates: expect.any(Array), - writeCompletionMarker: false, - writeBoundedCompletionMarker: false + retainPendingScanDates: true, + fullScanRequired: false }), undefined ) @@ -560,14 +611,14 @@ describe('createCodexSessionMigrationScheduler', () => { await vi.advanceTimersByTimeAsync(1_000) expect(startBackfill).toHaveBeenCalledWith( - expect.objectContaining({ writeCompletionMarker: false }), + expect.objectContaining({ retainPendingScanDates: true }), undefined ) scheduler.finishLaunch('stable-pty', 4) await vi.advanceTimersByTimeAsync(1_000) expect(startBackfill).toHaveBeenLastCalledWith( - expect.objectContaining({ writeCompletionMarker: true }), + expect.objectContaining({ retainPendingScanDates: false }), undefined ) }) @@ -588,14 +639,14 @@ describe('createCodexSessionMigrationScheduler', () => { scheduler.beginLaunch('stable-pty', false, new Date(), 2) await vi.advanceTimersByTimeAsync(61_000) expect(startBackfill).toHaveBeenLastCalledWith( - expect.objectContaining({ writeCompletionMarker: false }), + expect.objectContaining({ retainPendingScanDates: true }), undefined ) scheduler.finishLaunch('stable-pty', 3) await vi.advanceTimersByTimeAsync(1_000) expect(startBackfill).toHaveBeenLastCalledWith( - expect.objectContaining({ writeCompletionMarker: true }), + expect.objectContaining({ retainPendingScanDates: false }), undefined ) }) @@ -623,7 +674,7 @@ describe('createCodexSessionMigrationScheduler', () => { await vi.advanceTimersByTimeAsync(1_000) expect(startBackfill).toHaveBeenCalledTimes(2) expect(startBackfill).toHaveBeenLastCalledWith( - expect.objectContaining({ writeCompletionMarker: true }), + expect.objectContaining({ retainPendingScanDates: false }), undefined ) }) @@ -646,7 +697,7 @@ describe('createCodexSessionMigrationScheduler', () => { scheduler.beginLaunch('stable-pty', false, new Date(), 5) await vi.advanceTimersByTimeAsync(1_000) expect(startBackfill).toHaveBeenLastCalledWith( - expect.objectContaining({ writeCompletionMarker: false }), + expect.objectContaining({ retainPendingScanDates: true }), undefined ) @@ -654,7 +705,7 @@ describe('createCodexSessionMigrationScheduler', () => { await vi.advanceTimersByTimeAsync(1_000) expect(startBackfill).toHaveBeenCalledTimes(2) expect(startBackfill).toHaveBeenLastCalledWith( - expect.objectContaining({ writeCompletionMarker: true }), + expect.objectContaining({ retainPendingScanDates: false }), undefined ) }) @@ -675,7 +726,7 @@ describe('createCodexSessionMigrationScheduler', () => { await vi.advanceTimersByTimeAsync(1_000) expect(startBackfill).toHaveBeenCalledWith( - expect.objectContaining({ writeCompletionMarker: false }), + expect.objectContaining({ retainPendingScanDates: true }), undefined ) }) diff --git a/src/main/codex/codex-session-migration-scheduler.ts b/src/main/codex/codex-session-migration-scheduler.ts index 9f08d83c5ea..d0f696031ae 100644 --- a/src/main/codex/codex-session-migration-scheduler.ts +++ b/src/main/codex/codex-session-migration-scheduler.ts @@ -1,4 +1,9 @@ -import { getCodexSessionBackfillDate } from './codex-session-backfill-date' +import { + compareCodexSessionBackfillDates, + getCodexSessionBackfillDate, + getCodexSessionBackfillDatesBetween, + toCodexSessionBackfillDateKey +} from './codex-session-backfill-scan-dates' import { CodexSessionMigrationIgnoredLaunches } from './codex-session-migration-ignored-launches' import { CodexSessionMigrationRecentExits } from './codex-session-migration-recent-exits' import type { @@ -29,7 +34,7 @@ export function createCodexSessionMigrationScheduler(args: { isEligible: () => boolean isQuitting: () => boolean resolveSystemCodexHomePathOverride: () => string | undefined - prepareScheduledRun?: () => boolean | void + prepareScheduledRun?: (scanDates: readonly CodexSessionBackfillDate[]) => boolean | void finishScheduledRun?: () => void startBackfill: MigrationRun startIndexHeal: MigrationRun @@ -63,7 +68,7 @@ export function createCodexSessionMigrationScheduler(args: { pendingScheduledRunGeneration ?? requestedGeneration ) for (const scanDate of requestedScanDates) { - pendingScanDates.set(scanDate.join('-'), scanDate) + pendingScanDates.set(toCodexSessionBackfillDateKey(scanDate), scanDate) } pendingFullScan ||= requestedFullScan } @@ -77,17 +82,16 @@ export function createCodexSessionMigrationScheduler(args: { } const isScheduledRun = pendingScheduledRunGeneration !== null const activeScheduledRunGeneration = pendingScheduledRunGeneration + const runScanDates = [...pendingScanDates.values()].sort(compareCodexSessionBackfillDates) let preparationNeedsFullScan = false if (isScheduledRun) { pendingScheduledRunGeneration = null - // Why: an older active pass can rewrite the marker after launch invalidates it. - preparationNeedsFullScan = args.prepareScheduledRun?.() === true + // Why: preparation persists these dates so an abnormal exit still yields a + // bounded recovery window instead of another full-tree walk. + preparationNeedsFullScan = args.prepareScheduledRun?.(runScanDates) === true } const fullScanRequired = pendingFullScan || preparationNeedsFullScan - const scanDates = - !fullScanRequired && pendingScanDates.size > 0 - ? [...pendingScanDates.values()].sort(compareBackfillDates) - : undefined + const scanDates = !fullScanRequired && runScanDates.length > 0 ? runScanDates : undefined pendingScanDates.clear() pendingFullScan = false activeRunStopObserved = false @@ -105,12 +109,12 @@ export function createCodexSessionMigrationScheduler(args: { { shouldStop, scanDates, + fullScanRequired, ignoreCompletionMarker: isScheduledRun, - writeCompletionMarker: activeLaunches.size === 0, - writeBoundedCompletionMarker: - isScheduledRun && activeLaunches.size === 0 && !fullScanRequired, + // Why: a live pane keeps appending to its own date directory, so that + // date stays pending — but the historical baseline is still certified. + retainPendingScanDates: activeLaunches.size > 0, canWriteCompletionMarker: () => - activeLaunches.size === 0 && scheduledTimer === null && pendingScheduledRunGeneration === null && (!isScheduledRun || activeScheduledRunGeneration === scheduledRunGeneration) @@ -144,7 +148,7 @@ export function createCodexSessionMigrationScheduler(args: { ) pendingFullScan ||= fullScanRequired for (const scanDate of scanDates ?? []) { - pendingScanDates.set(scanDate.join('-'), scanDate) + pendingScanDates.set(toCodexSessionBackfillDateKey(scanDate), scanDate) } } if ( @@ -168,9 +172,9 @@ export function createCodexSessionMigrationScheduler(args: { scheduledTimer = null if (generation !== undefined) { const currentDate = getCodexSessionBackfillDate() - scheduledScanDates.set(currentDate.join('-'), currentDate) + scheduledScanDates.set(toCodexSessionBackfillDateKey(currentDate), currentDate) } - const scanDates = [...scheduledScanDates.values()].sort(compareBackfillDates) + const scanDates = [...scheduledScanDates.values()].sort(compareCodexSessionBackfillDates) scheduledScanDates.clear() const fullScanRequired = scheduledFullScan scheduledFullScan = false @@ -186,7 +190,7 @@ export function createCodexSessionMigrationScheduler(args: { scheduledRunGeneration += 1 scheduledFullScan ||= fullScanRequired const launchDate = getCodexSessionBackfillDate() - scheduledScanDates.set(launchDate.join('-'), launchDate) + scheduledScanDates.set(toCodexSessionBackfillDateKey(launchDate), launchDate) armScheduledRun(scheduledRunGeneration) } @@ -235,7 +239,7 @@ export function createCodexSessionMigrationScheduler(args: { return } for (const scanDate of getCodexSessionBackfillDatesBetween(startedAt, new Date())) { - scheduledScanDates.set(scanDate.join('-'), scanDate) + scheduledScanDates.set(toCodexSessionBackfillDateKey(scanDate), scanDate) } scheduleRun() }, @@ -249,31 +253,6 @@ export function createCodexSessionMigrationScheduler(args: { } } -function getCodexSessionBackfillDatesBetween( - startedAt: Date, - finishedAt: Date -): CodexSessionBackfillDate[] { - const dates: CodexSessionBackfillDate[] = [] - const cursor = new Date( - Date.UTC(startedAt.getUTCFullYear(), startedAt.getUTCMonth(), startedAt.getUTCDate()) - ) - const last = new Date( - Date.UTC(finishedAt.getUTCFullYear(), finishedAt.getUTCMonth(), finishedAt.getUTCDate()) - ) - while (cursor <= last) { - dates.push(getCodexSessionBackfillDate(cursor)) - cursor.setUTCDate(cursor.getUTCDate() + 1) - } - return dates -} - -function compareBackfillDates( - left: CodexSessionBackfillDate, - right: CodexSessionBackfillDate -): number { - return left.join('-').localeCompare(right.join('-')) -} - function isStoppedMigrationResult(result: unknown): boolean { return Boolean(result && typeof result === 'object' && 'stopped' in result && result.stopped) } diff --git a/src/main/index.ts b/src/main/index.ts index edc26214121..df30a05638e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2586,7 +2586,8 @@ void app.whenReady().then(async () => { isQuitting: () => isQuitting, resolveSystemCodexHomePathOverride: () => resolveHostCodexSessionSourceHome(store!.getSettings()), - prepareScheduledRun: () => codexRuntimeHome?.prepareHostSystemDefaultSessionMigrationPass(), + prepareScheduledRun: (scanDates) => + codexRuntimeHome?.prepareHostSystemDefaultSessionMigrationPass(scanDates), finishScheduledRun: () => codexRuntimeHome?.finishHostSystemDefaultSessionMigrationPass(), startBackfill: startCodexSessionBackfillInBackground, startIndexHeal: startCodexSessionIndexHealInBackground From 64c992cd56ac2ed2414a71f8c8e3816fe3ba22a7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:43:02 -0700 Subject: [PATCH 024/152] fix(memory): report the Windows number that predicts paging, not just resident pages (#16211) (#16589) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(memory): report Windows commit charge, not just working set (#16211) On Windows the per-process figure was working set — resident pages only. An agent whose pages Windows has trimmed to the pagefile shrinks its working set while still holding the commit that pushes the host into paging, so Resource Manager and `orca diagnostics memory` understated an owned tree by 10-40x (9 codex.exe: 1.4 GB working set, 13.4 GB private) and could not warn before the host was already thrashing. Add committed private bytes as a second, separately-labelled quantity rather than redefining the existing one: - CIM sweep gains one property (PageFileUsage, UInt32 KB); the typeperf fallback gains one counter (\Process(*)\Private Bytes). Both ride the sweep that already runs. - MemorySnapshot gains optional `privateMemory` per app/worktree/session plus `processCommitMetric` and `totalPrivateMemory`. Rule 1 additive optional fields: old clients ignore them, and absence reads as "not measured", never as zero — Unix hosts and older hosts send nothing. - `totalMemory` and `processMemoryMetric` keep their exact meaning, so the "shared pages may repeat" copy stays true; the working-set copy now also says paged-out memory is not counted. - Resource Manager shows "Σ Private" beside "Σ WS", and tints the badge yellow/red once tracked commit passes 60/80% of physical RAM — the same thresholds `usageTextColorClass` already uses for host usage. Tint and tooltip only; no toast, and the badge number is unchanged. The parsers move to windows-process-sample-parsing.ts and the Windows sweep tests to their own file to stay under max-lines. Not migrating the collector to windows-process-table.ts: the native snapshot exposes no commit figure and no CPU times, and truncates WorkingSetSize through a DWORD. Documented in the enumeration reference. * fix(memory): derive the typeperf field cap from the counter list The fallback parser's 8192-field cap was sized for three `\Process(*)` counters. Adding `Private Bytes` cut the parsable process count from ~2730 to ~2047, and overrun is a blackout (`parseTypeperfCsvLine` returns `[]`, so the whole sweep reports nothing) rather than a truncation. The counter list now lives beside the decoder that reads those names back out of the PDH header, and the cap is derived from it. Also collapses the four spellings of "omit privateMemory when unmeasured" in collector.ts onto one `commitField` helper, drops the unread parameter and the never-rendered `columnLabel` from `getResourceCommitMetricCopy`, folds `getCommitPressurePercent` into the only function that called it, and reverts unrelated Prettier churn in the Windows enumeration doc. The commit tint's doc comment no longer claims to predict host paging: it measures Orca's own share of physical RAM. Host commit charge / commit limit stays a follow-up (#16211). --- .../remote-shared-control-retirement-probe.ts | 4 + docs/reference/windows-process-enumeration.md | 7 + src/cli/index-memory-diagnostics.test.ts | 107 +++ src/cli/workspace-format.ts | 22 + .../memory/collector-windows-sweep.test.ts | 620 ++++++++++++++++++ src/main/memory/collector.test.ts | 430 +----------- src/main/memory/collector.ts | 95 ++- .../windows-process-resource-collector.ts | 213 +----- .../windows-process-sample-parsing.test.ts | 140 ++++ .../memory/windows-process-sample-parsing.ts | 240 +++++++ .../status-bar/ResourceUsageStatusSegment.tsx | 80 ++- .../resource-memory-metric-copy.test.ts | 53 +- .../status-bar/resource-memory-metric-copy.ts | 39 +- src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- src/shared/process-stats-types.ts | 31 + 19 files changed, 1436 insertions(+), 660 deletions(-) create mode 100644 src/main/memory/collector-windows-sweep.test.ts create mode 100644 src/main/memory/windows-process-sample-parsing.test.ts create mode 100644 src/main/memory/windows-process-sample-parsing.ts diff --git a/config/scripts/remote-shared-control-retirement-probe.ts b/config/scripts/remote-shared-control-retirement-probe.ts index 629ba1fddde..8c38b312a1d 100644 --- a/config/scripts/remote-shared-control-retirement-probe.ts +++ b/config/scripts/remote-shared-control-retirement-probe.ts @@ -190,8 +190,10 @@ function summarizeMemory(snapshot: MemorySnapshot): { app: MemorySnapshot['app'] host: MemorySnapshot['host'] processMemoryMetric: MemorySnapshot['processMemoryMetric'] + processCommitMetric: MemorySnapshot['processCommitMetric'] totalCpu: number totalMemory: number + totalPrivateMemory: MemorySnapshot['totalPrivateMemory'] worktreeCount: number sessionCount: number worktreeMemory: number @@ -208,8 +210,10 @@ function summarizeMemory(snapshot: MemorySnapshot): { app: snapshot.app, host: snapshot.host, processMemoryMetric: snapshot.processMemoryMetric, + processCommitMetric: snapshot.processCommitMetric, totalCpu: snapshot.totalCpu, totalMemory: snapshot.totalMemory, + totalPrivateMemory: snapshot.totalPrivateMemory, worktreeCount: snapshot.worktrees.length, sessionCount: snapshot.worktrees.reduce( (total, worktree) => total + worktree.sessions.length, diff --git a/docs/reference/windows-process-enumeration.md b/docs/reference/windows-process-enumeration.md index f79f70b4096..236d6adbf47 100644 --- a/docs/reference/windows-process-enumeration.md +++ b/docs/reference/windows-process-enumeration.md @@ -177,6 +177,13 @@ time to prove a PID has not been recycled — daemon identity, managed-hook ownership, and CPU accounting in the memory collector — still reads it through its own query. Those callers are not migrated. +Committed private bytes have no equivalent either, and the one memory value the +snapshot does carry is unusable for the sizes Orca now sees: `process.cc` stores +`pmc.WorkingSetSize` into a `DWORD`, so anything above 4 GB wraps. That is the +second reason `windows-process-resource-collector.ts` still runs its own +`Get-CimInstance` sweep — it needs `PageFileUsage` (commit) and the CPU-time +counters in the same pass. Migrating it to the native table would cost both. + Start time is a proxy for identity, not identity. The durable answer for the process trees Orca itself spawns is an inherited handle: a job object names the tree Orca created, so no start-time comparison is needed. Those readers should diff --git a/src/cli/index-memory-diagnostics.test.ts b/src/cli/index-memory-diagnostics.test.ts index d7cdcf2491b..9896c40b0aa 100644 --- a/src/cli/index-memory-diagnostics.test.ts +++ b/src/cli/index-memory-diagnostics.test.ts @@ -113,5 +113,112 @@ describe('orca cli worktree awareness', () => { expect(output).toContain('hostAvailable: 2.0 MB (free-memory)') expect(output).toContain('app: 1.0 MB') expect(output).toContain('- feature 1.0 MB 2.5% 1 session') + // Back-compat: a host that never heard of committed bytes prints none. + expect(output).not.toContain('totalPrivateMemory') + expect(output).not.toContain('processCommitMetric') + }) + + it('reports committed private bytes alongside the resident figure', async () => { + queueFixtures( + callMock, + okFixture('req_memory', { + app: { + cpu: 1.25, + memory: 1024 * 1024, + privateMemory: 2 * 1024 * 1024, + main: { cpu: 0.5, memory: 512 * 1024, privateMemory: 1024 * 1024 }, + renderer: { cpu: 0.5, memory: 384 * 1024, privateMemory: 768 * 1024 }, + other: { cpu: 0.25, memory: 128 * 1024, privateMemory: 256 * 1024 }, + history: [1024 * 1024] + }, + worktrees: [ + { + worktreeId: 'repo::/tmp/repo/feature', + worktreeName: 'feature', + repoId: 'repo', + repoName: 'Orca', + cpu: 2.5, + memory: 1024 * 1024, + privateMemory: 14 * 1024 * 1024, + sessions: [ + { + sessionId: 'pty-1', + paneKey: null, + pid: 123, + cpu: 2.5, + memory: 1024 * 1024, + privateMemory: 14 * 1024 * 1024 + } + ], + history: [1024 * 1024] + } + ], + host: { + totalMemory: 8 * 1024 * 1024, + freeMemory: 2 * 1024 * 1024, + availableMemory: 2 * 1024 * 1024, + availableMemorySource: 'free-memory', + usedMemory: 6 * 1024 * 1024, + memoryUsagePercent: 75, + cpuCoreCount: 8, + loadAverage1m: 1.25 + }, + processMemoryMetric: 'working-set', + processCommitMetric: 'private-bytes', + totalCpu: 3.75, + totalMemory: 2 * 1024 * 1024, + totalPrivateMemory: 16 * 1024 * 1024, + collectedAt: 1000 + }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['diagnostics', 'memory'], '/tmp/repo') + + const output = logSpy.mock.calls.flat().join('\n') + expect(output).toContain('totalMemory: 2.0 MB') + expect(output).toContain('processMemoryMetric: summed working set; shared pages may repeat') + expect(output).toContain('totalPrivateMemory: 16 MB') + expect(output).toContain( + 'processCommitMetric: summed private bytes; committed memory, counted whether resident or paged out' + ) + expect(output).toContain('- feature 1.0 MB 14 MB committed 2.5% 1 session') + }) + + it('passes committed bytes through --json untouched', async () => { + const snapshot = { + app: { + cpu: 0, + memory: 1024, + privateMemory: 4096, + main: { cpu: 0, memory: 1024, privateMemory: 4096 }, + renderer: { cpu: 0, memory: 0, privateMemory: 0 }, + other: { cpu: 0, memory: 0, privateMemory: 0 }, + history: [] + }, + worktrees: [], + host: { + totalMemory: 16 * 1024, + freeMemory: 1024, + availableMemory: 1024, + availableMemorySource: 'free-memory', + usedMemory: 15 * 1024, + memoryUsagePercent: 93.75, + cpuCoreCount: 8, + loadAverage1m: 0 + }, + processMemoryMetric: 'working-set', + processCommitMetric: 'private-bytes', + totalCpu: 0, + totalMemory: 1024, + totalPrivateMemory: 4096, + collectedAt: 1000 + } + queueFixtures(callMock, okFixture('req_memory', snapshot)) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['diagnostics', 'memory', '--json'], '/tmp/repo') + + expect(JSON.parse(logSpy.mock.calls.flat().join('\n')).result).toEqual(snapshot) }) }) diff --git a/src/cli/workspace-format.ts b/src/cli/workspace-format.ts index 008c2cf4c21..8cdfce86b74 100644 --- a/src/cli/workspace-format.ts +++ b/src/cli/workspace-format.ts @@ -16,6 +16,7 @@ export function formatMemorySnapshot(snapshot: MemorySnapshot): string { `collectedAt: ${new Date(snapshot.collectedAt).toISOString()}`, `totalMemory: ${formatByteCount(snapshot.totalMemory)}`, `processMemoryMetric: ${formatProcessMemoryMetric(snapshot.processMemoryMetric)}`, + ...formatCommitLines(snapshot), `totalCpu: ${formatCpu(snapshot.totalCpu)}`, [ `hostUsed: ${formatByteCount(snapshot.host.usedMemory)}`, @@ -51,11 +52,32 @@ function formatWorktreeMemoryLine(worktree: WorktreeMemory): string { return [ `- ${worktree.worktreeName}`, `${formatByteCount(worktree.memory)}`, + ...(worktree.privateMemory === undefined + ? [] + : [`${formatByteCount(worktree.privateMemory)} committed`]), `${formatCpu(worktree.cpu)}`, `${worktree.sessions.length} session${worktree.sessions.length === 1 ? '' : 's'}` ].join(' ') } +// Why omitted rather than zeroed: a host that predates the field, or cannot read +// commit at all, must not be printed as agents committing nothing. +function formatCommitLines(snapshot: MemorySnapshot): string[] { + if (typeof snapshot.totalPrivateMemory !== 'number') { + return [] + } + return [ + `totalPrivateMemory: ${formatByteCount(snapshot.totalPrivateMemory)}`, + `processCommitMetric: ${formatProcessCommitMetric(snapshot.processCommitMetric)}` + ] +} + +function formatProcessCommitMetric(metric: MemorySnapshot['processCommitMetric']): string { + return metric === 'private-bytes' + ? 'summed private bytes; committed memory, counted whether resident or paged out' + : `summed ${metric ?? 'unknown'}` +} + function formatCpu(cpu: number): string { return `${cpu.toFixed(1)}%` } diff --git a/src/main/memory/collector-windows-sweep.test.ts b/src/main/memory/collector-windows-sweep.test.ts new file mode 100644 index 00000000000..de180fcfb57 --- /dev/null +++ b/src/main/memory/collector-windows-sweep.test.ts @@ -0,0 +1,620 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import os from 'node:os' +import type { MemorySnapshotStore } from './collector' +import { setAppEnvironment } from '../../shared/app-environment' + +type AppMetricFixture = { + pid: number + type: string + cpu: { percentCPUUsage: number } + memory: { workingSetSize: number } +} + +const { appMetricsMock, runProcessMock, execMock, listRegisteredPtysMock } = vi.hoisted(() => ({ + appMetricsMock: vi.fn<() => AppMetricFixture[]>(() => []), + runProcessMock: vi.fn(), + execMock: vi.fn(), + listRegisteredPtysMock: vi.fn() +})) + +vi.mock('child_process', () => ({ + exec: (cmd: string, opts: unknown, cb: (err: Error | null, out: { stdout: string }) => void) => + execMock(cmd, opts, cb) +})) + +// Why mock the chokepoint for the Windows sweep: maxBuffer, timeout and the +// hidden console are its contract now, so the assertions below are about which +// query runs, not how a process is started. +vi.mock('../../shared/child-process/run-process', () => ({ + runProcess: (spec: { program: string; args?: string[] }) => runProcessMock(spec) +})) + +vi.mock('./pty-registry', () => ({ + listRegisteredPtys: listRegisteredPtysMock +})) + +function appEnvironment() { + return { + getPath: () => process.cwd(), + getAppPath: () => process.cwd(), + getVersion: () => '0.0.0-test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: appMetricsMock + } +} + +async function loadCollector() { + vi.resetModules() + const { setAppEnvironment: setResetAppEnvironment } = await import('../../shared/app-environment') + setResetAppEnvironment(appEnvironment()) + return await import('./collector') +} + +const emptyStore = { + getWorktreeMeta: () => undefined, + getRepo: () => undefined +} satisfies MemorySnapshotStore + +describe('collectMemorySnapshot on Windows', () => { + beforeEach(() => { + setAppEnvironment(appEnvironment()) + vi.restoreAllMocks() + appMetricsMock.mockReset() + appMetricsMock.mockReturnValue([]) + runProcessMock.mockReset() + execMock.mockReset() + listRegisteredPtysMock.mockReset() + listRegisteredPtysMock.mockReturnValue([]) + }) + + function mockPsResponse(stdout: string) { + execMock.mockImplementation((_cmd, _opts, cb) => cb(null, { stdout, stderr: '' })) + runProcessMock.mockImplementation((spec: { program: string }) => + Promise.resolve({ + code: 0, + signal: null, + stdout: + spec.program === 'typeperf.exe' + ? psFixtureToTypeperfOutput(stdout) + : psFixtureToWindowsProcessOutput(stdout), + stderr: '', + timedOut: false + }) + ) + } + + function psFixtureToWindowsProcessOutput(stdout: string): string { + return stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [pid, ppid, _cpu, rssKb] = line.split(/\s+/, 4) + const memory = Number.parseInt(rssKb ?? '', 10) + return [ + pid ?? '', + ppid ?? '', + Number.isFinite(memory) && memory > 0 ? memory * 1024 : 0, + '0', + '0', + '1' + ].join('\t') + }) + .join('\r\n') + } + + function psFixtureToTypeperfOutput(stdout: string): string { + const rows = stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line, index) => { + const [pid, ppid, _cpu, rssKb] = line.split(/\s+/, 4) + const memoryKb = Number.parseInt(rssKb ?? '', 10) + return { + instance: `fixture${index}`, + pid: pid ?? '', + ppid: ppid ?? '', + memory: Number.isFinite(memoryKb) && memoryKb > 0 ? memoryKb * 1024 : 0 + } + }) + const counterColumns = (counter: string): string[] => + rows.map((row) => `"\\\\HOST\\Process(${row.instance})\\${counter}"`) + const valueColumns = (field: 'pid' | 'ppid' | 'memory'): string[] => + rows.map((row) => `"${row[field]}"`) + + return [ + [ + '"(PDH-CSV 4.0)"', + ...counterColumns('ID Process'), + ...counterColumns('Creating Process ID'), + ...counterColumns('Working Set') + ].join(','), + ['"time"', ...valueColumns('pid'), ...valueColumns('ppid'), ...valueColumns('memory')].join( + ',' + ) + ].join('\r\n') + } + + it('uses one CIM process for Windows memory and CPU sampling', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + mockPsResponse('10 1 0 1024') + const { collectMemorySnapshot } = await loadCollector() + + await collectMemorySnapshot(emptyStore) + + expect(execMock).not.toHaveBeenCalled() + expect(runProcessMock).toHaveBeenCalledTimes(1) + const spec = runProcessMock.mock.calls[0][0] + expect(spec.program).toBe('powershell.exe') + expect(spec.args.join(' ')).toContain('Get-CimInstance Win32_Process') + expect(spec.args.join(' ')).toContain('KernelModeTime') + expect(spec.args.join(' ')).toContain('UserModeTime') + expect(spec.args.join(' ')).toContain('CreationDate') + expect(spec).toMatchObject({ maxOutputBytes: 10 * 1024 * 1024, timeoutMs: 5_000 }) + }) + + it('attributes Windows process CPU from cumulative time deltas between sweeps', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(3_000) + const cpuOutputs = [ + '10\t1\t1048576\t10000000\t0\t638830000000000000', + '10\t1\t1048576\t30000000\t0\t638830000000000000' + ] + runProcessMock.mockImplementation(() => + Promise.resolve({ + code: 0, + signal: null, + stdout: cpuOutputs.shift() ?? '', + stderr: '', + timedOut: false + }) + ) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'windows-cpu-pty', + worktreeId: 'repo-1::C:\\repo', + sessionId: 'session-1', + paneKey: 'pane-1', + pid: 10 + } + ]) + const { collectMemorySnapshot } = await loadCollector() + + const first = await collectMemorySnapshot(emptyStore) + const second = await collectMemorySnapshot(emptyStore) + + expect(first.worktrees[0].sessions[0].cpu).toBe(0) + expect(second.worktrees[0].sessions[0].cpu).toBe(100) + expect(runProcessMock.mock.calls.map(([spec]) => spec.program)).toEqual([ + 'powershell.exe', + 'powershell.exe' + ]) + }) + + it('does not attribute prior CPU time after Windows reuses a process id', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(3_000) + const cpuOutputs = [ + '10\t1\t1048576\t10000000\t0\t638830000000000000', + '10\t1\t1048576\t30000000\t0\t638830000000000001' + ] + runProcessMock.mockImplementation(() => + Promise.resolve({ + code: 0, + signal: null, + stdout: cpuOutputs.shift() ?? '', + stderr: '', + timedOut: false + }) + ) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'reused-pid-pty', + worktreeId: 'repo-1::C:\\repo', + sessionId: 'session-1', + paneKey: 'pane-1', + pid: 10 + } + ]) + const { collectMemorySnapshot } = await loadCollector() + + await collectMemorySnapshot(emptyStore) + const second = await collectMemorySnapshot(emptyStore) + + expect(second.worktrees[0].sessions[0].cpu).toBe(0) + }) + + it('supports cumulative CPU counters above JavaScript safe integers', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(3_000) + const cpuOutputs = [ + '10\t1\t1048576\t90071992547409920\t0\t638830000000000000', + '10\t1\t1048576\t90071992567409920\t0\t638830000000000000' + ] + runProcessMock.mockImplementation(() => + Promise.resolve({ + code: 0, + signal: null, + stdout: cpuOutputs.shift() ?? '', + stderr: '', + timedOut: false + }) + ) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'large-counter-pty', + worktreeId: 'repo-1::C:\\repo', + sessionId: 'session-1', + paneKey: 'pane-1', + pid: 10 + } + ]) + const { collectMemorySnapshot } = await loadCollector() + + await collectMemorySnapshot(emptyStore) + const second = await collectMemorySnapshot(emptyStore) + + expect(second.worktrees[0].sessions[0].cpu).toBe(100) + }) + + it('keeps the older CPU baseline when forced snapshots are too close together', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + vi.spyOn(performance, 'now') + .mockReturnValueOnce(1_000) + .mockReturnValueOnce(1_100) + .mockReturnValueOnce(3_000) + const cpuOutputs = [ + '10\t1\t1048576\t0\t0\t638830000000000000', + '10\t1\t1048576\t1000000\t0\t638830000000000000', + '10\t1\t1048576\t20000000\t0\t638830000000000000' + ] + runProcessMock.mockImplementation(() => + Promise.resolve({ + code: 0, + signal: null, + stdout: cpuOutputs.shift() ?? '', + stderr: '', + timedOut: false + }) + ) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'short-sample-pty', + worktreeId: 'repo-1::C:\\repo', + sessionId: 'session-1', + paneKey: 'pane-1', + pid: 10 + } + ]) + const { collectMemorySnapshot } = await loadCollector() + + await collectMemorySnapshot(emptyStore) + const tooSoon = await collectMemorySnapshot(emptyStore) + const normalPoll = await collectMemorySnapshot(emptyStore) + + expect(tooSoon.worktrees[0].sessions[0].cpu).toBe(0) + expect(normalPoll.worktrees[0].sessions[0].cpu).toBe(100) + }) + + it('caps impossible Windows CPU deltas at the host core capacity', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + vi.spyOn(os, 'cpus').mockReturnValue([{}, {}] as ReturnType) + vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(3_000) + const cpuOutputs = [ + '10\t1\t1048576\t0\t0\t638830000000000000', + '10\t1\t1048576\t1000000000\t0\t638830000000000000' + ] + runProcessMock.mockImplementation(() => + Promise.resolve({ + code: 0, + signal: null, + stdout: cpuOutputs.shift() ?? '', + stderr: '', + timedOut: false + }) + ) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'impossible-cpu-pty', + worktreeId: 'repo-1::C:\\repo', + sessionId: 'session-1', + paneKey: 'pane-1', + pid: 10 + } + ]) + const { collectMemorySnapshot } = await loadCollector() + + await collectMemorySnapshot(emptyStore) + const capped = await collectMemorySnapshot(emptyStore) + + expect(capped.worktrees[0].sessions[0].cpu).toBe(200) + }) + + it('warms CPU sampling again after Resource Manager was closed', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(12_000) + const cpuOutputs = [ + '10\t1\t1048576\t0\t0\t638830000000000000', + '10\t1\t1048576\t100000000\t0\t638830000000000000' + ] + runProcessMock.mockImplementation(() => + Promise.resolve({ + code: 0, + signal: null, + stdout: cpuOutputs.shift() ?? '', + stderr: '', + timedOut: false + }) + ) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'stale-counter-pty', + worktreeId: 'repo-1::C:\\repo', + sessionId: 'session-1', + paneKey: 'pane-1', + pid: 10 + } + ]) + const { collectMemorySnapshot } = await loadCollector() + + await collectMemorySnapshot(emptyStore) + const reopened = await collectMemorySnapshot(emptyStore) + + expect(reopened.worktrees[0].sessions[0].cpu).toBe(0) + }) + + it('preserves Windows process memory when CPU counters are unavailable', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + runProcessMock.mockImplementation(() => + Promise.resolve({ + code: 0, + signal: null, + stdout: '10\t1\t1048576\t\t\t638830000000000000', + stderr: '', + timedOut: false + }) + ) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'cpu-failure-pty', + worktreeId: 'repo-1::C:\\repo', + sessionId: 'session-1', + paneKey: 'pane-1', + pid: 10 + } + ]) + const { collectMemorySnapshot } = await loadCollector() + + const snapshot = await collectMemorySnapshot(emptyStore) + + expect(snapshot.worktrees[0].sessions[0]).toMatchObject({ cpu: 0, memory: 1024 * 1024 }) + }) + + it('uses Typeperf during the CIM retry cooldown', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + runProcessMock.mockImplementation((spec: { program: string }) => + Promise.resolve( + spec.program === 'powershell.exe' + ? { code: 1, signal: null, stdout: '', stderr: 'CIM unavailable', timedOut: false } + : { + code: 0, + signal: null, + stdout: psFixtureToTypeperfOutput('10 1 0 1024'), + stderr: '', + timedOut: false + } + ) + ) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'cim-pty', + worktreeId: null, + sessionId: null, + paneKey: null, + pid: 10 + } + ]) + const { collectMemorySnapshot } = await loadCollector() + + const first = await collectMemorySnapshot(emptyStore) + const second = await collectMemorySnapshot(emptyStore) + + expect(runProcessMock).toHaveBeenCalledTimes(3) + expect(runProcessMock.mock.calls.map(([spec]) => spec.program)).toEqual([ + 'powershell.exe', + 'typeperf.exe', + 'typeperf.exe' + ]) + expect(runProcessMock.mock.calls[1][0]).toMatchObject({ timeoutMs: 5_000 }) + expect(first.worktrees[0].memory).toBe(1048576) + expect(second.worktrees[0].memory).toBe(1048576) + }) + + it('retries CIM after fallback and warms CPU sampling before restoring deltas', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + vi.spyOn(performance, 'now') + .mockReturnValueOnce(1_000) + .mockReturnValueOnce(2_000) + .mockReturnValueOnce(31_001) + .mockReturnValueOnce(32_000) + .mockReturnValueOnce(34_000) + const cimOutputs = [ + '10\t1\t1048576\t10000000\t0\t638830000000000000', + '10\t1\t1048576\t30000000\t0\t638830000000000000' + ] + let cimCalls = 0 + runProcessMock.mockImplementation((spec: { program: string }) => { + if (spec.program === 'typeperf.exe') { + return Promise.resolve({ + code: 0, + signal: null, + stdout: psFixtureToTypeperfOutput('10 1 0 1024'), + stderr: '', + timedOut: false + }) + } + cimCalls += 1 + return Promise.resolve( + cimCalls === 1 + ? { code: 1, signal: null, stdout: '', stderr: 'transient CIM failure', timedOut: false } + : { + code: 0, + signal: null, + stdout: cimOutputs.shift() ?? '', + stderr: '', + timedOut: false + } + ) + }) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'recovering-cim-pty', + worktreeId: 'repo-1::C:\\repo', + sessionId: 'session-1', + paneKey: 'pane-1', + pid: 10 + } + ]) + const { collectMemorySnapshot } = await loadCollector() + + await collectMemorySnapshot(emptyStore) + await collectMemorySnapshot(emptyStore) + const warming = await collectMemorySnapshot(emptyStore) + const recovered = await collectMemorySnapshot(emptyStore) + + expect(runProcessMock.mock.calls.map(([spec]) => spec.program)).toEqual([ + 'powershell.exe', + 'typeperf.exe', + 'typeperf.exe', + 'powershell.exe', + 'powershell.exe' + ]) + expect(warming.worktrees[0].sessions[0].cpu).toBe(0) + expect(recovered.worktrees[0].sessions[0].cpu).toBe(100) + }) + + it('sums committed private bytes across the whole PTY subtree on Windows', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + // Working set stays small while commit is 10-40x larger — the reported shape. + const rows = [ + '10\t1\t52428800\t0\t0\t638830000000000000\t1048576', + '11\t10\t104857600\t0\t0\t638830000000000000\t2097152', + '12\t11\t52428800\t0\t0\t638830000000000000\t524288', + '900\t1\t20971520\t0\t0\t638830000000000000\t262144' + ].join('\r\n') + runProcessMock.mockResolvedValue({ + code: 0, + signal: null, + stdout: rows, + stderr: '', + timedOut: false + }) + appMetricsMock.mockReturnValue([ + { pid: 900, type: 'Browser', cpu: { percentCPUUsage: 0 }, memory: { workingSetSize: 0 } } + ]) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'pty-1', + worktreeId: 'repo-1::C:\\repo', + sessionId: 'session-1', + paneKey: 'pane-1', + pid: 10 + } + ]) + const { collectMemorySnapshot } = await loadCollector() + + const snapshot = await collectMemorySnapshot(emptyStore) + + const committedKb = 1048576 + 2097152 + 524288 + expect(snapshot.worktrees[0].sessions[0].privateMemory).toBe(committedKb * 1024) + expect(snapshot.worktrees[0].privateMemory).toBe(committedKb * 1024) + expect(snapshot.app.privateMemory).toBe(262144 * 1024) + expect(snapshot.totalPrivateMemory).toBe((committedKb + 262144) * 1024) + expect(snapshot.processCommitMetric).toBe('private-bytes') + // The resident figure keeps its old meaning rather than being redefined. + expect(snapshot.processMemoryMetric).toBe('working-set') + expect(snapshot.worktrees[0].memory).toBe(52428800 + 104857600 + 52428800) + }) + + it('omits the commit metric entirely when the Windows sweep cannot report it', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + runProcessMock.mockResolvedValue({ + code: 0, + signal: null, + stdout: '10\t1\t52428800\t0\t0\t638830000000000000', + stderr: '', + timedOut: false + }) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'pty-1', + worktreeId: 'repo-1::C:\\repo', + sessionId: 'session-1', + paneKey: 'pane-1', + pid: 10 + } + ]) + const { collectMemorySnapshot } = await loadCollector() + + const snapshot = await collectMemorySnapshot(emptyStore) + + // Why not zero: a host that cannot measure commit must be distinguishable + // from agents that hold none. + expect(snapshot.processCommitMetric).toBeUndefined() + expect(snapshot.totalPrivateMemory).toBeUndefined() + expect(snapshot.worktrees[0].privateMemory).toBeUndefined() + expect(snapshot.worktrees[0].sessions[0].privateMemory).toBeUndefined() + expect(snapshot.app.privateMemory).toBeUndefined() + expect(snapshot.totalMemory).toBe(52428800) + }) + + it('carries no commit metric on Unix, where ps has no committed-bytes column', async () => { + vi.spyOn(os, 'platform').mockReturnValue('darwin') + mockPsResponse('10 1 0 1024') + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'pty-1', + worktreeId: 'repo-1::/repo', + sessionId: 'session-1', + paneKey: 'pane-1', + pid: 10 + } + ]) + const { collectMemorySnapshot } = await loadCollector() + + const snapshot = await collectMemorySnapshot(emptyStore) + + expect(snapshot.processMemoryMetric).toBe('rss') + expect(snapshot.processCommitMetric).toBeUndefined() + expect(snapshot.totalPrivateMemory).toBeUndefined() + expect(snapshot.worktrees[0].sessions[0].privateMemory).toBeUndefined() + }) + + it("attributes a shared ancestor's commit to one PTY only", async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + runProcessMock.mockResolvedValue({ + code: 0, + signal: null, + stdout: [ + '10\t1\t1024\t0\t0\t638830000000000000\t1024', + '11\t10\t1024\t0\t0\t638830000000000000\t2048' + ].join('\r\n'), + stderr: '', + timedOut: false + }) + listRegisteredPtysMock.mockReturnValue([ + { ptyId: 'a', worktreeId: 'repo::C:\\a', sessionId: 's-a', paneKey: null, pid: 10 }, + { ptyId: 'b', worktreeId: 'repo::C:\\b', sessionId: 's-b', paneKey: null, pid: 11 } + ]) + const { collectMemorySnapshot } = await loadCollector() + + const snapshot = await collectMemorySnapshot(emptyStore) + + expect(snapshot.worktrees[0].sessions[0].privateMemory).toBe((1024 + 2048) * 1024) + expect(snapshot.worktrees[1].sessions[0].privateMemory).toBe(0) + expect(snapshot.totalPrivateMemory).toBe((1024 + 2048) * 1024) + }) +}) diff --git a/src/main/memory/collector.test.ts b/src/main/memory/collector.test.ts index 22e55825923..b1fe8095171 100644 --- a/src/main/memory/collector.test.ts +++ b/src/main/memory/collector.test.ts @@ -52,11 +52,6 @@ async function loadCollector() { return await import('./collector') } -async function loadWindowsProcessResourceCollector() { - vi.resetModules() - return await import('./windows-process-resource-collector') -} - const emptyStore = { getWorktreeMeta: () => undefined, getRepo: () => undefined @@ -125,68 +120,6 @@ describe('parsePsOutput', () => { }) }) -describe('parseWindowsProcessOutput', () => { - it('parses tab-delimited CIM process rows', async () => { - const { parseWindowsProcessOutput } = await loadWindowsProcessResourceCollector() - - expect(parseWindowsProcessOutput('100\t1\t2048\r\n200\t100\t1024')).toEqual([ - { pid: 100, ppid: 1, cpu: 0, memory: 2048 }, - { pid: 200, ppid: 100, cpu: 0, memory: 1024 } - ]) - }) - - it('skips malformed rows and clamps invalid memory to zero', async () => { - const { parseWindowsProcessOutput } = await loadWindowsProcessResourceCollector() - - expect( - parseWindowsProcessOutput( - [ - 'garbage', - 'abc\t1\t100', - '10\txyz\t100', - '0\t0\t100', - '-5\t0\t100', - '30\t-1\t100', - '20\t1\t-50' - ].join('\n') - ) - ).toEqual([{ pid: 20, ppid: 1, cpu: 0, memory: 0 }]) - }) - - it('preserves empty CIM field positions instead of shifting CPU ticks into memory', async () => { - const { parseWindowsProcessOutput } = await loadWindowsProcessResourceCollector() - - expect(parseWindowsProcessOutput('100\t1\t\t200\t300\t638830000000000000')).toEqual([ - { pid: 100, ppid: 1, cpu: 0, memory: 0 } - ]) - }) -}) - -describe('parseTypeperfProcessOutput', () => { - it('joins PID, parent PID, and working-set counters by process instance', async () => { - const { parseTypeperfProcessOutput } = await loadWindowsProcessResourceCollector() - const stdout = [ - '"(PDH-CSV 4.0)","\\\\HOST\\Process(node)\\ID Process","\\\\HOST\\Process(node#1)\\ID Process","\\\\HOST\\Process(node)\\Creating Process ID","\\\\HOST\\Process(node#1)\\Creating Process ID","\\\\HOST\\Process(node)\\Working Set","\\\\HOST\\Process(node#1)\\Working Set"', - '"07/15/2026 01:44:54.514","100.000000","200.000000","1.000000","100.000000","2048.000000","4096.000000"' - ].join('\r\n') - - expect(parseTypeperfProcessOutput(stdout)).toEqual([ - { pid: 100, ppid: 1, cpu: 0, memory: 2048 }, - { pid: 200, ppid: 100, cpu: 0, memory: 4096 } - ]) - }) - - it('ignores aggregate and incomplete rows and clamps invalid memory', async () => { - const { parseTypeperfProcessOutput } = await loadWindowsProcessResourceCollector() - const stdout = [ - '"(PDH-CSV 4.0)","\\\\HOST\\Process(_Total)\\ID Process","\\\\HOST\\Process(cmd)\\ID Process","\\\\HOST\\Process(orphan)\\ID Process","\\\\HOST\\Process(_Total)\\Creating Process ID","\\\\HOST\\Process(cmd)\\Creating Process ID","\\\\HOST\\Process(_Total)\\Working Set","\\\\HOST\\Process(cmd)\\Working Set"', - '"time","0.000000","100.000000","200.000000","0.000000","1.000000","999999.000000","-1.000000"' - ].join('\r\n') - - expect(parseTypeperfProcessOutput(stdout)).toEqual([{ pid: 100, ppid: 1, cpu: 0, memory: 0 }]) - }) -}) - describe('collectSubtree', () => { function makeIndex(rows: { pid: number; ppid: number }[]) { const byPid = new Map() @@ -200,7 +133,7 @@ describe('collectSubtree', () => { childrenOf.set(r.ppid, [r.pid]) } } - return { byPid, childrenOf } + return { byPid, childrenOf, hasPrivateMemory: false } } it('walks every descendant of the root inclusive', async () => { @@ -240,7 +173,8 @@ describe('collectSubtree', () => { // those as "walked" but do not fabricate a row for them. const index = { byPid: new Map([[1, { pid: 1, ppid: 0, cpu: 0, memory: 0 }]]), - childrenOf: new Map([[1, [2]]]) + childrenOf: new Map([[1, [2]]]), + hasPrivateMemory: false } expect(collectSubtree(index, 1)).toEqual([1]) @@ -336,364 +270,6 @@ describe('collectMemorySnapshot', () => { expect(execMock).toHaveBeenCalledTimes(count) } - it('uses one CIM process for Windows memory and CPU sampling', async () => { - vi.spyOn(os, 'platform').mockReturnValue('win32') - mockPsResponse('10 1 0 1024') - const { collectMemorySnapshot } = await loadCollector() - - await collectMemorySnapshot(emptyStore) - - expect(execMock).not.toHaveBeenCalled() - expect(runProcessMock).toHaveBeenCalledTimes(1) - const spec = runProcessMock.mock.calls[0][0] - expect(spec.program).toBe('powershell.exe') - expect(spec.args.join(' ')).toContain('Get-CimInstance Win32_Process') - expect(spec.args.join(' ')).toContain('KernelModeTime') - expect(spec.args.join(' ')).toContain('UserModeTime') - expect(spec.args.join(' ')).toContain('CreationDate') - expect(spec).toMatchObject({ maxOutputBytes: 10 * 1024 * 1024, timeoutMs: 5_000 }) - }) - - it('attributes Windows process CPU from cumulative time deltas between sweeps', async () => { - vi.spyOn(os, 'platform').mockReturnValue('win32') - vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(3_000) - const cpuOutputs = [ - '10\t1\t1048576\t10000000\t0\t638830000000000000', - '10\t1\t1048576\t30000000\t0\t638830000000000000' - ] - runProcessMock.mockImplementation(() => - Promise.resolve({ - code: 0, - signal: null, - stdout: cpuOutputs.shift() ?? '', - stderr: '', - timedOut: false - }) - ) - listRegisteredPtysMock.mockReturnValue([ - { - ptyId: 'windows-cpu-pty', - worktreeId: 'repo-1::C:\\repo', - sessionId: 'session-1', - paneKey: 'pane-1', - pid: 10 - } - ]) - const { collectMemorySnapshot } = await loadCollector() - - const first = await collectMemorySnapshot(emptyStore) - const second = await collectMemorySnapshot(emptyStore) - - expect(first.worktrees[0].sessions[0].cpu).toBe(0) - expect(second.worktrees[0].sessions[0].cpu).toBe(100) - expect(runProcessMock.mock.calls.map(([spec]) => spec.program)).toEqual([ - 'powershell.exe', - 'powershell.exe' - ]) - }) - - it('does not attribute prior CPU time after Windows reuses a process id', async () => { - vi.spyOn(os, 'platform').mockReturnValue('win32') - vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(3_000) - const cpuOutputs = [ - '10\t1\t1048576\t10000000\t0\t638830000000000000', - '10\t1\t1048576\t30000000\t0\t638830000000000001' - ] - runProcessMock.mockImplementation(() => - Promise.resolve({ - code: 0, - signal: null, - stdout: cpuOutputs.shift() ?? '', - stderr: '', - timedOut: false - }) - ) - listRegisteredPtysMock.mockReturnValue([ - { - ptyId: 'reused-pid-pty', - worktreeId: 'repo-1::C:\\repo', - sessionId: 'session-1', - paneKey: 'pane-1', - pid: 10 - } - ]) - const { collectMemorySnapshot } = await loadCollector() - - await collectMemorySnapshot(emptyStore) - const second = await collectMemorySnapshot(emptyStore) - - expect(second.worktrees[0].sessions[0].cpu).toBe(0) - }) - - it('supports cumulative CPU counters above JavaScript safe integers', async () => { - vi.spyOn(os, 'platform').mockReturnValue('win32') - vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(3_000) - const cpuOutputs = [ - '10\t1\t1048576\t90071992547409920\t0\t638830000000000000', - '10\t1\t1048576\t90071992567409920\t0\t638830000000000000' - ] - runProcessMock.mockImplementation(() => - Promise.resolve({ - code: 0, - signal: null, - stdout: cpuOutputs.shift() ?? '', - stderr: '', - timedOut: false - }) - ) - listRegisteredPtysMock.mockReturnValue([ - { - ptyId: 'large-counter-pty', - worktreeId: 'repo-1::C:\\repo', - sessionId: 'session-1', - paneKey: 'pane-1', - pid: 10 - } - ]) - const { collectMemorySnapshot } = await loadCollector() - - await collectMemorySnapshot(emptyStore) - const second = await collectMemorySnapshot(emptyStore) - - expect(second.worktrees[0].sessions[0].cpu).toBe(100) - }) - - it('keeps the older CPU baseline when forced snapshots are too close together', async () => { - vi.spyOn(os, 'platform').mockReturnValue('win32') - vi.spyOn(performance, 'now') - .mockReturnValueOnce(1_000) - .mockReturnValueOnce(1_100) - .mockReturnValueOnce(3_000) - const cpuOutputs = [ - '10\t1\t1048576\t0\t0\t638830000000000000', - '10\t1\t1048576\t1000000\t0\t638830000000000000', - '10\t1\t1048576\t20000000\t0\t638830000000000000' - ] - runProcessMock.mockImplementation(() => - Promise.resolve({ - code: 0, - signal: null, - stdout: cpuOutputs.shift() ?? '', - stderr: '', - timedOut: false - }) - ) - listRegisteredPtysMock.mockReturnValue([ - { - ptyId: 'short-sample-pty', - worktreeId: 'repo-1::C:\\repo', - sessionId: 'session-1', - paneKey: 'pane-1', - pid: 10 - } - ]) - const { collectMemorySnapshot } = await loadCollector() - - await collectMemorySnapshot(emptyStore) - const tooSoon = await collectMemorySnapshot(emptyStore) - const normalPoll = await collectMemorySnapshot(emptyStore) - - expect(tooSoon.worktrees[0].sessions[0].cpu).toBe(0) - expect(normalPoll.worktrees[0].sessions[0].cpu).toBe(100) - }) - - it('caps impossible Windows CPU deltas at the host core capacity', async () => { - vi.spyOn(os, 'platform').mockReturnValue('win32') - vi.spyOn(os, 'cpus').mockReturnValue([{}, {}] as ReturnType) - vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(3_000) - const cpuOutputs = [ - '10\t1\t1048576\t0\t0\t638830000000000000', - '10\t1\t1048576\t1000000000\t0\t638830000000000000' - ] - runProcessMock.mockImplementation(() => - Promise.resolve({ - code: 0, - signal: null, - stdout: cpuOutputs.shift() ?? '', - stderr: '', - timedOut: false - }) - ) - listRegisteredPtysMock.mockReturnValue([ - { - ptyId: 'impossible-cpu-pty', - worktreeId: 'repo-1::C:\\repo', - sessionId: 'session-1', - paneKey: 'pane-1', - pid: 10 - } - ]) - const { collectMemorySnapshot } = await loadCollector() - - await collectMemorySnapshot(emptyStore) - const capped = await collectMemorySnapshot(emptyStore) - - expect(capped.worktrees[0].sessions[0].cpu).toBe(200) - }) - - it('warms CPU sampling again after Resource Manager was closed', async () => { - vi.spyOn(os, 'platform').mockReturnValue('win32') - vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(12_000) - const cpuOutputs = [ - '10\t1\t1048576\t0\t0\t638830000000000000', - '10\t1\t1048576\t100000000\t0\t638830000000000000' - ] - runProcessMock.mockImplementation(() => - Promise.resolve({ - code: 0, - signal: null, - stdout: cpuOutputs.shift() ?? '', - stderr: '', - timedOut: false - }) - ) - listRegisteredPtysMock.mockReturnValue([ - { - ptyId: 'stale-counter-pty', - worktreeId: 'repo-1::C:\\repo', - sessionId: 'session-1', - paneKey: 'pane-1', - pid: 10 - } - ]) - const { collectMemorySnapshot } = await loadCollector() - - await collectMemorySnapshot(emptyStore) - const reopened = await collectMemorySnapshot(emptyStore) - - expect(reopened.worktrees[0].sessions[0].cpu).toBe(0) - }) - - it('preserves Windows process memory when CPU counters are unavailable', async () => { - vi.spyOn(os, 'platform').mockReturnValue('win32') - runProcessMock.mockImplementation(() => - Promise.resolve({ - code: 0, - signal: null, - stdout: '10\t1\t1048576\t\t\t638830000000000000', - stderr: '', - timedOut: false - }) - ) - listRegisteredPtysMock.mockReturnValue([ - { - ptyId: 'cpu-failure-pty', - worktreeId: 'repo-1::C:\\repo', - sessionId: 'session-1', - paneKey: 'pane-1', - pid: 10 - } - ]) - const { collectMemorySnapshot } = await loadCollector() - - const snapshot = await collectMemorySnapshot(emptyStore) - - expect(snapshot.worktrees[0].sessions[0]).toMatchObject({ cpu: 0, memory: 1024 * 1024 }) - }) - - it('uses Typeperf during the CIM retry cooldown', async () => { - vi.spyOn(os, 'platform').mockReturnValue('win32') - runProcessMock.mockImplementation((spec: { program: string }) => - Promise.resolve( - spec.program === 'powershell.exe' - ? { code: 1, signal: null, stdout: '', stderr: 'CIM unavailable', timedOut: false } - : { - code: 0, - signal: null, - stdout: psFixtureToTypeperfOutput('10 1 0 1024'), - stderr: '', - timedOut: false - } - ) - ) - listRegisteredPtysMock.mockReturnValue([ - { - ptyId: 'cim-pty', - worktreeId: null, - sessionId: null, - paneKey: null, - pid: 10 - } - ]) - const { collectMemorySnapshot } = await loadCollector() - - const first = await collectMemorySnapshot(emptyStore) - const second = await collectMemorySnapshot(emptyStore) - - expect(runProcessMock).toHaveBeenCalledTimes(3) - expect(runProcessMock.mock.calls.map(([spec]) => spec.program)).toEqual([ - 'powershell.exe', - 'typeperf.exe', - 'typeperf.exe' - ]) - expect(runProcessMock.mock.calls[1][0]).toMatchObject({ timeoutMs: 5_000 }) - expect(first.worktrees[0].memory).toBe(1048576) - expect(second.worktrees[0].memory).toBe(1048576) - }) - - it('retries CIM after fallback and warms CPU sampling before restoring deltas', async () => { - vi.spyOn(os, 'platform').mockReturnValue('win32') - vi.spyOn(performance, 'now') - .mockReturnValueOnce(1_000) - .mockReturnValueOnce(2_000) - .mockReturnValueOnce(31_001) - .mockReturnValueOnce(32_000) - .mockReturnValueOnce(34_000) - const cimOutputs = [ - '10\t1\t1048576\t10000000\t0\t638830000000000000', - '10\t1\t1048576\t30000000\t0\t638830000000000000' - ] - let cimCalls = 0 - runProcessMock.mockImplementation((spec: { program: string }) => { - if (spec.program === 'typeperf.exe') { - return Promise.resolve({ - code: 0, - signal: null, - stdout: psFixtureToTypeperfOutput('10 1 0 1024'), - stderr: '', - timedOut: false - }) - } - cimCalls += 1 - return Promise.resolve( - cimCalls === 1 - ? { code: 1, signal: null, stdout: '', stderr: 'transient CIM failure', timedOut: false } - : { - code: 0, - signal: null, - stdout: cimOutputs.shift() ?? '', - stderr: '', - timedOut: false - } - ) - }) - listRegisteredPtysMock.mockReturnValue([ - { - ptyId: 'recovering-cim-pty', - worktreeId: 'repo-1::C:\\repo', - sessionId: 'session-1', - paneKey: 'pane-1', - pid: 10 - } - ]) - const { collectMemorySnapshot } = await loadCollector() - - await collectMemorySnapshot(emptyStore) - await collectMemorySnapshot(emptyStore) - const warming = await collectMemorySnapshot(emptyStore) - const recovered = await collectMemorySnapshot(emptyStore) - - expect(runProcessMock.mock.calls.map(([spec]) => spec.program)).toEqual([ - 'powershell.exe', - 'typeperf.exe', - 'typeperf.exe', - 'powershell.exe', - 'powershell.exe' - ]) - expect(warming.worktrees[0].sessions[0].cpu).toBe(0) - expect(recovered.worktrees[0].sessions[0].cpu).toBe(100) - }) - it('coalesces concurrent callers onto a single in-flight sweep', async () => { // Why: the collector exists in part to prevent a burst of renderer // polls from spawning overlapping `ps` children. If a regression ever diff --git a/src/main/memory/collector.ts b/src/main/memory/collector.ts index b48a6bb75ed..c0e77513004 100644 --- a/src/main/memory/collector.ts +++ b/src/main/memory/collector.ts @@ -30,7 +30,9 @@ import { getAppEnvironment, type AppEnvironment } from '../../shared/app-environ import type { AppMemory, MemorySnapshot, + ProcessCommitMetric, SessionMemory, + UsageValues, WorktreeMemory } from '../../shared/process-stats-types' import type { Store } from '../persistence' @@ -81,12 +83,43 @@ type ProcRow = { cpu: number /** Resident memory in bytes. */ memory: number + /** Committed bytes, resident or paged out. Absent when the host cannot report it. */ + privateMemory?: number } /** Indexed view of a single host process sweep. */ type ProcIndex = { byPid: Map childrenOf: Map + /** + * Whether this sweep reported committed bytes at all. Data-driven rather than + * platform-driven: the Windows typeperf fallback can be missing the counter, + * and reporting a 0 sum then would read as "agents commit nothing". + */ + hasPrivateMemory: boolean +} + +const PROCESS_COMMIT_METRIC: ProcessCommitMetric = 'private-bytes' + +/** + * The one rule for every committed-bytes key: present only when the sweep could + * measure it, because a 0 would read as "these processes commit nothing". + */ +function commitField(hasPrivateMemory: boolean, privateMemory: number): { privateMemory?: number } { + return hasPrivateMemory ? { privateMemory: clampNumber(privateMemory) } : {} +} + +/** The snapshot-level pair, which names the unit alongside the total. */ +function snapshotCommitFields( + hasPrivateMemory: boolean, + totalPrivateMemory: number +): Pick { + return hasPrivateMemory + ? { + processCommitMetric: PROCESS_COMMIT_METRIC, + totalPrivateMemory: clampNumber(totalPrivateMemory) + } + : {} } function clampNumber(value: unknown): number { @@ -155,9 +188,11 @@ async function enumerateProcesses(): Promise { const byPid = new Map() const childrenOf = new Map() + let hasPrivateMemory = false for (const row of rows) { byPid.set(row.pid, row) + hasPrivateMemory ||= row.privateMemory !== undefined const siblings = childrenOf.get(row.ppid) if (siblings) { siblings.push(row.pid) @@ -166,7 +201,7 @@ async function enumerateProcesses(): Promise { } } - return { byPid, childrenOf } + return { byPid, childrenOf, hasPrivateMemory } } async function enumerateUnix(): Promise { @@ -261,13 +296,16 @@ function electronMetricMemoryBytes( } function bucketElectronMetrics(processIndex: ProcIndex): AppBucketsRaw { - const main = { cpu: 0, memory: 0 } - const renderer = { cpu: 0, memory: 0 } - const other = { cpu: 0, memory: 0 } + const main = { cpu: 0, memory: 0, privateMemory: 0 } + const renderer = { cpu: 0, memory: 0, privateMemory: 0 } + const other = { cpu: 0, memory: 0, privateMemory: 0 } for (const proc of getAppEnvironment().getAppMetrics()) { const cpu = clampNumber(proc.cpu?.percentCPUUsage) const memoryBytes = electronMetricMemoryBytes(proc, processIndex) + // Why the host row rather than Electron's own metric: getAppMetrics has no + // commit figure for helper processes, and the sweep already indexed them. + const privateBytes = clampNumber(processIndex.byPid.get(proc.pid)?.privateMemory) // Why: lowercase once so future Electron versions emitting different // casing ('browser' vs 'Browser') still bucket correctly. @@ -281,14 +319,24 @@ function bucketElectronMetrics(processIndex: ProcIndex): AppBucketsRaw { target.cpu += cpu target.memory += memoryBytes + target.privateMemory += privateBytes } + const usage = (bucket: typeof main): UsageValues => ({ + cpu: bucket.cpu, + memory: bucket.memory, + ...commitField(processIndex.hasPrivateMemory, bucket.privateMemory) + }) + return { - main, - renderer, - other, - cpu: main.cpu + renderer.cpu + other.cpu, - memory: main.memory + renderer.memory + other.memory + main: usage(main), + renderer: usage(renderer), + other: usage(other), + ...usage({ + cpu: main.cpu + renderer.cpu + other.cpu, + memory: main.memory + renderer.memory + other.memory, + privateMemory: main.privateMemory + renderer.privateMemory + other.privateMemory + }) } } @@ -301,6 +349,7 @@ type WorktreeBucket = { repoName: string cpu: number memory: number + privateMemory: number sessions: SessionMemory[] } @@ -334,7 +383,16 @@ function makeEmptyBucket( repoId: string, repoName: string ): WorktreeBucket { - return { worktreeId, worktreeName, repoId, repoName, cpu: 0, memory: 0, sessions: [] } + return { + worktreeId, + worktreeName, + repoId, + repoName, + cpu: 0, + memory: 0, + privateMemory: 0, + sessions: [] + } } // ─── Main collection path ─────────────────────────────────────────── @@ -361,6 +419,7 @@ async function runSnapshot(store: MemorySnapshotStore): Promise for (const pty of ptys) { let sessionCpu = 0 let sessionMemory = 0 + let sessionPrivateMemory = 0 if (pty.pid != null) { for (const pid of collectSubtree(processIndex, pty.pid)) { @@ -374,6 +433,9 @@ async function runSnapshot(store: MemorySnapshotStore): Promise claimed.add(pid) sessionCpu += row.cpu sessionMemory += row.memory + // Why the whole subtree: an agent's committed bytes live in the + // children it spawned (codex.exe, MCP servers), not in the shell. + sessionPrivateMemory += clampNumber(row.privateMemory) } } @@ -382,7 +444,8 @@ async function runSnapshot(store: MemorySnapshotStore): Promise paneKey: pty.paneKey, pid: pty.pid ?? 0, cpu: clampNumber(sessionCpu), - memory: clampNumber(sessionMemory) + memory: clampNumber(sessionMemory), + ...commitField(processIndex.hasPrivateMemory, sessionPrivateMemory) } let bucket: WorktreeBucket @@ -401,6 +464,7 @@ async function runSnapshot(store: MemorySnapshotStore): Promise bucket.cpu += session.cpu bucket.memory += session.memory + bucket.privateMemory += clampNumber(session.privateMemory) bucket.sessions.push(session) } @@ -419,16 +483,19 @@ async function runSnapshot(store: MemorySnapshotStore): Promise } sweepStaleHistory(now) - const worktrees: WorktreeMemory[] = bucketList.map((b) => ({ + const worktrees: WorktreeMemory[] = bucketList.map(({ privateMemory, ...b }) => ({ ...b, + ...commitField(processIndex.hasPrivateMemory, privateMemory), history: readHistory(b.worktreeId) })) let sessionCpuTotal = 0 let sessionMemoryTotal = 0 + let sessionPrivateTotal = 0 for (const wt of worktrees) { sessionCpuTotal += wt.cpu sessionMemoryTotal += wt.memory + sessionPrivateTotal += clampNumber(wt.privateMemory) } return { @@ -436,6 +503,10 @@ async function runSnapshot(store: MemorySnapshotStore): Promise worktrees, host, processMemoryMetric: getProcessMemoryMetric(), + ...snapshotCommitFields( + processIndex.hasPrivateMemory, + clampNumber(appBuckets.privateMemory) + sessionPrivateTotal + ), totalCpu: appBuckets.cpu + sessionCpuTotal, totalMemory: appBuckets.memory + sessionMemoryTotal, collectedAt: now diff --git a/src/main/memory/windows-process-resource-collector.ts b/src/main/memory/windows-process-resource-collector.ts index 5c929ebf376..bc7e85a79f8 100644 --- a/src/main/memory/windows-process-resource-collector.ts +++ b/src/main/memory/windows-process-resource-collector.ts @@ -2,50 +2,24 @@ import { runProcess } from '../../shared/child-process/run-process' import os from 'node:os' import { performance } from 'node:perf_hooks' import { - iterateProcessOutputLines, - PROCESS_OUTPUT_FIELD_SCAN_MAX_CHARS -} from '../../shared/process-output-field-scanner' + parseTypeperfProcessOutput, + parseWindowsProcessSample, + TYPEPERF_COUNTERS, + type ParsedWindowsProcessSample, + type WindowsProcessResourceRow +} from './windows-process-sample-parsing' + +export type { WindowsProcessResourceRow } from './windows-process-sample-parsing' const PROCESS_QUERY_TIMEOUT_MS = 5_000 const PROCESS_QUERY_MAX_BUFFER = 10 * 1024 * 1024 -const TYPEPERF_COUNTERS = [ - '\\Process(*)\\ID Process', - '\\Process(*)\\Creating Process ID', - '\\Process(*)\\Working Set' -] as const -const TYPEPERF_MAX_FIELDS = 8_192 -const TYPEPERF_MAX_LINE_CHARS = 1024 * 1024 const CPU_MIN_SAMPLE_MS = 250 const CPU_STALE_AFTER_MS = 10_000 const HUNDRED_NS_TICKS_PER_MS = 10_000 const CIM_RETRY_AFTER_MS = 30_000 -export type WindowsProcessResourceRow = { - pid: number - ppid: number - /** Percent of one core (may exceed 100 on multi-core). */ - cpu: number - /** Resident memory in bytes. */ - memory: number -} - -type WindowsCpuTimes = { - cpuTicks: bigint - startTimeId: string -} - -type WindowsProcessSample = { +type WindowsProcessSample = ParsedWindowsProcessSample & { sampledAtMs: number - rows: WindowsProcessResourceRow[] - cpuByPid: Map -} - -type ParsedWindowsProcessSample = Omit - -type TypeperfProcessFields = { - pid?: number - ppid?: number - memory?: number } let processBackend: 'cim' | 'typeperf' = 'cim' @@ -114,14 +88,16 @@ function applyWindowsCpuSample(sample: WindowsProcessSample): WindowsProcessReso } async function enumerateWindowsWithCim(): Promise { + // PageFileUsage rides along on the sweep that already runs: it is the commit + // charge the working set stops showing once Windows starts trimming pages. const args = [ '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', "$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'; " + - 'Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,WorkingSetSize,KernelModeTime,UserModeTime,CreationDate | ' + - 'ForEach-Object { try { [string]::Join([char]9, @($_.ProcessId, $_.ParentProcessId, $_.WorkingSetSize, [string]$_.KernelModeTime, [string]$_.UserModeTime, $_.CreationDate.ToUniversalTime().Ticks)) } catch {} }' + 'Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,WorkingSetSize,KernelModeTime,UserModeTime,CreationDate,PageFileUsage | ' + + 'ForEach-Object { try { [string]::Join([char]9, @($_.ProcessId, $_.ParentProcessId, $_.WorkingSetSize, [string]$_.KernelModeTime, [string]$_.UserModeTime, $_.CreationDate.ToUniversalTime().Ticks, $_.PageFileUsage)) } catch {} }' ] try { const stdout = await execFileText('powershell.exe', args) @@ -164,169 +140,6 @@ async function execFileText(file: string, args: string[]): Promise { return result.stdout } -function parseWindowsProcessSample(stdout: string): ParsedWindowsProcessSample { - const rows: WindowsProcessResourceRow[] = [] - const cpuByPid = new Map() - for (const line of iterateProcessOutputLines(stdout)) { - const fields = parseCimTabFields(line) - if (fields.length < 3) { - continue - } - const pid = Number.parseInt(fields[0], 10) - const ppid = Number.parseInt(fields[1], 10) - const memory = Number.parseInt(fields[2], 10) - if (!Number.isSafeInteger(pid) || pid <= 0 || !Number.isSafeInteger(ppid) || ppid < 0) { - continue - } - rows.push({ - pid, - ppid, - cpu: 0, - memory: Number.isFinite(memory) && memory > 0 ? memory : 0 - }) - - const kernelTicks = parseUnsignedBigInt(fields[3]) - const userTicks = parseUnsignedBigInt(fields[4]) - const startTimeId = fields[5] ?? '' - if ( - kernelTicks !== null && - userTicks !== null && - /^\d+$/.test(startTimeId) && - !/^0+$/.test(startTimeId) - ) { - cpuByPid.set(pid, { cpuTicks: kernelTicks + userTicks, startTimeId }) - } - } - return { rows, cpuByPid } -} - -function parseCimTabFields(line: string): string[] { - // Why: CIM serializes null properties as empty tab fields; collapsing - // whitespace would shift CPU counters into the working-set column. - if (line.length > PROCESS_OUTPUT_FIELD_SCAN_MAX_CHARS) { - return [] - } - return line.split('\t', 6).map((field) => field.trim()) -} - -/** Parse tab-delimited PowerShell CIM process rows without deriving CPU deltas. */ -export function parseWindowsProcessOutput(stdout: string): WindowsProcessResourceRow[] { - return parseWindowsProcessSample(stdout).rows -} - -/** Parse one CSV sample from Windows Typeperf. */ -export function parseTypeperfProcessOutput(stdout: string): WindowsProcessResourceRow[] { - let headers: string[] | null = null - let values: string[] | null = null - - for (const line of iterateProcessOutputLines(stdout)) { - if (!line || line.length > TYPEPERF_MAX_LINE_CHARS) { - continue - } - const fields = parseTypeperfCsvLine(line) - if (!headers && fields[0]?.startsWith('(PDH-CSV')) { - headers = fields - continue - } - if (headers && fields.length === headers.length) { - values = fields - break - } - } - - if (!headers || !values) { - return [] - } - - const byInstance = new Map() - for (let index = 1; index < headers.length; index += 1) { - const path = parseTypeperfCounterPath(headers[index]) - if (!path || path.instance === '_Total') { - continue - } - const value = Number.parseFloat(values[index]) - if (!Number.isFinite(value)) { - continue - } - const row = byInstance.get(path.instance) ?? {} - if (path.counter === 'ID Process') { - row.pid = Math.trunc(value) - } else if (path.counter === 'Creating Process ID') { - row.ppid = Math.trunc(value) - } else if (path.counter === 'Working Set') { - row.memory = value - } - byInstance.set(path.instance, row) - } - - const rows: WindowsProcessResourceRow[] = [] - for (const row of byInstance.values()) { - if (row.pid === undefined || row.pid <= 0 || row.ppid === undefined || row.ppid < 0) { - continue - } - rows.push({ - pid: row.pid, - ppid: row.ppid, - cpu: 0, - memory: row.memory !== undefined && row.memory > 0 ? row.memory : 0 - }) - } - return rows -} - -function parseTypeperfCounterPath(path: string): { instance: string; counter: string } | null { - const processStart = path.lastIndexOf('\\Process(') - const counterStart = path.lastIndexOf(')\\') - if (processStart === -1 || counterStart <= processStart + 9) { - return null - } - return { - instance: path.slice(processStart + 9, counterStart), - counter: path.slice(counterStart + 2) - } -} - -function parseTypeperfCsvLine(line: string): string[] { - const fields: string[] = [] - let value = '' - let quoted = false - - for (let index = 0; index < line.length; index += 1) { - const char = line[index] - if (char === '"') { - if (quoted && line[index + 1] === '"') { - value += '"' - index += 1 - } else { - quoted = !quoted - } - continue - } - if (char === ',' && !quoted) { - fields.push(value) - value = '' - if (fields.length >= TYPEPERF_MAX_FIELDS) { - return [] - } - continue - } - value += char - } - fields.push(value) - return fields -} - -function parseUnsignedBigInt(value: string | undefined): bigint | null { - if (!value || !/^\d+$/.test(value)) { - return null - } - try { - return BigInt(value) - } catch { - return null - } -} - function nonNegativeNumber(value: unknown): number { return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0 } diff --git a/src/main/memory/windows-process-sample-parsing.test.ts b/src/main/memory/windows-process-sample-parsing.test.ts new file mode 100644 index 00000000000..1601d183b80 --- /dev/null +++ b/src/main/memory/windows-process-sample-parsing.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it, vi } from 'vitest' + +async function loadWindowsProcessSampleParsing() { + vi.resetModules() + return await import('./windows-process-sample-parsing') +} + +describe('parseWindowsProcessOutput', () => { + it('parses tab-delimited CIM process rows', async () => { + const { parseWindowsProcessOutput } = await loadWindowsProcessSampleParsing() + + expect(parseWindowsProcessOutput('100\t1\t2048\r\n200\t100\t1024')).toEqual([ + { pid: 100, ppid: 1, cpu: 0, memory: 2048 }, + { pid: 200, ppid: 100, cpu: 0, memory: 1024 } + ]) + }) + + it('skips malformed rows and clamps invalid memory to zero', async () => { + const { parseWindowsProcessOutput } = await loadWindowsProcessSampleParsing() + + expect( + parseWindowsProcessOutput( + [ + 'garbage', + 'abc\t1\t100', + '10\txyz\t100', + '0\t0\t100', + '-5\t0\t100', + '30\t-1\t100', + '20\t1\t-50' + ].join('\n') + ) + ).toEqual([{ pid: 20, ppid: 1, cpu: 0, memory: 0 }]) + }) + + it('reads PageFileUsage kilobytes into committed private bytes', async () => { + const { parseWindowsProcessOutput } = await loadWindowsProcessSampleParsing() + + // 5,600,000 KB of commit behind a 96 MB working set is the reported shape. + expect( + parseWindowsProcessOutput('100\t1\t100663296\t0\t0\t638830000000000000\t5600000') + ).toEqual([{ pid: 100, ppid: 1, cpu: 0, memory: 100663296, privateMemory: 5600000 * 1024 }]) + }) + + it('leaves committed bytes absent when the host omits PageFileUsage', async () => { + const { parseWindowsProcessOutput } = await loadWindowsProcessSampleParsing() + + const [row] = parseWindowsProcessOutput('100\t1\t2048\t0\t0\t638830000000000000') + + expect(row.privateMemory).toBeUndefined() + expect(parseWindowsProcessOutput('100\t1\t2048\t0\t0\t1\t')[0].privateMemory).toBeUndefined() + }) + + it('keeps a zero PageFileUsage distinct from an unreported one', async () => { + const { parseWindowsProcessOutput } = await loadWindowsProcessSampleParsing() + + expect(parseWindowsProcessOutput('4\t0\t2048\t0\t0\t1\t0')[0].privateMemory).toBe(0) + }) + + it('preserves empty CIM field positions instead of shifting CPU ticks into memory', async () => { + const { parseWindowsProcessOutput } = await loadWindowsProcessSampleParsing() + + expect(parseWindowsProcessOutput('100\t1\t\t200\t300\t638830000000000000')).toEqual([ + { pid: 100, ppid: 1, cpu: 0, memory: 0 } + ]) + }) +}) + +describe('parseTypeperfProcessOutput', () => { + it('joins PID, parent PID, and working-set counters by process instance', async () => { + const { parseTypeperfProcessOutput } = await loadWindowsProcessSampleParsing() + const stdout = [ + '"(PDH-CSV 4.0)","\\\\HOST\\Process(node)\\ID Process","\\\\HOST\\Process(node#1)\\ID Process","\\\\HOST\\Process(node)\\Creating Process ID","\\\\HOST\\Process(node#1)\\Creating Process ID","\\\\HOST\\Process(node)\\Working Set","\\\\HOST\\Process(node#1)\\Working Set"', + '"07/15/2026 01:44:54.514","100.000000","200.000000","1.000000","100.000000","2048.000000","4096.000000"' + ].join('\r\n') + + expect(parseTypeperfProcessOutput(stdout)).toEqual([ + { pid: 100, ppid: 1, cpu: 0, memory: 2048 }, + { pid: 200, ppid: 100, cpu: 0, memory: 4096 } + ]) + }) + + it('joins the Private Bytes counter onto the same process instance', async () => { + const { parseTypeperfProcessOutput } = await loadWindowsProcessSampleParsing() + const stdout = [ + '"(PDH-CSV 4.0)","\\\\HOST\\Process(codex)\\ID Process","\\\\HOST\\Process(codex)\\Creating Process ID","\\\\HOST\\Process(codex)\\Working Set","\\\\HOST\\Process(codex)\\Private Bytes"', + '"07/15/2026 01:44:54.514","100.000000","1.000000","100663296.000000","5734400000.000000"' + ].join('\r\n') + + expect(parseTypeperfProcessOutput(stdout)).toEqual([ + { pid: 100, ppid: 1, cpu: 0, memory: 100663296, privateMemory: 5734400000 } + ]) + }) + + it('leaves committed bytes absent when the Private Bytes counter is missing', async () => { + const { parseTypeperfProcessOutput } = await loadWindowsProcessSampleParsing() + const stdout = [ + '"(PDH-CSV 4.0)","\\\\HOST\\Process(codex)\\ID Process","\\\\HOST\\Process(codex)\\Creating Process ID","\\\\HOST\\Process(codex)\\Working Set"', + '"time","100.000000","1.000000","2048.000000"' + ].join('\r\n') + + expect(parseTypeperfProcessOutput(stdout)[0].privateMemory).toBeUndefined() + }) + + it('still parses a busy host once a fourth counter widens every sample line', async () => { + const { parseTypeperfProcessOutput } = await loadWindowsProcessSampleParsing() + // 2100 processes x 4 counters overruns a fixed 8192-field cap; the reported + // MCP fan-out host runs well past 2048 processes. + const instanceCount = 2100 + const headers = ['"(PDH-CSV 4.0)"'] + const values = ['"time"'] + for (let index = 0; index < instanceCount; index += 1) { + for (const counter of ['ID Process', 'Creating Process ID', 'Working Set', 'Private Bytes']) { + headers.push(`"\\\\HOST\\Process(node#${index})\\${counter}"`) + } + values.push(`"${1000 + index}"`, '"1"', '"2048"', '"4096"') + } + const stdout = [headers.join(','), values.join(',')].join('\r\n') + + const rows = parseTypeperfProcessOutput(stdout) + expect(rows).toHaveLength(instanceCount) + expect(rows[instanceCount - 1]).toEqual({ + pid: 1000 + instanceCount - 1, + ppid: 1, + cpu: 0, + memory: 2048, + privateMemory: 4096 + }) + }) + + it('ignores aggregate and incomplete rows and clamps invalid memory', async () => { + const { parseTypeperfProcessOutput } = await loadWindowsProcessSampleParsing() + const stdout = [ + '"(PDH-CSV 4.0)","\\\\HOST\\Process(_Total)\\ID Process","\\\\HOST\\Process(cmd)\\ID Process","\\\\HOST\\Process(orphan)\\ID Process","\\\\HOST\\Process(_Total)\\Creating Process ID","\\\\HOST\\Process(cmd)\\Creating Process ID","\\\\HOST\\Process(_Total)\\Working Set","\\\\HOST\\Process(cmd)\\Working Set"', + '"time","0.000000","100.000000","200.000000","0.000000","1.000000","999999.000000","-1.000000"' + ].join('\r\n') + + expect(parseTypeperfProcessOutput(stdout)).toEqual([{ pid: 100, ppid: 1, cpu: 0, memory: 0 }]) + }) +}) diff --git a/src/main/memory/windows-process-sample-parsing.ts b/src/main/memory/windows-process-sample-parsing.ts new file mode 100644 index 00000000000..b29c109dbdc --- /dev/null +++ b/src/main/memory/windows-process-sample-parsing.ts @@ -0,0 +1,240 @@ +/** + * Text parsers for the two Windows process-table formats the memory collector + * reads: tab-delimited `Get-CimInstance Win32_Process` rows and one Typeperf + * CSV sample. Kept apart from the backend/CPU-delta orchestration so each side + * stays readable on its own. + */ + +import { + iterateProcessOutputLines, + PROCESS_OUTPUT_FIELD_SCAN_MAX_CHARS +} from '../../shared/process-output-field-scanner' + +/** + * Counter paths typeperf is asked for, kept beside the decoder that reads their + * names back out of the PDH header. + */ +export const TYPEPERF_COUNTERS = [ + '\\Process(*)\\ID Process', + '\\Process(*)\\Creating Process ID', + '\\Process(*)\\Working Set', + '\\Process(*)\\Private Bytes' +] as const + +const TYPEPERF_MAX_INSTANCES = 4_096 +// Why derived: PDH emits one field per counter per instance plus a timestamp, so +// a fixed cap silently shrinks the parsable process count each time a counter is +// added. The 1 MB line cap bounds memory independently. +const TYPEPERF_MAX_FIELDS = 1 + TYPEPERF_COUNTERS.length * TYPEPERF_MAX_INSTANCES +const TYPEPERF_MAX_LINE_CHARS = 1024 * 1024 + +export type WindowsProcessResourceRow = { + pid: number + ppid: number + /** Percent of one core (may exceed 100 on multi-core). */ + cpu: number + /** Resident memory in bytes. */ + memory: number + /** Committed private bytes, resident or paged out. Absent when the host did not report it. */ + privateMemory?: number +} + +export type WindowsCpuTimes = { + cpuTicks: bigint + startTimeId: string +} + +export type ParsedWindowsProcessSample = { + rows: WindowsProcessResourceRow[] + cpuByPid: Map +} + +type TypeperfProcessFields = { + pid?: number + ppid?: number + memory?: number + privateMemory?: number +} + +export function parseWindowsProcessSample(stdout: string): ParsedWindowsProcessSample { + const rows: WindowsProcessResourceRow[] = [] + const cpuByPid = new Map() + for (const line of iterateProcessOutputLines(stdout)) { + const fields = parseCimTabFields(line) + if (fields.length < 3) { + continue + } + const pid = Number.parseInt(fields[0], 10) + const ppid = Number.parseInt(fields[1], 10) + const memory = Number.parseInt(fields[2], 10) + if (!Number.isSafeInteger(pid) || pid <= 0 || !Number.isSafeInteger(ppid) || ppid < 0) { + continue + } + const privateMemory = parseCimPageFileBytes(fields[6]) + rows.push({ + pid, + ppid, + cpu: 0, + memory: Number.isFinite(memory) && memory > 0 ? memory : 0, + ...(privateMemory === null ? {} : { privateMemory }) + }) + + const kernelTicks = parseUnsignedBigInt(fields[3]) + const userTicks = parseUnsignedBigInt(fields[4]) + const startTimeId = fields[5] ?? '' + if ( + kernelTicks !== null && + userTicks !== null && + /^\d+$/.test(startTimeId) && + !/^0+$/.test(startTimeId) + ) { + cpuByPid.set(pid, { cpuTicks: kernelTicks + userTicks, startTimeId }) + } + } + return { rows, cpuByPid } +} + +function parseCimTabFields(line: string): string[] { + // Why: CIM serializes null properties as empty tab fields; collapsing + // whitespace would shift CPU counters into the working-set column. + if (line.length > PROCESS_OUTPUT_FIELD_SCAN_MAX_CHARS) { + return [] + } + return line.split('\t', 7).map((field) => field.trim()) +} + +/** + * Win32_Process.PageFileUsage is a UInt32 of KILOBYTES. null (not 0) when the + * property is missing, because a host that cannot report commit must not be + * indistinguishable from a process holding none. + */ +function parseCimPageFileBytes(field: string | undefined): number | null { + if (!field) { + return null + } + const kb = Number.parseInt(field, 10) + return Number.isSafeInteger(kb) && kb >= 0 ? kb * 1024 : null +} + +/** Parse tab-delimited PowerShell CIM process rows without deriving CPU deltas. */ +export function parseWindowsProcessOutput(stdout: string): WindowsProcessResourceRow[] { + return parseWindowsProcessSample(stdout).rows +} + +/** Parse one CSV sample from Windows Typeperf. */ +export function parseTypeperfProcessOutput(stdout: string): WindowsProcessResourceRow[] { + let headers: string[] | null = null + let values: string[] | null = null + + for (const line of iterateProcessOutputLines(stdout)) { + if (!line || line.length > TYPEPERF_MAX_LINE_CHARS) { + continue + } + const fields = parseTypeperfCsvLine(line) + if (!headers && fields[0]?.startsWith('(PDH-CSV')) { + headers = fields + continue + } + if (headers && fields.length === headers.length) { + values = fields + break + } + } + + if (!headers || !values) { + return [] + } + + const byInstance = new Map() + for (let index = 1; index < headers.length; index += 1) { + const path = parseTypeperfCounterPath(headers[index]) + if (!path || path.instance === '_Total') { + continue + } + const value = Number.parseFloat(values[index]) + if (!Number.isFinite(value)) { + continue + } + const row = byInstance.get(path.instance) ?? {} + if (path.counter === 'ID Process') { + row.pid = Math.trunc(value) + } else if (path.counter === 'Creating Process ID') { + row.ppid = Math.trunc(value) + } else if (path.counter === 'Working Set') { + row.memory = value + } else if (path.counter === 'Private Bytes') { + row.privateMemory = value + } + byInstance.set(path.instance, row) + } + + const rows: WindowsProcessResourceRow[] = [] + for (const row of byInstance.values()) { + if (row.pid === undefined || row.pid <= 0 || row.ppid === undefined || row.ppid < 0) { + continue + } + rows.push({ + pid: row.pid, + ppid: row.ppid, + cpu: 0, + memory: row.memory !== undefined && row.memory > 0 ? row.memory : 0, + ...(row.privateMemory !== undefined && row.privateMemory >= 0 + ? { privateMemory: row.privateMemory } + : {}) + }) + } + return rows +} + +function parseTypeperfCounterPath(path: string): { instance: string; counter: string } | null { + const processStart = path.lastIndexOf('\\Process(') + const counterStart = path.lastIndexOf(')\\') + if (processStart === -1 || counterStart <= processStart + 9) { + return null + } + return { + instance: path.slice(processStart + 9, counterStart), + counter: path.slice(counterStart + 2) + } +} + +function parseTypeperfCsvLine(line: string): string[] { + const fields: string[] = [] + let value = '' + let quoted = false + + for (let index = 0; index < line.length; index += 1) { + const char = line[index] + if (char === '"') { + if (quoted && line[index + 1] === '"') { + value += '"' + index += 1 + } else { + quoted = !quoted + } + continue + } + if (char === ',' && !quoted) { + fields.push(value) + value = '' + if (fields.length >= TYPEPERF_MAX_FIELDS) { + return [] + } + continue + } + value += char + } + fields.push(value) + return fields +} + +function parseUnsignedBigInt(value: string | undefined): bigint | null { + if (!value || !/^\d+$/.test(value)) { + return null + } + try { + return BigInt(value) + } catch { + return null + } +} diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index d93f7a69825..a4d57276d1c 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -67,7 +67,11 @@ import { getResourceManagerAriaLabel, getResourceManagerTooltipLines } from './resource-manager-terminal-copy' -import { getResourceMemoryMetricCopy } from './resource-memory-metric-copy' +import { + getCommitPressureToneClass, + getResourceCommitMetricCopy, + getResourceMemoryMetricCopy +} from './resource-memory-metric-copy' import { requiresKillConfirmation } from './resource-session-kill-confirmation' import { resolveResourceManagerWorktreeTarget } from './resource-manager-worktree-target' import { @@ -960,15 +964,29 @@ export function ResourceUsageStatusSegment({ const memoryMetricCopy = getResourceMemoryMetricCopy( resourceSnapshot?.processMemoryMetric ?? 'rss' ) - const { totalMemory, totalCpu, memBadgeLabel } = useMemo(() => { - const memory = resourceSnapshot?.totalMemory ?? 0 - const cpu = resourceSnapshot?.totalCpu ?? 0 - return { - totalMemory: memory, - totalCpu: cpu, - memBadgeLabel: resourceSnapshot ? formatMemory(memory) : '—' - } - }, [resourceSnapshot]) + // Why null-not-zero: a host that cannot read commit (every Unix host, and any + // host older than the field) must render nothing here, never "0 B committed". + const commitMetricCopy = resourceSnapshot?.processCommitMetric + ? getResourceCommitMetricCopy() + : null + const { totalMemory, totalCpu, memBadgeLabel, totalPrivateMemory, commitToneClass } = + useMemo(() => { + const memory = resourceSnapshot?.totalMemory ?? 0 + const cpu = resourceSnapshot?.totalCpu ?? 0 + const privateMemory = resourceSnapshot?.totalPrivateMemory + return { + totalMemory: memory, + totalCpu: cpu, + memBadgeLabel: resourceSnapshot ? formatMemory(memory) : '—', + totalPrivateMemory: privateMemory, + commitToneClass: getCommitPressureToneClass({ + privateMemory, + hostTotalMemory: resourceSnapshot?.host.totalMemory ?? 0 + }) + } + }, [resourceSnapshot]) + const commitBadgeLabel = + commitMetricCopy && totalPrivateMemory !== undefined ? formatMemory(totalPrivateMemory) : null // Why: memorySnapshotError null means "succeeded" OR "never fetched"; a sessions failure before any snapshot still counts as daemon-unreachable. const daemonUnreachable = sessionsError && (memorySnapshotError !== null || snapshot === null) @@ -976,7 +994,14 @@ export function ResourceUsageStatusSegment({ const sessionsOnlyError = sessionsError && memorySnapshotError === null const resourceManagerTooltipLines = getResourceManagerTooltipLines({ memoryLabel: resourceSnapshot - ? `${memBadgeLabel} · ${memoryMetricCopy.summaryLabel}` + ? [ + `${memBadgeLabel} · ${memoryMetricCopy.summaryLabel}`, + commitBadgeLabel && commitMetricCopy + ? `${commitBadgeLabel} ${commitMetricCopy.summaryLabel}` + : null + ] + .filter(Boolean) + .join(' · ') : memBadgeLabel, sessionCount: triggerSessionCount, spaceScanReady @@ -1162,7 +1187,14 @@ export function ResourceUsageStatusSegment({ {!iconOnly && ( <> - + {/* Tint only: the number stays the resident sum it has always been, + and the tooltip names the commit figure that raised the tone. */} + {memBadgeLabel} · @@ -1353,6 +1385,30 @@ export function ResourceUsageStatusSegment({ {memoryMetricCopy.description} + {commitBadgeLabel && commitMetricCopy && ( + <> + · + + + + {commitBadgeLabel}{' '} + + {commitMetricCopy.summaryLabel} + + + + + {commitMetricCopy.description} + + + + )} {orphanCount > 0 && ( diff --git a/src/renderer/src/components/status-bar/resource-memory-metric-copy.test.ts b/src/renderer/src/components/status-bar/resource-memory-metric-copy.test.ts index c4d65bdb151..b743916e5b6 100644 --- a/src/renderer/src/components/status-bar/resource-memory-metric-copy.test.ts +++ b/src/renderer/src/components/status-bar/resource-memory-metric-copy.test.ts @@ -4,7 +4,11 @@ vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) -import { getResourceMemoryMetricCopy } from './resource-memory-metric-copy' +import { + getCommitPressureToneClass, + getResourceCommitMetricCopy, + getResourceMemoryMetricCopy +} from './resource-memory-metric-copy' describe('resource memory metric copy', () => { it('discloses that Unix RSS sums can repeat shared and aliased pages', () => { @@ -16,11 +20,54 @@ describe('resource memory metric copy', () => { }) }) - it('uses working-set terminology for Windows snapshots', () => { + it('says working set counts only resident pages, so paged-out memory is missing', () => { expect(getResourceMemoryMetricCopy('working-set')).toEqual({ columnLabel: 'WS', summaryLabel: 'Σ WS', - description: 'Summed working set (WS). Shared pages can appear in more than one process.' + description: + 'Summed working set (WS): pages resident in RAM right now. Shared pages can appear in more than one process, and memory Windows has paged out is not counted here.' + }) + }) + + it('labels committed bytes as a separate quantity, not a corrected working set', () => { + expect(getResourceCommitMetricCopy()).toEqual({ + summaryLabel: 'Σ Private', + description: + 'Summed private bytes: memory these processes have committed, counted whether it is resident or paged out. This is what the host charges against its commit limit, so it keeps rising while the working set above shrinks under paging.' }) }) }) + +describe('commit pressure tone', () => { + const hostTotalMemory = 16 * 1024 ** 3 + + it('stays silent while tracked commit is a modest share of RAM', () => { + expect(getCommitPressureToneClass({ privateMemory: 4 * 1024 ** 3, hostTotalMemory })).toBeNull() + }) + + it('warns at the same 60/80 thresholds the host usage bars already use', () => { + expect(getCommitPressureToneClass({ privateMemory: 10 * 1024 ** 3, hostTotalMemory })).toBe( + 'text-yellow-500' + ) + // The reported host: 13.4 GB committed by agents on 16 GB of RAM. + expect(getCommitPressureToneClass({ privateMemory: 13.4 * 1024 ** 3, hostTotalMemory })).toBe( + 'text-red-500' + ) + }) + + it('stays silent for a snapshot that carries no commit figure at all', () => { + expect(getCommitPressureToneClass({ privateMemory: undefined, hostTotalMemory })).toBeNull() + }) + + it('stays silent when the host total is unknown, rather than dividing by zero', () => { + expect( + getCommitPressureToneClass({ privateMemory: 8 * 1024 ** 3, hostTotalMemory: 0 }) + ).toBeNull() + }) + + it('keeps warning above 100% of RAM rather than capping the share', () => { + expect(getCommitPressureToneClass({ privateMemory: 32 * 1024 ** 3, hostTotalMemory })).toBe( + 'text-red-500' + ) + }) +}) diff --git a/src/renderer/src/components/status-bar/resource-memory-metric-copy.ts b/src/renderer/src/components/status-bar/resource-memory-metric-copy.ts index 50448bc4ae6..33105696d7a 100644 --- a/src/renderer/src/components/status-bar/resource-memory-metric-copy.ts +++ b/src/renderer/src/components/status-bar/resource-memory-metric-copy.ts @@ -1,5 +1,6 @@ import type { ProcessMemoryMetric } from '../../../../shared/process-stats-types' import { translate } from '@/i18n/i18n' +import { usageTextColorClass } from './usage-roster-formatting' export type ResourceMemoryMetricCopy = { columnLabel: string @@ -14,7 +15,7 @@ export function getResourceMemoryMetricCopy(metric: ProcessMemoryMetric): Resour summaryLabel: 'Σ WS', description: translate( 'auto.components.status.bar.resource.memory.metric.workingSetDescription', - 'Summed working set (WS). Shared pages can appear in more than one process.' + 'Summed working set (WS): pages resident in RAM right now. Shared pages can appear in more than one process, and memory Windows has paged out is not counted here.' ) } } @@ -27,3 +28,39 @@ export function getResourceMemoryMetricCopy(metric: ProcessMemoryMetric): Resour ) } } + +/** No column of its own yet, so no `columnLabel`: the commit figure is a summary + tooltip. */ +export function getResourceCommitMetricCopy(): Omit { + return { + summaryLabel: 'Σ Private', + description: translate( + 'auto.components.status.bar.resource.memory.metric.privateBytesDescription', + 'Summed private bytes: memory these processes have committed, counted whether it is resident or paged out. This is what the host charges against its commit limit, so it keeps rising while the working set above shrinks under paging.' + ) + } +} + +/** + * Warning tint once *Orca's own* tracked commit grows large against physical + * RAM, on the same 60/80 bands as the host usage bars. Deliberately not a + * host-wide paging predictor: that needs the host's commit charge and commit + * limit, which this snapshot does not carry (#16211). + * + * Null both when the share is unremarkable and when the snapshot has no commit + * figure at all — silence is the honest answer for an unmeasured host. + */ +export function getCommitPressureToneClass(args: { + privateMemory: number | undefined + hostTotalMemory: number +}): string | null { + const { privateMemory, hostTotalMemory } = args + if (typeof privateMemory !== 'number' || !Number.isFinite(privateMemory)) { + return null + } + if (!Number.isFinite(hostTotalMemory) || hostTotalMemory <= 0) { + return null + } + // Uncapped on purpose: commit past 100% of RAM is the loudest case, not an error. + const tone = usageTextColorClass((privateMemory / hostTotalMemory) * 100) + return tone === 'text-foreground' ? null : tone +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c776a97452a..2432d4c5952 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3842,7 +3842,8 @@ "resource": { "memory": { "metric": { - "workingSetDescription": "Summed working set (WS). Shared pages can appear in more than one process.", + "workingSetDescription": "Summed working set (WS): pages resident in RAM right now. Shared pages can appear in more than one process, and memory Windows has paged out is not counted here.", + "privateBytesDescription": "Summed private bytes: memory these processes have committed, counted whether it is resident or paged out. This is what the host charges against its commit limit, so it keeps rising while the working set above shrinks under paging.", "rssDescription": "Summed resident set size (RSS). Shared or aliased pages can appear in more than one process." } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 41b300a10e8..5142116a66b 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3501,7 +3501,8 @@ "resource": { "memory": { "metric": { - "workingSetDescription": "Suma del conjunto de trabajo (WS). Las páginas compartidas pueden aparecer en más de un proceso.", + "workingSetDescription": "Suma del conjunto de trabajo (WS): las páginas residentes en RAM en este momento. Las páginas compartidas pueden aparecer en más de un proceso, y la memoria que Windows ha paginado a disco no se cuenta aquí.", + "privateBytesDescription": "Suma de bytes privados: la memoria que estos procesos han confirmado, contada tanto si está residente como si está paginada a disco. Es lo que el host imputa a su límite de confirmación, así que sigue subiendo mientras el conjunto de trabajo de arriba se reduce por la paginación.", "rssDescription": "Suma del tamaño del conjunto residente (RSS). Las páginas compartidas o con alias pueden aparecer en más de un proceso." } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index d9466efd0c1..3b9ebdd2393 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3501,7 +3501,8 @@ "resource": { "memory": { "metric": { - "workingSetDescription": "合計ワーキングセット(WS)。共有ページが複数のプロセスに表示されることがあります。", + "workingSetDescription": "合計ワーキングセット(WS): 現在 RAM に常駐しているページです。共有ページが複数のプロセスに表示されることがあり、Windows がページアウトしたメモリはここには含まれません。", + "privateBytesDescription": "合計プライベートバイト: これらのプロセスがコミットしたメモリで、常駐中かページアウト済みかを問わず計上されます。ホストがコミット制限に対して計上する値であるため、ページングによって上のワーキングセットが縮小しても増え続けます。", "rssDescription": "合計レジデントセットサイズ(RSS)。共有またはエイリアスされたページが複数のプロセスに表示されることがあります。" } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index f154990358d..e5aefb9291a 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3506,7 +3506,8 @@ "resource": { "memory": { "metric": { - "workingSetDescription": "합산된 워킹 세트(WS). 공유 페이지가 둘 이상의 프로세스에 나타날 수 있습니다.", + "workingSetDescription": "합산된 워킹 세트(WS): 지금 RAM에 상주 중인 페이지입니다. 공유 페이지가 둘 이상의 프로세스에 나타날 수 있으며, Windows가 페이지 아웃한 메모리는 여기에 포함되지 않습니다.", + "privateBytesDescription": "합산된 프라이빗 바이트: 이 프로세스들이 커밋한 메모리로, 상주 여부와 관계없이 계산됩니다. 호스트가 커밋 한도에 반영하는 값이므로, 페이징으로 위의 워킹 세트가 줄어드는 동안에도 계속 늘어납니다.", "rssDescription": "합산된 레지던트 세트 크기(RSS). 공유 또는 별칭된 페이지가 둘 이상의 프로세스에 나타날 수 있습니다." } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index f68271bf57f..6b5cae8fc24 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3516,7 +3516,8 @@ "resource": { "memory": { "metric": { - "workingSetDescription": "工作集 (WS) 的总和。共享页可能会出现在多个进程中。", + "workingSetDescription": "工作集 (WS) 的总和:当前驻留在 RAM 中的页。共享页可能会出现在多个进程中,被 Windows 换出到页面文件的内存不计入其中。", + "privateBytesDescription": "专用字节的总和:这些进程已提交的内存,无论驻留还是已换出都会计入。这是主机计入提交限制的数值,因此在分页导致上方工作集缩小时它仍会继续上升。", "rssDescription": "驻留集大小 (RSS) 的总和。共享页或别名映射页可能会出现在多个进程中。" } }, diff --git a/src/shared/process-stats-types.ts b/src/shared/process-stats-types.ts index ec0187864ab..47933d0978d 100644 --- a/src/shared/process-stats-types.ts +++ b/src/shared/process-stats-types.ts @@ -14,10 +14,29 @@ export type StatsSummary = { export type UsageValues = { cpu: number memory: number + /** + * Committed bytes (see `ProcessCommitMetric`), a second quantity alongside + * `memory` — never a substitute for it. Absent means the host cannot report + * it; absent must never be read as zero. + */ + privateMemory?: number } export type ProcessMemoryMetric = 'rss' | 'working-set' +/** + * Unit of every `privateMemory` field in a snapshot. + * + * `private-bytes` is the Windows private commit charge + * (`Win32_Process.PageFileUsage` / `\Process(*)\Private Bytes`): memory a + * process has committed whether or not it is currently resident. Working set + * counts only resident pages, so an agent whose pages have been trimmed to the + * pagefile shrinks its working set while still holding the commit that pushes + * the host into paging. Unix has no equivalent, so snapshots from those hosts + * carry no commit metric at all. + */ +export type ProcessCommitMetric = 'private-bytes' + export type HostAvailableMemorySource = 'memory-pressure' | 'proc-meminfo' | 'free-memory' /** The top-level cpu/memory are the sum of main + renderer + other. */ @@ -66,9 +85,21 @@ export type MemorySnapshot = { host: HostMemory /** Per-process byte metric used by app, session, worktree, history, and totalMemory values. */ processMemoryMetric: ProcessMemoryMetric + /** + * Names the unit of every `privateMemory` field below. Absent when this sweep + * produced none — an older host, or any host whose process table cannot + * report committed bytes. Readers must treat absence as unknown, not zero. + */ + processCommitMetric?: ProcessCommitMetric /** Sum of app + all tracked worktree sessions. Percent of a single core, so may exceed 100 on multi-core machines. */ totalCpu: number /** Sum of per-process samples. Shared pages may repeat, so this can exceed host.totalMemory. */ totalMemory: number + /** + * Sum of app + all tracked worktree `privateMemory`. Present exactly when + * `processCommitMetric` is. Committed bytes are not bounded by physical RAM, + * so exceeding host.totalMemory is the signal, not a bug. + */ + totalPrivateMemory?: number collectedAt: number } From 96565fe370010d67491a0dce5bc5597ffb212a4f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:44:11 -0700 Subject: [PATCH 025/152] perf(source-control): stop re-running every git read on each file selection (#15036) (#16600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(source-control): stop blocking main on four sync git-dir probes per status poll detectConflictOperation ran four existsSync calls against the git dir on every status poll. On a `\\wsl.localhost\...` worktree each one is a 9p round trip, and being synchronous they landed on the Electron main thread back to back. Replace them with concurrent fs/promises access probes: same "any failure reads as absent" semantics existsSync had, one wave instead of four serialized blocking calls. The outer try/catch went with them -- neither resolveGitDir nor the probes can throw now, so it was unreachable. Part of #15036 (source-control latency). * perf(wsl): let git reads take the shell-free route from a cwd-derived distro shouldAttemptWslDirectGit required options.wslDistro, so a `\\wsl.localhost\...` worktree without a resolved WSL project runtime never qualified -- even though the distro is right there in the cwd and wslDistroForCommand already knew how to read it. Every `git show` behind a diff therefore ran through the user's login shell, executing their rc once per blob read. Three changes: - Derive the distro from the cwd when no override was supplied. This is the fix; the routing decision now depends on where the repo actually lives. - Wait, bounded, for a cold read-environment probe instead of resolving without it. The probe is one wsl.exe call shared per distro, so the wait is paid at most once, and past WSL_GIT_READ_ENVIRONMENT_WAIT_MS the shell route runs exactly as before. It returns null rather than a settled promise when there is nothing to wait for, so a non-WSL git call is not pushed into a later microtask. - Opt the blob reads into preferWslDirectGit via gitReadOptionsForWorktree (renamed from gitStatusReadOptionsForWorktree; it was never status-specific). Belt-and- braces only: `show`, `config --get-regexp`, `ls-files` and `rev-parse` were all already matched by isWslDirectGitReadCommand, so this changes no routing today -- it just stops the diff path depending on a heuristic it knows the answer to. git-blob-read also gains a `failed` flag distinguishing "git ran and reported the path absent" (exit 128) from "the read never got an answer"; nothing consumes it yet, the settled diff cache does. Part of #15036 (source-control latency). * perf(source-control): give diff reads a settled cache keyed on stamped git state gitDiffReadDedupe coalesces only while a read is in flight, so every file selection re-ran the whole read: a `git config --file .gitmodules` spawn, one or two `git show` spawns, and a working-tree stat+read. On a WSL/UNC worktree each git spawn is a wsl.exe invocation, which is the ">3s Loading diff..." in #15036. Correctness first -- a stale diff is worse than a slow one. The cache never expires on a clock and there is no TTL to tune. Instead: - worktree-diff-stamp.ts takes a subprocess-free stamp of exactly the inputs a file diff is built from: HEAD (by resolved tip *content*, so a commit is visible even though HEAD's own bytes never move), `.git/index` (mtime+size), `.gitmodules` (submodule routing), and the working-tree file. A linked worktree's commondir and the packed-refs/reftable fallback are handled; an unborn branch is caught by recording "no loose ref" rather than only the packed stamps. - The stamp is captured BEFORE the read and stored with the result. Anything that moves during or after the read leaves the stored stamp behind, so the next lookup misses. That, not a freshness window, is why a stale diff cannot be served. - A store is refused unless the stamp was taken a full mtime bucket (2s, FAT's granularity) after its newest component. Below that, a second write inside the same bucket would be invisible -- git's own racy-index rule. - `null` stamp means "cannot prove" and never caches: a folder workspace, a repo whose layout cannot be read, or a filesystem reporting no usable mtime. - Submodule routes and reads that failed rather than proved absence are not reusable. A wsl.exe hiccup produces the same empty left side a new file does, and pinning that would persist a wrong diff. - invalidateGitReadCaches clears it and bumps a generation, so a read that started pre-mutation cannot store its result post-mutation. `ino` is deliberately optional in the working-tree component: Windows reports 0 for it on the redirector behind `\\wsl.localhost`, and requiring an unstable 0 to match would make the cache silently never hit on the exact host it exists for. Cache counters are exposed for the same reason -- a miss storm and a cold start otherwise look identical. Also drops gitDiffReadDedupe.clear() from getStatus. A status poll is a read; all it did was destroy a live coalescing entry so a concurrent identical request started duplicate git work. Mutations still invalidate through the shared point. Memory is bounded by retained characters, not entry count -- one diff result can legitimately hold megabytes. Fixes the source-control half of #15036. * perf(source-control): reuse BoundedMap and stop the WSL probe wait from outliving its answer Review follow-ups on the settled-diff-cache work: - SettledDiffCache now sits on the shared BoundedMap instead of hand-rolling the same Map + character ledger + evict-oldest loop. - pendingWslDirectGitReadEnvironment returns null once the probe has settled either way, so a distro whose direct route was disabled no longer pays for a 1.5s timer and two microtask hops on every git read. - That wait now honours the read's abort signal and goes through withTimeout, so an aborted read is not held for the full bound and a probe rejection can never surface as a read failure. - The settled-cache generation fence is taken before the stamp read, so a mutation that lands entirely inside the stamp's stats can no longer store an entry whose stamp is torn across it. - The cache counters are folded into the main-thread churn probe report, which is what tells a permanently-cold cache apart from a cold start in the field. * fix(source-control): tell WSL clock skew apart from a genuinely fresh write The racy-write margin compares two clocks: capturedAtMs is this host's, while the component mtimes come from whatever wrote the files. On a \\wsl.localhost worktree the guest sets them, so a guest running ahead pushes every recently-touched file past the margin and the cache refuses to store — for as long as the skew lasts, on exactly the platform this cache exists for. Nothing was wrong with the refusal; it was invisible. racyWrites alone cannot distinguish "the repo was just edited" from "the clocks disagree and this will never resolve on its own", so a permanently cold cache looked like a cold start. isDiffStampClockSkewed flags the one thing no local write can produce — an mtime in this host's future — and the cache counts those separately as clockSkewedWrites. A nonzero count is the signal that the cache is off for a reason idling will not fix. Found by review of #16600; behavior is unchanged, only observability. --- .../main-thread-churn-probe.test.ts | 32 +- .../diagnostics/main-thread-churn-probe.ts | 17 +- .../command-runner/git-command-resolution.ts | 68 +++- src/main/git/command-runner/git-exec-file.ts | 11 +- .../git/command-runner/git-stream-stdout.ts | 5 + src/main/git/git-runtime-options.ts | 8 +- src/main/git/runner-wsl-direct-read.test.ts | 136 ++++++- .../git/settled-diff-cache-bounds.test.ts | 154 ++++++++ .../effective-upstream-status-probe.ts | 6 +- src/main/git/source-control/file-diff.ts | 113 ++++-- src/main/git/source-control/git-blob-read.ts | 38 +- .../source-control/git-conflict-operation.ts | 29 +- .../git-read-cache-invalidation.ts | 5 + .../git/source-control/settled-diff-cache.ts | 155 ++++++++ .../status-branch-line-total-input.ts | 4 +- .../git/source-control/status-line-stats.ts | 4 +- src/main/git/source-control/status-read.ts | 8 +- .../git/source-control/worktree-diff-stamp.ts | 226 ++++++++++++ .../git/status-conflict-operations.test.ts | 82 +++-- .../git/status-diff-settled-cache.test.ts | 331 ++++++++++++++++++ src/main/git/status-diff.test.ts | 41 ++- src/main/git/status-submodule.test.ts | 11 +- src/main/git/status-test-harness.ts | 9 +- src/main/git/status.test.ts | 43 +-- src/main/git/wsl-git-read-environment.ts | 37 +- src/main/index.ts | 5 +- 26 files changed, 1425 insertions(+), 153 deletions(-) create mode 100644 src/main/git/settled-diff-cache-bounds.test.ts create mode 100644 src/main/git/source-control/settled-diff-cache.ts create mode 100644 src/main/git/source-control/worktree-diff-stamp.ts create mode 100644 src/main/git/status-diff-settled-cache.test.ts diff --git a/src/main/diagnostics/main-thread-churn-probe.test.ts b/src/main/diagnostics/main-thread-churn-probe.test.ts index 62ede5c99c8..bb87989d56d 100644 --- a/src/main/diagnostics/main-thread-churn-probe.test.ts +++ b/src/main/diagnostics/main-thread-churn-probe.test.ts @@ -1,10 +1,19 @@ import { afterEach, describe, expect, it, vi } from 'vitest' + +const { writeStartupDiagnosticLineMock } = vi.hoisted(() => ({ + writeStartupDiagnosticLineMock: vi.fn() +})) +vi.mock('../startup/startup-diagnostics', () => ({ + writeStartupDiagnosticLine: writeStartupDiagnosticLineMock +})) + import { MAIN_THREAD_DIAGNOSTICS_ENV, classifySubprocessCommand, drainSubprocessSpawnStats, isMainThreadDiagnosticsEnabled, - recordSubprocessSpawn + recordSubprocessSpawn, + startMainThreadChurnProbe } from './main-thread-churn-probe' afterEach(() => { @@ -87,3 +96,24 @@ describe('recordSubprocessSpawn', () => { expect(drainSubprocessSpawnStats()).toEqual({}) }) }) + +describe('startMainThreadChurnProbe', () => { + // Counters nothing reads are counters nobody can act on: the probe report is what makes + // "the diff cache never hit" visible outside a test run. + it('folds caller-contributed counters into the report line', async () => { + vi.stubEnv(MAIN_THREAD_DIAGNOSTICS_ENV, '1') + writeStartupDiagnosticLineMock.mockClear() + vi.useFakeTimers() + try { + startMainThreadChurnProbe({ extraStats: () => ({ diffCache: { hits: 3, misses: 1 } }) }) + await vi.advanceTimersByTimeAsync(5_100) + } finally { + vi.useRealTimers() + } + + const line = String(writeStartupDiagnosticLineMock.mock.calls.at(-1)?.[0] ?? '') + expect(JSON.parse(line.replace('[main-thread] ', ''))).toMatchObject({ + diffCache: { hits: 3, misses: 1 } + }) + }) +}) diff --git a/src/main/diagnostics/main-thread-churn-probe.ts b/src/main/diagnostics/main-thread-churn-probe.ts index 1d70999bc14..af6877e408e 100644 --- a/src/main/diagnostics/main-thread-churn-probe.ts +++ b/src/main/diagnostics/main-thread-churn-probe.ts @@ -136,14 +136,20 @@ export function writeMainThreadDiagnosticMarker(marker: string): void { ) } +export type MainThreadChurnProbeOptions = { + /** Extra counters folded into each report line, sampled once per window. */ + extraStats?: () => Record +} + /** * 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. + * window's worst event-loop stall, stall counts over 50/250ms, drained subprocess + * spawn stats, and any counters the caller contributes. Unlike the startup stall + * probe this never stops: the churn it measures (git status polling, updater + * retries) is steady-state. */ -export function startMainThreadChurnProbe(): void { +export function startMainThreadChurnProbe(options: MainThreadChurnProbeOptions = {}): void { if (!isMainThreadDiagnosticsEnabled()) { return } @@ -176,7 +182,8 @@ export function startMainThreadChurnProbe(): void { gapsOver50Ms, gapsOver250Ms, spawnCount: Object.values(spawns).reduce((sum, s) => sum + s.count, 0), - spawns + spawns, + ...options.extraStats?.() } windowMaxGapMs = 0 gapsOver50Ms = 0 diff --git a/src/main/git/command-runner/git-command-resolution.ts b/src/main/git/command-runner/git-command-resolution.ts index 9c53658c159..ddde988b933 100644 --- a/src/main/git/command-runner/git-command-resolution.ts +++ b/src/main/git/command-runner/git-command-resolution.ts @@ -1,10 +1,14 @@ +import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' +import { withTimeout } from '../../../shared/promise-timeout-fallback' import { parseWslPath } from '../../wsl' import { isWslDirectGitReadCommand } from '../wsl-direct-git-read-commands' import { disableWslGitReadEnvironment, getWslGitReadEnvironment, invalidateWslGitReadEnvironment, - peekWslGitReadEnvironment + isWslGitReadEnvironmentSettled, + peekWslGitReadEnvironment, + WSL_GIT_READ_ENVIRONMENT_WAIT_MS } from '../wsl-git-read-environment' import { usesHostGitForWslLinkedWorktree } from '../wsl-linked-worktree-git-routing' import { resolveCommand, type ResolvedCommand } from './wsl-command-resolution' @@ -27,23 +31,39 @@ export function resolveGitCommand( // Why: WSL Git resolves a Windows-authored linked-worktree pointer relative to cwd. return { binary: 'git', args, cwd: options.cwd, wsl: null, wslMode: null } } - if (!forceLoginShell && shouldAttemptWslDirectGit(args, options)) { - const distro = wslDistroForCommand(options.cwd, options.wslDistro) - const environment = distro ? peekWslGitReadEnvironment(distro) : undefined + const distro = directWslGitReadDistro(args, options, forceLoginShell) + if (distro) { + const environment = peekWslGitReadEnvironment(distro) if (environment) { - return resolveCommand('git', args, options.cwd, options.wslDistro, { + return resolveCommand('git', args, options.cwd, distro, { wslGitReadEnvironment: environment, env: options.env, terminationBarrier: options.terminationBarrier }) } - if (distro) { - void getWslGitReadEnvironment(distro) - } + void getWslGitReadEnvironment(distro) } return resolveGitCommandWithoutProbe(args, options, captureLoginShellOutput) } +/** + * The distro this command could run shell-free in, or null when it can't. + * + * Why cwd counts: a `\\wsl.localhost\\...` worktree names its distro in + * the path, so requiring the caller to have resolved a WSL project runtime first + * left every diff read on the login shell — one rc run per `git show`. + */ +function directWslGitReadDistro( + args: string[], + options: GitExecOptions, + forceLoginShell: boolean +): string | null { + if (forceLoginShell || !shouldAttemptWslDirectGit(args, options)) { + return null + } + return wslDistroForCommand(options.cwd, options.wslDistro) +} + function shouldAttemptWslDirectGit(args: string[], options: GitExecOptions): boolean { return Boolean( process.platform === 'win32' && @@ -55,8 +75,36 @@ function shouldAttemptWslDirectGit(args: string[], options: GitExecOptions): boo !Object.entries(options.env ?? {}).some( ([key, value]) => key.startsWith('GIT_') && key !== 'GIT_OPTIONAL_LOCKS' && value !== process.env[key] - ) && - options.wslDistro + ) + ) +} + +/** + * Give a read the chance to take the shell-free route instead of silently + * falling back while the probe is still resolving. + * + * The probe is one `wsl.exe` call, shared per distro and reused by every later + * command, so waiting costs at most once. Bounded because a cold or wedged + * distro must not hold a read behind it — past the bound the login-shell route + * runs exactly as it did before. + */ +export function pendingWslDirectGitReadEnvironment( + args: string[], + options: GitExecOptions +): Promise | null { + // Why null rather than a resolved promise: every non-WSL git call goes through here, and + // awaiting even an already-settled promise would push the spawn into a later microtask. + // A settled probe — including a distro whose direct route was permanently disabled — has + // nothing left to wait for, and neither does a read that is already aborted. + const distro = directWslGitReadDistro(args, options, false) + if (!distro || isWslGitReadEnvironmentSettled(distro) || options.signal?.aborted) { + return null + } + // withTimeout also absorbs rejection, so a probe failure can never become a read failure. + return withTimeout( + waitForPromiseWithSignal(getWslGitReadEnvironment(distro), options.signal), + WSL_GIT_READ_ENVIRONMENT_WAIT_MS, + null ) } diff --git a/src/main/git/command-runner/git-exec-file.ts b/src/main/git/command-runner/git-exec-file.ts index c2815c4bb4c..d9762030813 100644 --- a/src/main/git/command-runner/git-exec-file.ts +++ b/src/main/git/command-runner/git-exec-file.ts @@ -13,6 +13,7 @@ import { resolveCommand, type ResolvedCommand } from './wsl-command-resolution' import type { GitExecOptions } from './git-exec-options' import { execFileCapture, execFileCaptureToTermination } from './exec-file-capture' import { + pendingWslDirectGitReadEnvironment, directWslGitExitCode, disableDirectWslGitAfterSuccessfulFallback, invalidateMissingDirectWslGit, @@ -39,6 +40,10 @@ async function gitExecFileAsyncUnlocked( signal: options.signal }) } + const readEnvironmentReady = pendingWslDirectGitReadEnvironment(args, options) + if (readEnvironmentReady) { + await readEnvironmentReady + } let resolved = resolveGitCommand(args, options, false, options.captureWslLoginShellOutput) const environmentReady = prepareWindowsHostGitEnvironment( resolved, @@ -127,11 +132,15 @@ export function gitExecFileAsync( */ export async function gitExecFileAsyncBuffer( args: string[], - options: { cwd: string; maxBuffer?: number; wslDistro?: string } + options: { cwd: string; maxBuffer?: number; wslDistro?: string; preferWslDirectGit?: boolean } ): Promise<{ stdout: Buffer }> { if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) { await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro) } + const readEnvironmentReady = pendingWslDirectGitReadEnvironment(args, options) + if (readEnvironmentReady) { + await readEnvironmentReady + } // `git show` is a read, so this normally runs with no shell at all. The fence // still matters for the login-shell fallback: these are raw blob bytes going // straight to the diff/blob viewer, where a banner becomes file content. diff --git a/src/main/git/command-runner/git-stream-stdout.ts b/src/main/git/command-runner/git-stream-stdout.ts index 318be527c41..7f33308d747 100644 --- a/src/main/git/command-runner/git-stream-stdout.ts +++ b/src/main/git/command-runner/git-stream-stdout.ts @@ -11,6 +11,7 @@ import { killSpawnedCommandTree } from './spawned-command-tree-kill' import type { ResolvedCommand } from './wsl-command-resolution' import { DEFAULT_GIT_MAX_BUFFER, type GitExecOptions } from './git-exec-options' import { + pendingWslDirectGitReadEnvironment, directWslGitExitCode, disableDirectWslGitAfterSuccessfulFallback, invalidateMissingDirectWslGit, @@ -64,6 +65,10 @@ export async function gitStreamStdout( ...(options.preferWslDirectGit ? { preferWslDirectGit: true } : {}), ...(options.signal ? { signal: options.signal } : {}) } + const readEnvironmentReady = pendingWslDirectGitReadEnvironment(args, gitOptions) + if (readEnvironmentReady) { + await readEnvironmentReady + } let resolved = resolveGitCommand(args, gitOptions) const environmentReady = prepareWindowsHostGitEnvironment( resolved, diff --git a/src/main/git/git-runtime-options.ts b/src/main/git/git-runtime-options.ts index caa52bd02b4..edc3bd4b30c 100644 --- a/src/main/git/git-runtime-options.ts +++ b/src/main/git/git-runtime-options.ts @@ -14,7 +14,13 @@ export function gitOptionsForWorktree( } } -export function gitStatusReadOptionsForWorktree( +/** + * Options for a git invocation that only reads. Opting in explicitly keeps the + * shell-free WSL route from depending on `wsl-direct-git-read-commands` + * classifying the argv, which is a heuristic these call sites already know the + * answer to. + */ +export function gitReadOptionsForWorktree( cwd: string, options: GitRuntimeOptions = {} ): { diff --git a/src/main/git/runner-wsl-direct-read.test.ts b/src/main/git/runner-wsl-direct-read.test.ts index aa5bd5dbc0d..9cc43223c76 100644 --- a/src/main/git/runner-wsl-direct-read.test.ts +++ b/src/main/git/runner-wsl-direct-read.test.ts @@ -17,11 +17,14 @@ vi.mock('../observability/instrumentation', () => ({ })) vi.mock('../diagnostics/main-thread-churn-probe', () => ({ recordSubprocessSpawn: vi.fn() })) +import { pendingWslDirectGitReadEnvironment } from './command-runner/git-command-resolution' import { gitExecFileAsync, gitSpawn, gitStreamStdout } from './runner' import { + disableWslGitReadEnvironment, getWslGitReadEnvironment, resetWslGitReadEnvironmentForTests, - seedWslGitReadEnvironmentForTests + seedWslGitReadEnvironmentForTests, + WSL_GIT_READ_ENVIRONMENT_WAIT_MS } from './wsl-git-read-environment' import { prepareWslLinkedWorktreeGitRouting, @@ -349,7 +352,9 @@ describe('WSL direct Git reads', () => { }) }) - it('leaves override-less WSL UNC routing on its existing non-login shell', async () => { + // A UNC worktree names its distro in the path, so a read there needs no resolved WSL project + // runtime to skip the shell -- requiring one is what kept every diff read on the login shell. + it('takes the direct read route for an override-less WSL UNC cwd', async () => { await withPlatform('win32', async () => { seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT) succeedExecFile() @@ -359,7 +364,132 @@ describe('WSL direct Git reads', () => { preferWslDirectGit: true }) - expect(execFileMock.mock.calls[0]?.[1]?.slice(3, 5)).toEqual(['bash', '-c']) + const resolved = execFileMock.mock.calls[0]?.[1] ?? [] + expect(resolved).toContain('--exec') + expect(resolved).toContain(`PATH=${LOGIN_ENVIRONMENT.path}`) + expect(resolved).not.toContain('bash') + }) + }) + + // The classifier already recognizes `show`, so the blob reads behind every diff take the + // direct route from the cwd alone -- no caller-supplied distro, no explicit opt-in. + it('takes the direct read route for an unclassified-caller blob read on a UNC cwd', async () => { + await withPlatform('win32', async () => { + seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT) + succeedExecFile() + + await gitExecFileAsync(['show', ':src/file.ts'], { + cwd: String.raw`\\wsl.localhost\Ubuntu\repo` + }) + + expect(execFileMock.mock.calls[0]?.[1]).toContain('--exec') + }) + }) + + // Kicking the probe off and resolving without it left the reads issued before it + // answered on the login shell -- the slow route, chosen by nothing but timing. + it('waits for a cold probe so the first read already skips the shell', async () => { + await withPlatform('win32', async () => { + execFileMock.mockImplementation((_command, args, _options, callback) => { + const child = createMockChild() + if (String(args).includes('_orca_git_path')) { + setTimeout( + () => callback?.(null, fencedProbeStdout(args, LOGIN_ENVIRONMENT_FIELDS), ''), + 0 + ) + } else { + queueMicrotask(() => callback?.(null, 'ok', '')) + } + return child + }) + + await gitExecFileAsync(['show', ':src/file.ts'], { + cwd: String.raw`\\wsl.localhost\Ubuntu\repo` + }) + + expect(execFileMock.mock.calls.at(-1)?.[1]).toContain('--exec') + expect(execFileMock.mock.calls.at(-1)?.[1]).toContain(`PATH=${LOGIN_ENVIRONMENT.path}`) + }) + }) + + it('stops waiting for a wedged probe and reads through the shell route', async () => { + await withPlatform('win32', async () => { + vi.useFakeTimers() + try { + execFileMock.mockImplementation((_command, args, _options, callback) => { + const child = createMockChild() + // The probe never answers; only the git command itself does. + if (!String(args).includes('_orca_git_path')) { + queueMicrotask(() => callback?.(null, 'ok', '')) + } + return child + }) + + const pending = gitExecFileAsync(['show', ':src/file.ts'], { + cwd: String.raw`\\wsl.localhost\Ubuntu\repo` + }) + await vi.advanceTimersByTimeAsync(WSL_GIT_READ_ENVIRONMENT_WAIT_MS) + await pending + + // Exactly the routing this read had before the wait existed: an unanswered + // probe must cost the bound and nothing else. + const resolved = execFileMock.mock.calls.at(-1)?.[1] ?? [] + expect(resolved.slice(3, 5)).toEqual(['bash', '-c']) + expect(resolved).not.toContain('/usr/bin/env') + } finally { + vi.useRealTimers() + } + }) + }) + + // Once the route is disabled the answer is already known, so paying for a timer and two + // extra microtask hops on every later read is pure overhead on the host this PR targets. + it('stops deferring reads once the direct route is disabled for the distro', async () => { + await withPlatform('win32', async () => { + seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT) + disableWslGitReadEnvironment(DISTRO) + + expect( + pendingWslDirectGitReadEnvironment(['show', ':src/file.ts'], { + cwd: String.raw`\\wsl.localhost\Ubuntu\repo` + }) + ).toBeNull() + }) + }) + + it('never waits on the probe for an already-aborted read', async () => { + await withPlatform('win32', async () => { + const controller = new AbortController() + controller.abort() + + expect( + pendingWslDirectGitReadEnvironment(['show', ':src/file.ts'], { + cwd: String.raw`\\wsl.localhost\Ubuntu\repo`, + signal: controller.signal + }) + ).toBeNull() + expect(execFileMock).not.toHaveBeenCalled() + }) + }) + + it('stops waiting for a cold probe as soon as the read aborts', async () => { + await withPlatform('win32', async () => { + vi.useFakeTimers() + try { + // The probe never answers, so only the abort can end the wait. + execFileMock.mockImplementation(() => createMockChild()) + const controller = new AbortController() + + const pending = pendingWslDirectGitReadEnvironment(['show', ':src/file.ts'], { + cwd: String.raw`\\wsl.localhost\Ubuntu\repo`, + signal: controller.signal + }) + controller.abort() + + await expect(pending).resolves.toBeNull() + } finally { + vi.useRealTimers() + } }) }) diff --git a/src/main/git/settled-diff-cache-bounds.test.ts b/src/main/git/settled-diff-cache-bounds.test.ts new file mode 100644 index 00000000000..8d25bba40de --- /dev/null +++ b/src/main/git/settled-diff-cache-bounds.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest' +import type { GitDiffResult } from '../../shared/git-diff-compare-types' +import { + MAX_SETTLED_DIFF_CACHE_ENTRIES, + MAX_SETTLED_DIFF_CACHE_RESULT_CHARACTERS, + MAX_SETTLED_DIFF_CACHE_TOTAL_CHARACTERS, + SettledDiffCache +} from './source-control/settled-diff-cache' +import type { WorktreeDiffStamp } from './source-control/worktree-diff-stamp' + +function settledStamp(value: string): WorktreeDiffStamp { + // Old enough that the racy-write margin is satisfied. + return { value, newestMtimeMs: Date.now() - 60_000, capturedAtMs: Date.now() } +} + +function diffOfSize(characters: number): GitDiffResult { + return { + kind: 'text', + originalContent: '', + modifiedContent: 'x'.repeat(characters), + originalIsBinary: false, + modifiedIsBinary: false + } +} + +describe('SettledDiffCache bounds', () => { + it('evicts the least recently used entry past the entry cap', () => { + const cache = new SettledDiffCache() + for (let index = 0; index <= MAX_SETTLED_DIFF_CACHE_ENTRIES; index += 1) { + cache.set(`key-${index}`, settledStamp(`stamp-${index}`), diffOfSize(1), cache.beginRead()) + } + + expect(cache.stats().entries).toBe(MAX_SETTLED_DIFF_CACHE_ENTRIES) + expect(cache.get('key-0', settledStamp('stamp-0'))).toBeUndefined() + expect( + cache.get( + `key-${MAX_SETTLED_DIFF_CACHE_ENTRIES}`, + settledStamp(`stamp-${MAX_SETTLED_DIFF_CACHE_ENTRIES}`) + ) + ).toBeDefined() + }) + + it('keeps a re-read entry alive by refreshing its LRU position', () => { + const cache = new SettledDiffCache() + cache.set('hot', settledStamp('hot-stamp'), diffOfSize(1), cache.beginRead()) + for (let index = 0; index < MAX_SETTLED_DIFF_CACHE_ENTRIES; index += 1) { + cache.get('hot', settledStamp('hot-stamp')) + cache.set(`cold-${index}`, settledStamp(`cold-${index}`), diffOfSize(1), cache.beginRead()) + } + + expect(cache.get('hot', settledStamp('hot-stamp'))).toBeDefined() + }) + + // One entry can legitimately hold megabytes, so an entry count alone bounds nothing. + it('holds total retained content under the character budget', () => { + const cache = new SettledDiffCache() + const chunk = MAX_SETTLED_DIFF_CACHE_RESULT_CHARACTERS + const needed = Math.ceil(MAX_SETTLED_DIFF_CACHE_TOTAL_CHARACTERS / chunk) + 2 + + for (let index = 0; index < needed; index += 1) { + cache.set( + `key-${index}`, + settledStamp(`stamp-${index}`), + diffOfSize(chunk), + cache.beginRead() + ) + } + + expect(cache.stats().retainedCharacters).toBeLessThanOrEqual( + MAX_SETTLED_DIFF_CACHE_TOTAL_CHARACTERS + ) + }) + + it('declines a single result larger than the per-entry cap', () => { + const cache = new SettledDiffCache() + + cache.set( + 'huge', + settledStamp('huge-stamp'), + diffOfSize(MAX_SETTLED_DIFF_CACHE_RESULT_CHARACTERS + 1), + cache.beginRead() + ) + + expect(cache.stats().entries).toBe(0) + expect(cache.stats().retainedCharacters).toBe(0) + }) + + it('releases retained characters when a key is overwritten', () => { + const cache = new SettledDiffCache() + cache.set('key', settledStamp('first'), diffOfSize(1_000), cache.beginRead()) + cache.set('key', settledStamp('second'), diffOfSize(10), cache.beginRead()) + + expect(cache.stats().entries).toBe(1) + expect(cache.stats().retainedCharacters).toBe(10) + expect(cache.get('key', settledStamp('first'))).toBeUndefined() + expect(cache.get('key', settledStamp('second'))).toBeDefined() + }) + + it('drops everything and refuses in-flight stores after a clear', () => { + const cache = new SettledDiffCache() + const readGeneration = cache.beginRead() + cache.clear() + cache.set('key', settledStamp('stamp'), diffOfSize(1), readGeneration) + + expect(cache.stats().entries).toBe(0) + expect(cache.stats().invalidatedDuringRead).toBe(1) + }) +}) + +// Why (#15036 review): the margin compares this host's clock to mtimes the WSL guest +// wrote. A guest running ahead refuses every store for as long as the skew lasts, and +// without its own counter that is indistinguishable from a repo nobody has touched. +describe('settled diff cache clock skew', () => { + const result: GitDiffResult = diffOfSize(10) + + it('counts a future-dated mtime separately from an ordinary racy write', () => { + const cache = new SettledDiffCache() + const now = Date.now() + + // Honestly fresh: written just now, margin not yet satisfied. + cache.set( + 'fresh', + { value: 'a', newestMtimeMs: now, capturedAtMs: now }, + result, + cache.beginRead() + ) + // Skewed: mtime in this host's future, which no local write can produce. + cache.set( + 'skewed', + { value: 'b', newestMtimeMs: now + 60_000, capturedAtMs: now }, + result, + cache.beginRead() + ) + + const stats = cache.stats() + expect(stats.racyWrites).toBe(2) + expect(stats.clockSkewedWrites).toBe(1) + expect(stats.stores).toBe(0) + }) + + it('does not flag a stamp whose components were all absent', () => { + const cache = new SettledDiffCache() + const now = Date.now() + + cache.set( + 'absent', + { value: 'c', newestMtimeMs: Number.NEGATIVE_INFINITY, capturedAtMs: now }, + result, + cache.beginRead() + ) + + expect(cache.stats().clockSkewedWrites).toBe(0) + }) +}) diff --git a/src/main/git/source-control/effective-upstream-status-probe.ts b/src/main/git/source-control/effective-upstream-status-probe.ts index bea3dc93d79..1fd15cda486 100644 --- a/src/main/git/source-control/effective-upstream-status-probe.ts +++ b/src/main/git/source-control/effective-upstream-status-probe.ts @@ -6,7 +6,7 @@ import { } from '../../../shared/git-effective-upstream' import { createGitConfigSnapshotRunner } from '../../../shared/git-config-snapshot-runner' import type { GitRuntimeOptions } from '../git-runtime-options' -import { gitStatusReadOptionsForWorktree } from '../git-runtime-options' +import { gitReadOptionsForWorktree } from '../git-runtime-options' import { gitExecFileAsync } from '../runner' import { MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES, @@ -90,7 +90,7 @@ async function probeOrRevalidateEffectiveUpstreamStatus( } else if (cached) { try { const status = await getGitUpstreamStatusForUpstreamName( - (args) => gitExecFileAsync(args, gitStatusReadOptionsForWorktree(worktreePath, options)), + (args) => gitExecFileAsync(args, gitReadOptionsForWorktree(worktreePath, options)), cached.upstreamName ) return { status, probedSameNameOriginRef: false } @@ -127,7 +127,7 @@ async function probeEffectiveUpstreamStatus( ): Promise<{ status: GitUpstreamStatus; probedSameNameOriginRef: boolean }> { let probedSameNameOriginRef = false const snapshotRunner = createGitConfigSnapshotRunner((args) => - gitExecFileAsync(args, gitStatusReadOptionsForWorktree(worktreePath, options)) + gitExecFileAsync(args, gitReadOptionsForWorktree(worktreePath, options)) ) const status = await getEffectiveGitUpstreamStatus((args) => { if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) { diff --git a/src/main/git/source-control/file-diff.ts b/src/main/git/source-control/file-diff.ts index a8fe78a6270..5595f0f6804 100644 --- a/src/main/git/source-control/file-diff.ts +++ b/src/main/git/source-control/file-diff.ts @@ -3,7 +3,8 @@ import type { GitDiffResult } from '../../../shared/git-diff-compare-types' import { stableInFlightKey } from '../../../shared/in-flight-promise-dedupe' import type { GitRuntimeOptions } from '../git-runtime-options' import { gitRuntimeOptionsKey } from './git-runtime-options-cache-key' -import { gitDiffReadDedupe } from './git-read-cache-invalidation' +import { gitDiffReadDedupe, settledDiffCache } from './git-read-cache-invalidation' +import { readWorktreeDiffStamp } from './worktree-diff-stamp' import { buildDiffResult } from './diff-result' import { readGitBlobAtIndexPath, @@ -33,27 +34,74 @@ export async function getDiff( compareAgainstHead = false, options: GitRuntimeOptions = {} ): Promise { - // Why: register the dedupe synchronously (before any await) so concurrent identical reads coalesce. - return gitDiffReadDedupe.run( - stableInFlightKey([ - 'diff', + const readKey = stableInFlightKey([ + 'diff', + worktreePath, + filePath, + staged, + compareAgainstHead, + ...gitRuntimeOptionsKey(options) + ]) + // Why: register the dedupe synchronously (before any await) so concurrent identical reads + // coalesce — including on the settled-cache lookup, which is itself I/O. + return gitDiffReadDedupe.run(readKey, () => + loadDiffThroughSettledCache( + readKey, worktreePath, filePath, staged, compareAgainstHead, - ...gitRuntimeOptionsKey(options) - ]), - () => loadDiff(worktreePath, filePath, staged, compareAgainstHead, options) + options + ) ) } +/** + * Serve a settled diff when the git state it was built from is provably + * unchanged, otherwise read and — only if the read proved everything it touched + * — record it under the stamp taken *before* the read. + * + * Stamping first is what makes staleness impossible: anything that moves during + * or after the read leaves the stored stamp behind, so the next lookup misses. + */ +async function loadDiffThroughSettledCache( + readKey: string, + worktreePath: string, + filePath: string, + staged: boolean, + compareAgainstHead: boolean, + options: GitRuntimeOptions +): Promise { + // Why before the stamp read: the stamp is itself several awaited stats, and a mutation that + // lands entirely inside that window would otherwise leave the fence covering only the git read. + const readGeneration = settledDiffCache.beginRead() + // A staged diff compares HEAD to the index, so the working tree is not one of its inputs. + const stamp = await readWorktreeDiffStamp(worktreePath, filePath, !staged) + const cached = settledDiffCache.get(readKey, stamp) + if (cached) { + return cached + } + const loaded = await loadDiff(worktreePath, filePath, staged, compareAgainstHead, options) + if (loaded.reusable) { + settledDiffCache.set(readKey, stamp, loaded.result, readGeneration) + } + return loaded.result +} + +/** + * `reusable` is false when the result cannot be proven to describe the stamped + * state: a submodule route, whose inputs live in another repo and are stamped by + * that repo's own read, or a blob read that failed rather than proving absence. + */ +type LoadedDiff = { result: GitDiffResult; reusable: boolean } + async function loadDiff( worktreePath: string, filePath: string, staged: boolean, compareAgainstHead: boolean, options: GitRuntimeOptions -): Promise { +): Promise { // Why: gitlink paths can't be read as blobs, so route submodule diffs explicitly (root → pointer, inner → recurse). const submodulePaths = await listSubmodulePaths(worktreePath, options) if (submodulePaths.length > 0) { @@ -63,13 +111,15 @@ async function loadDiff( const submoduleWorktreePath = resolveSubmoduleWorktreePath(worktreePath, matchedSubmodule) const normalizedFilePath = filePath.replace(/\\/g, '/').replace(/\/+$/, '') if (normalizedFilePath === matchedSubmodule) { - return buildSubmodulePointerDiff( - worktreePath, - matchedSubmodule, - staged, - compareAgainstHead, - options, - submoduleWorktreePath + return notReusable( + await buildSubmodulePointerDiff( + worktreePath, + matchedSubmodule, + staged, + compareAgainstHead, + options, + submoduleWorktreePath + ) ) } const innerPath = normalizedFilePath.slice(matchedSubmodule.length + 1) @@ -82,15 +132,20 @@ async function loadDiff( : await readWorkingSubmoduleHead(submoduleWorktreePath, options) // Why: a moved gitlink with a clean submodule worktree means the change is committed — diff the two commits. if (fromOid && toOid && fromOid !== toOid) { - return buildSubmoduleInnerCommitRangeDiff( - submoduleWorktreePath, - innerPath, - fromOid, - toOid, - options + return notReusable( + await buildSubmoduleInnerCommitRangeDiff( + submoduleWorktreePath, + innerPath, + fromOid, + toOid, + options + ) ) } - return getDiff(submoduleWorktreePath, innerPath, staged, compareAgainstHead, options) + // The inner read stamps and caches against the submodule's own repo state. + return notReusable( + await getDiff(submoduleWorktreePath, innerPath, staged, compareAgainstHead, options) + ) } } @@ -99,6 +154,7 @@ async function loadDiff( let originalIsBinary = false let modifiedIsBinary = false let modifiedDeleted = false + let readFailed = false try { if (staged) { @@ -113,6 +169,7 @@ async function loadDiff( modifiedContent = rightBlob.content modifiedIsBinary = rightBlob.isBinary modifiedDeleted = !rightBlob.exists + readFailed = leftBlob.failed === true || rightBlob.failed === true } else { // The left chain (index→HEAD) is sequential within itself, but the working // tree read is a plain fs read that does not depend on it. @@ -127,9 +184,11 @@ async function loadDiff( modifiedContent = workingTreeBlob.content modifiedIsBinary = workingTreeBlob.isBinary modifiedDeleted = !workingTreeBlob.exists + readFailed = leftBlob.failed === true || workingTreeBlob.failed === true } } catch { // Fallback + readFailed = true } const result = buildDiffResult( @@ -141,7 +200,11 @@ async function loadDiff( ) // Why: mark a proven deletion so previewers don't mistake a read failure's empty side for one. if (result.kind === 'binary' && modifiedDeleted) { - return { ...result, modifiedDeleted: true } + return { result: { ...result, modifiedDeleted: true }, reusable: !readFailed } } - return result + return { result, reusable: !readFailed } +} + +function notReusable(result: GitDiffResult): LoadedDiff { + return { result, reusable: false } } diff --git a/src/main/git/source-control/git-blob-read.ts b/src/main/git/source-control/git-blob-read.ts index e9e9182939c..f25a80eedbb 100644 --- a/src/main/git/source-control/git-blob-read.ts +++ b/src/main/git/source-control/git-blob-read.ts @@ -2,7 +2,7 @@ import { readFile, stat } from 'node:fs/promises' import * as path from 'node:path' import { isBinaryBuffer } from '../../../shared/binary-buffer' import type { GitRuntimeOptions } from '../git-runtime-options' -import { gitOptionsForWorktree } from '../git-runtime-options' +import { gitReadOptionsForWorktree } from '../git-runtime-options' import { gitExecFileAsyncBuffer } from '../runner' import { isMaxBufferOverflowError } from '../max-buffer-overflow' import { MAX_GIT_SHOW_BYTES } from './git-show-max-bytes' @@ -12,6 +12,21 @@ export type GitBlobReadResult = { content: string isBinary: boolean exists: boolean + /** + * The read did not complete: the blob is neither known-present nor proven + * absent. Callers must not persist a diff built on one, because the empty side + * it produces is indistinguishable from a genuinely new file. + */ + failed?: boolean +} + +/** + * Tell "Git ran and said the path is not there" apart from "the read never got + * an answer". Git exits 128 for a missing path in a tree or the index; a WSL + * relay that never reached Git exits with anything else, or with a spawn errno. + */ +function isProvenAbsentError(error: unknown): boolean { + return (error as { code?: unknown } | null)?.code === 128 } export async function readUnstagedLeftBlob( @@ -24,7 +39,9 @@ export async function readUnstagedLeftBlob( return indexBlob } - return readGitBlobAtOidPath(worktreePath, 'HEAD', filePath, options) + const headBlob = await readGitBlobAtOidPath(worktreePath, 'HEAD', filePath, options) + // Why: if the index read never got an answer, falling back to HEAD is a guess, not a proof. + return indexBlob.failed ? { ...headBlob, failed: true } : headBlob } export async function readGitBlobAtIndexPath( @@ -36,7 +53,7 @@ export async function readGitBlobAtIndexPath( const gitPath = filePath.replace(/\\/g, '/') try { const { stdout } = await gitExecFileAsyncBuffer(['show', `:${gitPath}`], { - ...gitOptionsForWorktree(worktreePath, options), + ...gitReadOptionsForWorktree(worktreePath, options), maxBuffer: MAX_GIT_SHOW_BYTES }) @@ -45,7 +62,7 @@ export async function readGitBlobAtIndexPath( if (isMaxBufferOverflowError(error)) { return { content: '', isBinary: true, exists: true } } - return { content: '', isBinary: false, exists: false } + return { content: '', isBinary: false, exists: false, failed: !isProvenAbsentError(error) } } } @@ -61,7 +78,7 @@ export async function readGitBlobAtOidPath( const { stdout } = await gitExecFileAsyncBuffer( ['show', '--end-of-options', `${oid}:${gitPath}`], { - ...gitOptionsForWorktree(worktreePath, options), + ...gitReadOptionsForWorktree(worktreePath, options), maxBuffer: MAX_GIT_SHOW_BYTES } ) @@ -71,7 +88,7 @@ export async function readGitBlobAtOidPath( if (isMaxBufferOverflowError(error)) { return { content: '', isBinary: true, exists: true } } - return { content: '', isBinary: false, exists: false } + return { content: '', isBinary: false, exists: false, failed: !isProvenAbsentError(error) } } } @@ -81,11 +98,8 @@ export async function readWorkingTreeFile(filePath: string): Promise { + try { + await access(target) + return true + } catch { + return false + } +} + export async function abortMerge( worktreePath: string, options: GitRuntimeOptions = {} diff --git a/src/main/git/source-control/git-read-cache-invalidation.ts b/src/main/git/source-control/git-read-cache-invalidation.ts index 7d4da0cc8ed..13abe4706a8 100644 --- a/src/main/git/source-control/git-read-cache-invalidation.ts +++ b/src/main/git/source-control/git-read-cache-invalidation.ts @@ -7,15 +7,20 @@ import { GitStatusReadLeaseOwner } from '../git-status-read-lease-owner' import { invalidateGitUpstreamStatusReads } from '../upstream' import { clearSubmodulePathsCache } from './submodule-paths' import { resolvedUpstreamNameCache } from './resolved-upstream-name-cache' +import { SettledDiffCache } from './settled-diff-cache' export const gitDiffReadDedupe = new InFlightPromiseDedupe() +/** Settled diff results, valid only while their stamped git state holds. */ +export const settledDiffCache = new SettledDiffCache() + export const statusReadLeaseOwner = new GitStatusReadLeaseOwner() // Why: clear every in-flight git read cache; clearing only some would let a post-mutation // getStatus() join a pre-mutation read and publish it as current. export function invalidateGitReadCaches(): void { gitDiffReadDedupe.clear() + settledDiffCache.clear() statusReadLeaseOwner.invalidate() invalidateGitBranchLineTotalInFlight() invalidateGitUpstreamStatusReads() diff --git a/src/main/git/source-control/settled-diff-cache.ts b/src/main/git/source-control/settled-diff-cache.ts new file mode 100644 index 00000000000..5ffd7f03e76 --- /dev/null +++ b/src/main/git/source-control/settled-diff-cache.ts @@ -0,0 +1,155 @@ +import { BoundedMap } from '../../../shared/bounded-map' +import type { GitDiffResult } from '../../../shared/git-diff-compare-types' +import { + canProveUnchangedByStamp, + isDiffStampClockSkewed, + type WorktreeDiffStamp +} from './worktree-diff-stamp' + +/** + * Diff results that survive their read, guarded by a stamp of the git state they + * were built from. + * + * Why this is not a TTL: nothing here expires on a clock, so no window exists in + * which a stale diff can be served. An entry is returned only when a freshly + * taken stamp equals the one captured *before* the read that produced it, which + * means no input moved from then until now. Everything else — an unprovable + * stamp, a write too recent for its mtime to be conclusive, a read that failed + * partway, a mutation that ran while the read was in flight — declines to cache + * rather than risk it. + */ + +// A diff result carries whole file contents on both sides, so an entry count alone bounds +// nothing: the real budget is characters, and one entry can legitimately be huge. +export const MAX_SETTLED_DIFF_CACHE_ENTRIES = 32 +export const MAX_SETTLED_DIFF_CACHE_RESULT_CHARACTERS = 1_000_000 +export const MAX_SETTLED_DIFF_CACHE_TOTAL_CHARACTERS = 8_000_000 + +export type SettledDiffCacheStats = { + hits: number + /** Stamp was taken but no entry matched it. */ + misses: number + /** No stamp could be taken, so the read could never be cached. */ + unprovable: number + stores: number + /** Store declined because a write was too recent for its mtime to be conclusive. */ + racyWrites: number + /** + * Subset of `racyWrites` where a component's mtime was in this host's future, so + * the clocks disagree and the refusal will persist until they converge. A nonzero + * count here means the cache is off for a reason no amount of idling will fix. + */ + clockSkewedWrites: number + /** Store declined because a mutation invalidated the cache while the read ran. */ + invalidatedDuringRead: number + entries: number + retainedCharacters: number +} + +type CacheEntry = { stamp: string; result: GitDiffResult; characters: number } + +export class SettledDiffCache { + private readonly entries = new BoundedMap({ + maxEntries: MAX_SETTLED_DIFF_CACHE_ENTRIES, + maxBytes: MAX_SETTLED_DIFF_CACHE_TOTAL_CHARACTERS, + maxEntryBytes: MAX_SETTLED_DIFF_CACHE_RESULT_CHARACTERS, + sizeOf: (entry) => entry.characters + }) + private generation = 0 + private hits = 0 + private misses = 0 + private unprovable = 0 + private stores = 0 + private racyWrites = 0 + private clockSkewedWrites = 0 + private invalidatedDuringRead = 0 + + /** + * Take before starting a read; hand back to `set`. A mutation that lands while + * the read is in flight bumps the generation, and the store is refused — the + * result describes pre-mutation state and must not outlive it. + */ + beginRead(): number { + return this.generation + } + + get(key: string, stamp: WorktreeDiffStamp | null): GitDiffResult | undefined { + if (!stamp) { + this.unprovable += 1 + return undefined + } + // BoundedMap.get() already refreshes the LRU position. + const entry = this.entries.get(key) + if (!entry || entry.stamp !== stamp.value) { + this.misses += 1 + return undefined + } + this.hits += 1 + return entry.result + } + + set( + key: string, + stamp: WorktreeDiffStamp | null, + result: GitDiffResult, + readGeneration: number + ): void { + if (!stamp) { + return + } + if (readGeneration !== this.generation) { + this.invalidatedDuringRead += 1 + return + } + if (!canProveUnchangedByStamp(stamp)) { + this.racyWrites += 1 + if (isDiffStampClockSkewed(stamp)) { + this.clockSkewedWrites += 1 + } + return + } + const characters = resultCharacterCount(result) + if (this.entries.set(key, { stamp: stamp.value, result, characters })) { + this.stores += 1 + } + } + + clear(): void { + this.entries.clear() + // Why: bump so a read that started pre-mutation can't repopulate the invalidated cache. + this.generation += 1 + } + + /** + * Why exposed: a stamp that can never match — an inode or mtime the filesystem + * reports unstably — looks exactly like having no cache at all. These counters + * are what tells a miss storm apart from a cold start. + */ + stats(): SettledDiffCacheStats { + return { + hits: this.hits, + misses: this.misses, + unprovable: this.unprovable, + stores: this.stores, + racyWrites: this.racyWrites, + clockSkewedWrites: this.clockSkewedWrites, + invalidatedDuringRead: this.invalidatedDuringRead, + entries: this.entries.size, + retainedCharacters: this.entries.retainedBytes + } + } + + resetStatsForTests(): void { + this.hits = 0 + this.misses = 0 + this.unprovable = 0 + this.stores = 0 + this.racyWrites = 0 + this.clockSkewedWrites = 0 + this.invalidatedDuringRead = 0 + } +} + +function resultCharacterCount(result: GitDiffResult): number { + return result.originalContent.length + result.modifiedContent.length +} diff --git a/src/main/git/source-control/status-branch-line-total-input.ts b/src/main/git/source-control/status-branch-line-total-input.ts index d2ac795bb55..5603d55bd4c 100644 --- a/src/main/git/source-control/status-branch-line-total-input.ts +++ b/src/main/git/source-control/status-branch-line-total-input.ts @@ -6,7 +6,7 @@ import { type GitBranchLineTotal } from '../../../shared/git-branch-line-total' import type { GitRuntimeOptions } from '../git-runtime-options' -import { gitStatusReadOptionsForWorktree } from '../git-runtime-options' +import { gitReadOptionsForWorktree } from '../git-runtime-options' import { gitExecFileAsync, gitOptionalLocksDisabledEnv } from '../runner' import type { GetStatusOptions } from './get-status-options' @@ -36,7 +36,7 @@ export function createBranchLineTotalInput( .map((entry) => entry.path), runDiffNumstat: (args, signal) => gitExecFileAsync(args, { - ...gitStatusReadOptionsForWorktree(worktreePath, options), + ...gitReadOptionsForWorktree(worktreePath, options), // Why: after the spread, so the shared lease signal wins over this caller's own. signal, env: gitOptionalLocksDisabledEnv(), diff --git a/src/main/git/source-control/status-line-stats.ts b/src/main/git/source-control/status-line-stats.ts index 660c9ea018e..32e229cdbbd 100644 --- a/src/main/git/source-control/status-line-stats.ts +++ b/src/main/git/source-control/status-line-stats.ts @@ -6,7 +6,7 @@ import { type GitLineStats } from '../../../shared/git-uncommitted-line-stats' import type { GitRuntimeOptions } from '../git-runtime-options' -import { gitStatusReadOptionsForWorktree } from '../git-runtime-options' +import { gitReadOptionsForWorktree } from '../git-runtime-options' import { gitExecFileAsync, gitOptionalLocksDisabledEnv } from '../runner' async function runNumstat( @@ -26,7 +26,7 @@ async function runNumstat( '-M' ], { - ...gitStatusReadOptionsForWorktree(worktreePath, options), + ...gitReadOptionsForWorktree(worktreePath, options), env: gitOptionalLocksDisabledEnv() } ) diff --git a/src/main/git/source-control/status-read.ts b/src/main/git/source-control/status-read.ts index 898b46e1ba9..7893cabde15 100644 --- a/src/main/git/source-control/status-read.ts +++ b/src/main/git/source-control/status-read.ts @@ -15,7 +15,7 @@ import { import { gitOptionalLocksDisabledEnv, gitStreamStdout } from '../runner' import { findExistingWorktreeSymlinkPaths } from '../worktree-symlink-detection' import type { GetStatusOptions } from './get-status-options' -import { gitDiffReadDedupe, statusReadLeaseOwner } from './git-read-cache-invalidation' +import { statusReadLeaseOwner } from './git-read-cache-invalidation' import { detectConflictOperation } from './git-conflict-operation' import { parseUnmergedEntry } from './status-conflict-entries' import { getEffectiveUpstreamStatusCacheKey } from './effective-upstream-status-cache' @@ -37,8 +37,10 @@ export async function getStatus( worktreePath: string, options: GetStatusOptions = {} ): Promise { - gitDiffReadDedupe.clear() - // Why: dedupe only concurrent identical reads; after settle, callers must run a fresh read. + // Why nothing is cleared here: a status poll is a read. Dropping the in-flight diff entry + // mid-read only made a concurrent identical request start duplicate git work, and the + // settled diff cache is keyed on stamped git state, which a read cannot change anyway. + // Mutations invalidate both, through invalidateGitReadCaches. const cacheKey = getStatusReadKey(worktreePath, options) return statusReadLeaseOwner.lease(cacheKey, options.signal, (sharedSignal) => runGetStatus(worktreePath, { ...options, signal: sharedSignal }) diff --git a/src/main/git/source-control/worktree-diff-stamp.ts b/src/main/git/source-control/worktree-diff-stamp.ts new file mode 100644 index 00000000000..f24130d274b --- /dev/null +++ b/src/main/git/source-control/worktree-diff-stamp.ts @@ -0,0 +1,226 @@ +import { readFile, stat } from 'node:fs/promises' +import * as path from 'node:path' +import { resolveGitDir } from './resolve-git-dir' + +/** + * Subprocess-free proof of every input one working-tree file diff is built from: + * the HEAD tree, the index, `.gitmodules` (which decides submodule routing) and + * the working-tree file itself. + * + * Two equal stamps mean `git show HEAD:`, `git show :` and the + * working-tree bytes would still return what they returned when the stamp was + * taken, so a caller may reuse a settled diff instead of respawning Git — on a + * WSL/UNC worktree that trades two `wsl.exe` spawns for a handful of 9p stats. + * + * `null` means "cannot prove unchanged" — not a repo, an unreadable layout, or a + * filesystem that reports no usable mtime — and callers must not cache. + */ +export type WorktreeDiffStamp = { + /** Opaque; compare for equality only. */ + value: string + /** Newest mtime any component reported, or -Infinity when every one was absent. */ + newestMtimeMs: number + capturedAtMs: number +} + +/** + * How long after a component's mtime the stamp has to be taken before a later + * write is guaranteed to move that mtime. + * + * Why 2s: FAT/exFAT truncate mtime to a 2s bucket, the coarsest granularity a + * repo can realistically sit on. Below the margin a second write inside the same + * bucket would leave the stamp identical, so the read stays uncached instead. + */ +export const DIFF_STAMP_RACY_WRITE_MARGIN_MS = 2_000 + +const MISSING = '-' +const ABSENT: StampComponent = { text: MISSING, mtimeMs: Number.NEGATIVE_INFINITY } + +type StampComponent = { text: string; mtimeMs: number } + +/** + * A write that lands in the same timestamp bucket as the stamp is invisible, so + * only a stamp taken a full bucket after its newest component can be trusted. + * + * The margin compares two clocks: `capturedAtMs` is this host's, while the mtimes + * come from whatever wrote the files. On a `\\wsl.localhost` worktree the guest + * sets them, and a guest running ahead pushes every recently-touched file past the + * margin — silently, and for as long as the skew lasts. `isDiffStampClockSkewed` + * separates that from an honestly-too-fresh file so the caller can count it. + */ +export function canProveUnchangedByStamp(stamp: WorktreeDiffStamp): boolean { + return stamp.capturedAtMs - stamp.newestMtimeMs >= DIFF_STAMP_RACY_WRITE_MARGIN_MS +} + +/** + * True when a component's mtime is in this host's future, which no local write can + * produce — so the margin above is measuring skew, not freshness, and will keep + * refusing to store until the clocks converge. + */ +export function isDiffStampClockSkewed(stamp: WorktreeDiffStamp): boolean { + return Number.isFinite(stamp.newestMtimeMs) && stamp.newestMtimeMs > stamp.capturedAtMs +} + +/** + * Stamp the inputs of one file diff. Pass `includeWorkingTree: false` for a + * staged diff, which compares HEAD to the index and never reads the working tree. + */ +export async function readWorktreeDiffStamp( + worktreePath: string, + filePath: string, + includeWorkingTree: boolean +): Promise { + const capturedAtMs = Date.now() + try { + const gitDir = await resolveGitDir(worktreePath) + const [head, index, gitmodules, workingTree] = await Promise.all([ + readHeadComponent(gitDir), + // Over-invalidates on purpose: git run outside Orca (a terminal `git status`/`git add`) + // can refresh a stat-dirty index and rewrite this file without changing a single blob, + // which costs one re-read. That is the safe direction — do not "fix" it by dropping the + // index from the stamp, because `git add` then becomes invisible and the cache serves a + // pre-staging diff. + readFileStampComponent(path.join(gitDir, 'index')), + readFileStampComponent(path.join(worktreePath, '.gitmodules')), + includeWorkingTree + ? readWorkingTreeComponent(path.join(worktreePath, filePath)) + : Promise.resolve(ABSENT) + ]) + if (!head) { + return null + } + const components = [head, index, gitmodules, workingTree] + return { + // Why JSON: a path or a ref can contain any separator character, and an ambiguous + // join is a stamp collision — two different states that compare equal. + value: JSON.stringify([ + worktreePath, + filePath, + ...components.map((component) => component.text) + ]), + newestMtimeMs: Math.max(...components.map((component) => component.mtimeMs)), + capturedAtMs + } + } catch { + return null + } +} + +/** + * Identify the commit HEAD resolves to. Reading the tip is what makes a plain + * commit visible: it rewrites `refs/heads/` and leaves HEAD untouched. + * + * A loose tip is recorded by content, so it needs no mtime margin. Only when no + * loose ref exists at all — packed refs, the reftable backend, or an unborn + * branch — does this fall back to the coarser stamps of the files a packed tip + * moves, and the recorded "no loose ref" marker still catches the ref appearing. + */ +async function readHeadComponent(gitDir: string): Promise { + const [head, commonDirEntry] = await Promise.all([ + readTrimmedFile(path.join(gitDir, 'HEAD')), + readTrimmedFile(path.join(gitDir, 'commondir')) + ]) + if (!head) { + return null + } + const commonDir = commonDirEntry ? path.resolve(gitDir, commonDirEntry) : gitDir + const refName = head.match(/^ref:\s*(.+?)\s*$/)?.[1] + if (!refName || !isSafeRefName(refName)) { + // Detached HEAD already holds the object id; an unrecognized HEAD is covered by its own text. + return { text: head, mtimeMs: Number.NEGATIVE_INFINITY } + } + // Per-worktree refs (`refs/bisect`, `refs/worktree`) live beside the checkout; branches are shared. + const [perWorktreeTip, sharedTip] = await Promise.all([ + readTrimmedFile(path.join(gitDir, refName)), + commonDir === gitDir ? Promise.resolve(null) : readTrimmedFile(path.join(commonDir, refName)) + ]) + const looseTip = perWorktreeTip ?? sharedTip + if (looseTip) { + // A loose ref shadows any packed entry, so its bytes settle the tip on their own. + return { + text: JSON.stringify([head, 'loose', perWorktreeTip ? 'worktree' : 'common', looseTip]), + mtimeMs: Number.NEGATIVE_INFINITY + } + } + const [packedRefs, reftable] = await Promise.all([ + readFileStampComponent(path.join(commonDir, 'packed-refs')), + readFileStampComponent(path.join(commonDir, 'reftable')) + ]) + return { + text: JSON.stringify([head, 'packed', packedRefs.text, reftable.text]), + mtimeMs: Math.max(packedRefs.mtimeMs, reftable.mtimeMs) + } +} + +/** Keep a hand-edited HEAD from steering the stamp outside the repo's ref store. */ +function isSafeRefName(refName: string): boolean { + const segments = refName.split(/[\\/]/) + return ( + segments[0] === 'refs' && + segments.length > 1 && + segments.every((segment) => segment.length > 0 && segment !== '.' && segment !== '..') && + !path.isAbsolute(refName) + ) +} + +async function readTrimmedFile(filePath: string): Promise { + try { + const trimmed = (await readFile(filePath, 'utf-8')).trim() + return trimmed.length > 0 ? trimmed : null + } catch (error) { + if (isMissingEntryError(error)) { + return null + } + throw error + } +} + +async function readFileStampComponent(filePath: string): Promise { + try { + const stats = await stat(filePath) + return { text: `${requireMtimeMs(stats.mtimeMs)}:${stats.size}`, mtimeMs: stats.mtimeMs } + } catch (error) { + if (isMissingEntryError(error)) { + return ABSENT + } + throw error + } +} + +async function readWorkingTreeComponent(filePath: string): Promise { + try { + const stats = await stat(filePath) + // Why ino is optional: an atomic-rename save can keep both the size and the mtime bucket, + // so a changed inode is extra proof — but Windows reports 0 for it on the network + // redirector behind `\\wsl.localhost`, and requiring an unstable 0 to match would make + // the stamp never hit on exactly the host this cache exists for. Fold it in only when + // the filesystem gives a real one. + const inode = isUsableInode(stats.ino) ? String(stats.ino) : MISSING + return { + text: `${requireMtimeMs(stats.mtimeMs)}:${stats.size}:${inode}`, + mtimeMs: stats.mtimeMs + } + } catch (error) { + if (isMissingEntryError(error)) { + return ABSENT + } + throw error + } +} + +function isUsableInode(ino: unknown): boolean { + return typeof ino === 'number' && Number.isFinite(ino) && ino !== 0 +} + +/** A filesystem (or a stub) that reports no usable mtime cannot prove anything unchanged. */ +function requireMtimeMs(mtimeMs: unknown): number { + if (typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs)) { + throw new Error('stat reported no usable mtime') + } + return mtimeMs +} + +function isMissingEntryError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code + return code === 'ENOENT' || code === 'ENOTDIR' +} diff --git a/src/main/git/status-conflict-operations.test.ts b/src/main/git/status-conflict-operations.test.ts index e99c43532f4..5b61932578f 100644 --- a/src/main/git/status-conflict-operations.test.ts +++ b/src/main/git/status-conflict-operations.test.ts @@ -15,7 +15,7 @@ const { readFileMock, statMock, rmMock, - existsSyncMock + accessMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), gitExecFileAsyncBufferMock: vi.fn(), @@ -25,7 +25,7 @@ const { readFileMock: vi.fn(), statMock: vi.fn(), rmMock: vi.fn(), - existsSyncMock: vi.fn() + accessMock: vi.fn() })) vi.mock('./runner', () => @@ -37,13 +37,16 @@ vi.mock('./runner', () => ) vi.mock('fs/promises', () => - createFsPromisesModuleMock({ lstatMock, realpathMock, readFileMock, statMock, rmMock }) + createFsPromisesModuleMock({ + lstatMock, + realpathMock, + readFileMock, + statMock, + rmMock, + accessMock + }) ) -vi.mock('fs', () => ({ - existsSync: existsSyncMock -})) - vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) => createBoundedFileReaderModuleMock(await importOriginal(), { readFileMock, @@ -83,32 +86,65 @@ describe('abortRebase', () => { describe('detectConflictOperation', () => { beforeEach(() => { readFileMock.mockReset() - existsSyncMock.mockReset() + accessMock.mockReset() }) it('ignores a stale REBASE_HEAD when no rebase directory exists', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockImplementation((target: string) => { - if (target.endsWith('MERGE_HEAD')) { - return false - } - if (target.endsWith('CHERRY_PICK_HEAD')) { - return false - } - if (target.endsWith('rebase-merge')) { - return false - } - if (target.endsWith('rebase-apply')) { - return false - } + accessMock.mockImplementation(async (target: string) => { + // Only REBASE_HEAD is present: the marker git leaves behind after a rebase finishes. if (target.endsWith('REBASE_HEAD')) { - return true + return undefined } - return false + throw Object.assign(new Error(`ENOENT: ${target}`), { code: 'ENOENT' }) }) const result = await detectConflictOperation('/repo') expect(result).toBe('unknown') }) + + it.each([ + ['MERGE_HEAD', 'merge'], + ['rebase-merge', 'rebase'], + ['rebase-apply', 'rebase'], + ['CHERRY_PICK_HEAD', 'cherry-pick'] + ])('reports %s as %s', async (marker, expected) => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + accessMock.mockImplementation(async (target: string) => { + if (target.endsWith(marker)) { + return undefined + } + throw Object.assign(new Error(`ENOENT: ${target}`), { code: 'ENOENT' }) + }) + + await expect(detectConflictOperation('/repo')).resolves.toBe(expected) + }) + + // The four markers are independent, so serializing them costs four round trips + // on a UNC git dir for something one wave answers. + it('probes every marker concurrently', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + let concurrent = 0 + let peakConcurrent = 0 + accessMock.mockImplementation(async () => { + concurrent += 1 + peakConcurrent = Math.max(peakConcurrent, concurrent) + await Promise.resolve() + concurrent -= 1 + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + + await detectConflictOperation('/repo') + + expect(accessMock).toHaveBeenCalledTimes(4) + expect(peakConcurrent).toBe(4) + }) + + it('reads as unknown when the git dir cannot be reached at all', async () => { + readFileMock.mockRejectedValue(Object.assign(new Error('EIO'), { code: 'EIO' })) + accessMock.mockRejectedValue(Object.assign(new Error('EIO'), { code: 'EIO' })) + + await expect(detectConflictOperation('/repo')).resolves.toBe('unknown') + }) }) diff --git a/src/main/git/status-diff-settled-cache.test.ts b/src/main/git/status-diff-settled-cache.test.ts new file mode 100644 index 00000000000..1e5cf2231b1 --- /dev/null +++ b/src/main/git/status-diff-settled-cache.test.ts @@ -0,0 +1,331 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as BoundedFileReader from '../../shared/node-bounded-file-reader' +import { createBoundedFileReaderModuleMock, createGitRunnerModuleMock } from './status-test-harness' + +const { + gitExecFileAsyncMock, + gitExecFileAsyncBufferMock, + gitStreamOptionsMock, + lstatMock, + realpathMock, + rmMock, + existsSyncMock +} = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn(), + gitExecFileAsyncBufferMock: vi.fn(), + gitStreamOptionsMock: vi.fn(), + lstatMock: vi.fn(), + realpathMock: vi.fn(), + rmMock: vi.fn(), + existsSyncMock: vi.fn() +})) + +/** + * A tiny in-memory filesystem, because every assertion here is about what the + * cache does when one specific path's mtime or bytes move. Sequenced + * `mockResolvedValueOnce` stacks cannot express that: the stamp and the diff + * read touch overlapping paths in an order the test should not have to know. + */ +type FakeFile = { content: Buffer; mtimeMs: number; ino: number } + +const { files } = vi.hoisted(() => ({ files: new Map() })) + +const { readFileMock, statMock, accessMock } = vi.hoisted(() => { + const missing = (target: string): NodeJS.ErrnoException => + Object.assign(new Error(`ENOENT: ${target}`), { code: 'ENOENT' }) + return { + readFileMock: vi.fn(async (target: string, encoding?: BufferEncoding) => { + const file = files.get(target) + if (!file) { + throw missing(target) + } + return encoding ? file.content.toString(encoding) : file.content + }), + statMock: vi.fn(async (target: string) => { + const file = files.get(target) + if (!file) { + throw missing(target) + } + return { + isFile: () => true, + size: file.content.byteLength, + mtimeMs: file.mtimeMs, + ino: file.ino + } + }), + accessMock: vi.fn(async (target: string) => { + if (!files.has(target)) { + throw missing(target) + } + }) + } +}) + +vi.mock('./runner', () => + createGitRunnerModuleMock({ + gitExecFileAsyncMock, + gitExecFileAsyncBufferMock, + gitStreamOptionsMock + }) +) + +vi.mock('fs/promises', () => ({ + lstat: lstatMock, + realpath: realpathMock, + readFile: readFileMock, + stat: statMock, + rm: rmMock, + access: accessMock +})) + +vi.mock('fs', () => ({ existsSync: existsSyncMock })) + +vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) => + createBoundedFileReaderModuleMock(await importOriginal(), { + readFileMock, + statMock + }) +) + +import { getDiff, getStatus, invalidateGitReadCaches, stageFile } from './status' +import { settledDiffCache } from './source-control/git-read-cache-invalidation' + +const REPO = '/repo' +const FILE = 'src/file.ts' +const WORKING_TREE_PATH = `${REPO}/${FILE}` +const HEAD_PATH = `${REPO}/.git/HEAD` +const REF_PATH = `${REPO}/.git/refs/heads/main` +const INDEX_PATH = `${REPO}/.git/index` +const GITMODULES_PATH = `${REPO}/.gitmodules` + +// Old enough that a further write is guaranteed to move the mtime, which is what +// lets the cache store at all. +const SETTLED_MTIME_MS = Date.now() - 60_000 +let nextInode = 100 + +function writeFile(target: string, content: string, mtimeMs = SETTLED_MTIME_MS): void { + files.set(target, { content: Buffer.from(content), mtimeMs, ino: (nextInode += 1) }) +} + +function blobReadCount(): number { + return gitExecFileAsyncBufferMock.mock.calls.length +} + +function seedRepo(): void { + files.clear() + // No `.git` file entry: `.git` is a directory, so reading it as a pointer misses. + writeFile(HEAD_PATH, 'ref: refs/heads/main\n') + writeFile(REF_PATH, `${'a'.repeat(40)}\n`) + writeFile(INDEX_PATH, 'index-bytes') + writeFile(WORKING_TREE_PATH, 'working-tree-content') +} + +describe('settled diff cache', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + gitExecFileAsyncBufferMock.mockReset() + gitStreamOptionsMock.mockReset() + readFileMock.mockClear() + statMock.mockClear() + accessMock.mockClear() + existsSyncMock.mockReset() + invalidateGitReadCaches() + settledDiffCache.resetStatsForTests() + seedRepo() + // `.gitmodules` is absent, so submodule routing resolves to "no submodules". + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + gitExecFileAsyncBufferMock.mockResolvedValue({ stdout: Buffer.from('index-content\n') }) + }) + + it('serves the second read of an unchanged file without respawning git', async () => { + const first = await getDiff(REPO, FILE, false) + const spawnsAfterFirst = blobReadCount() + + const second = await getDiff(REPO, FILE, false) + + expect(spawnsAfterFirst).toBeGreaterThan(0) + expect(blobReadCount()).toBe(spawnsAfterFirst) + expect(second).toEqual(first) + expect(settledDiffCache.stats().hits).toBe(1) + }) + + // The four invalidation axes, one per diff input. Each proves the stale result is + // never served, which matters more than any of the hits above. + it.each([ + [ + 'the working tree file is edited', + () => writeFile(WORKING_TREE_PATH, 'edited-in-another-editor') + ], + ['the index is rewritten by git add', () => writeFile(INDEX_PATH, 'index-bytes-after-add')], + ['HEAD moves to a new commit', () => writeFile(REF_PATH, `${'b'.repeat(40)}\n`)], + ['HEAD is detached onto another commit', () => writeFile(HEAD_PATH, `${'c'.repeat(40)}\n`)], + ['.gitmodules appears', () => writeFile(GITMODULES_PATH, '[submodule "vendor"]\n')] + ])('re-reads after %s', async (_name, mutate) => { + await getDiff(REPO, FILE, false) + const spawnsAfterFirst = blobReadCount() + + mutate() + gitExecFileAsyncBufferMock.mockResolvedValue({ stdout: Buffer.from('fresh-index-content\n') }) + const second = await getDiff(REPO, FILE, false) + + expect(blobReadCount()).toBeGreaterThan(spawnsAfterFirst) + expect(second).toMatchObject({ originalContent: 'fresh-index-content\n' }) + }) + + it('re-reads after a mutation runs through the shared invalidation point', async () => { + await getDiff(REPO, FILE, false) + const spawnsAfterFirst = blobReadCount() + + await stageFile(REPO, FILE) + await getDiff(REPO, FILE, false) + + expect(blobReadCount()).toBeGreaterThan(spawnsAfterFirst) + }) + + // The dangerous ordering: the read observed pre-mutation state, so storing its + // result after the mutation would pin a diff that was already wrong. + it('refuses to store a result for a read that a mutation overtook', async () => { + let releaseBlob = (): void => {} + const blocked = new Promise<{ stdout: Buffer }>((resolve) => { + releaseBlob = () => resolve({ stdout: Buffer.from('pre-mutation\n') }) + }) + gitExecFileAsyncBufferMock.mockReturnValue(blocked) + + const inFlight = getDiff(REPO, FILE, false) + await vi.waitFor(() => expect(blobReadCount()).toBeGreaterThan(0)) + invalidateGitReadCaches() + releaseBlob() + await inFlight + + expect(settledDiffCache.stats().invalidatedDuringRead).toBe(1) + expect(settledDiffCache.stats().entries).toBe(0) + + const spawnsAfterFirst = blobReadCount() + gitExecFileAsyncBufferMock.mockResolvedValue({ stdout: Buffer.from('post-mutation\n') }) + const second = await getDiff(REPO, FILE, false) + + expect(blobReadCount()).toBeGreaterThan(spawnsAfterFirst) + expect(second).toMatchObject({ originalContent: 'post-mutation\n' }) + }) + + // The stamp is itself several awaited stats, so a mutation can begin and end entirely + // inside it — leaving a stamp torn across the mutation that no later stamp can match. + it('refuses to store a result for a mutation that landed inside the stamp read', async () => { + const baseStat = statMock.getMockImplementation() + if (!baseStat) { + throw new Error('the fake filesystem lost its stat implementation') + } + let invalidated = false + statMock.mockImplementation(async (target: string) => { + if (!invalidated && target === INDEX_PATH) { + invalidated = true + invalidateGitReadCaches() + } + return baseStat(target) + }) + try { + await getDiff(REPO, FILE, false) + } finally { + statMock.mockImplementation(baseStat) + } + + expect(invalidated).toBe(true) + expect(settledDiffCache.stats().invalidatedDuringRead).toBe(1) + expect(settledDiffCache.stats().entries).toBe(0) + }) + + // A write inside the mtime granularity window could be overwritten again without + // moving the timestamp, so that read is not allowed to become a cache entry. + it('refuses to store a diff of a file written moments ago', async () => { + writeFile(WORKING_TREE_PATH, 'just-saved', Date.now()) + + await getDiff(REPO, FILE, false) + const spawnsAfterFirst = blobReadCount() + await getDiff(REPO, FILE, false) + + expect(blobReadCount()).toBeGreaterThan(spawnsAfterFirst) + expect(settledDiffCache.stats().racyWrites).toBeGreaterThan(0) + expect(settledDiffCache.stats().entries).toBe(0) + }) + + // A folder workspace, or any path that is not a git checkout, cannot be stamped. + it('never caches when the repo layout cannot be stamped', async () => { + files.delete(HEAD_PATH) + + await getDiff(REPO, FILE, false) + const spawnsAfterFirst = blobReadCount() + await getDiff(REPO, FILE, false) + + expect(blobReadCount()).toBeGreaterThan(spawnsAfterFirst) + expect(settledDiffCache.stats().unprovable).toBeGreaterThan(0) + expect(settledDiffCache.stats().entries).toBe(0) + }) + + // A WSL relay that never reached git returns the same empty left side as a new + // file does, so persisting it would pin a wrong diff until something else moved. + it('refuses to store a diff whose blob read failed rather than proved absence', async () => { + gitExecFileAsyncBufferMock.mockRejectedValue( + Object.assign(new Error('wsl.exe failed'), { code: 1 }) + ) + + await getDiff(REPO, FILE, false) + const spawnsAfterFirst = blobReadCount() + await getDiff(REPO, FILE, false) + + expect(blobReadCount()).toBeGreaterThan(spawnsAfterFirst) + expect(settledDiffCache.stats().entries).toBe(0) + }) + + it('caches a new file whose absence from the index git actually reported', async () => { + gitExecFileAsyncBufferMock.mockRejectedValue( + Object.assign(new Error("fatal: path 'src/file.ts' does not exist"), { code: 128 }) + ) + + await getDiff(REPO, FILE, false) + const spawnsAfterFirst = blobReadCount() + await getDiff(REPO, FILE, false) + + expect(blobReadCount()).toBe(spawnsAfterFirst) + expect(settledDiffCache.stats().hits).toBe(1) + }) + + it('keeps staged and unstaged diffs of one file in separate entries', async () => { + await getDiff(REPO, FILE, false) + const spawnsAfterUnstaged = blobReadCount() + + await getDiff(REPO, FILE, true) + + expect(blobReadCount()).toBeGreaterThan(spawnsAfterUnstaged) + }) + + // A staged diff compares HEAD to the index, so a working-tree edit must not evict it. + it('keeps a staged diff across a working-tree edit', async () => { + await getDiff(REPO, FILE, true) + const spawnsAfterFirst = blobReadCount() + + writeFile(WORKING_TREE_PATH, 'edited-after-staging') + await getDiff(REPO, FILE, true) + + expect(blobReadCount()).toBe(spawnsAfterFirst) + }) + + it('does not let a status poll drop the in-flight diff read', async () => { + let releaseBlob = (): void => {} + const blocked = new Promise<{ stdout: Buffer }>((resolve) => { + releaseBlob = () => resolve({ stdout: Buffer.from('index-content\n') }) + }) + gitExecFileAsyncBufferMock.mockReturnValue(blocked) + + const first = getDiff(REPO, FILE, false) + await vi.waitFor(() => expect(blobReadCount()).toBeGreaterThan(0)) + const spawnsBeforePoll = blobReadCount() + + await getStatus(REPO) + const second = getDiff(REPO, FILE, false) + releaseBlob() + await Promise.all([first, second]) + + // Why exactly equal: the second read must join the first, not start its own spawns. + expect(blobReadCount()).toBe(spawnsBeforePoll) + }) +}) diff --git a/src/main/git/status-diff.test.ts b/src/main/git/status-diff.test.ts index df59b1b9a3f..d66c64e7a89 100644 --- a/src/main/git/status-diff.test.ts +++ b/src/main/git/status-diff.test.ts @@ -102,7 +102,8 @@ describe('getDiff', () => { expect(gitExecFileAsyncBufferMock).toHaveBeenCalledWith(['show', ':src/file.ts'], { cwd: '/repo', - maxBuffer: 10 * 1024 * 1024 + maxBuffer: 10 * 1024 * 1024, + preferWslDirectGit: true }) expect(readFileMock).toHaveBeenCalledWith(path.join('/repo', 'src/file.ts')) expect(result).toEqual({ @@ -122,7 +123,8 @@ describe('getDiff', () => { expect(gitExecFileAsyncBufferMock).toHaveBeenCalledWith(['show', ':src/file.ts'], { cwd: '/repo', - maxBuffer: 10 * 1024 * 1024 + maxBuffer: 10 * 1024 * 1024, + preferWslDirectGit: true }) }) @@ -139,7 +141,8 @@ describe('getDiff', () => { ['show', '--end-of-options', 'HEAD:src/file.ts'], { cwd: '/repo', - maxBuffer: 10 * 1024 * 1024 + maxBuffer: 10 * 1024 * 1024, + preferWslDirectGit: true } ) expect(result.originalContent).toBe('head-content\n') @@ -158,15 +161,19 @@ describe('getDiff', () => { }) it('does not read oversized working-tree files into memory', async () => { + const workingTreePath = path.join('/repo', 'dist/large.log') gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: Buffer.from('index-content\n') }) - statMock.mockResolvedValueOnce({ - isFile: () => true, - size: 10 * 1024 * 1024 + 1 - }) + // Why by path: the diff also stats git-dir entries to stamp its inputs, so a + // one-shot queue would hand the oversized size to whichever stat ran first. + statMock.mockImplementation(async (target: string) => + target === workingTreePath + ? { isFile: () => true, size: 10 * 1024 * 1024 + 1 } + : { isFile: () => true, size: 12 } + ) const result = await getDiff('/repo', 'dist/large.log', false) - expect(readFileMock).not.toHaveBeenCalled() + expect(readFileMock).not.toHaveBeenCalledWith(workingTreePath) expect(result.kind).toBe('binary') expect(result.modifiedIsBinary).toBe(true) expect(result.modifiedContent).toBe('') @@ -259,8 +266,14 @@ describe('getDiff', () => { it('flags a deleted image so previewers can fall back to the original bytes', async () => { const pngBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00]) + const workingTreePath = path.join('/repo', 'assets/deleted.png') gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: pngBuffer }) - statMock.mockRejectedValueOnce(Object.assign(new Error('missing'), { code: 'ENOENT' })) + statMock.mockImplementation(async (target: string) => { + if (target === workingTreePath) { + throw Object.assign(new Error('missing'), { code: 'ENOENT' }) + } + return { isFile: () => true, size: 12 } + }) const result = await getDiff('/repo', 'assets/deleted.png', false) @@ -275,9 +288,15 @@ describe('getDiff', () => { it('does not treat an unreadable working-tree image as a deletion', async () => { const pngBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00]) + const workingTreePath = path.join('/repo', 'assets/unreadable.png') gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: pngBuffer }) - statMock.mockResolvedValueOnce({ isFile: () => true, size: 5 }) - readFileMock.mockRejectedValueOnce(new Error('EIO')) + statMock.mockImplementation(async () => ({ isFile: () => true, size: 5 })) + readFileMock.mockImplementation(async (target: string) => { + if (target === workingTreePath) { + throw new Error('EIO') + } + return Buffer.from('') + }) const result = await getDiff('/repo', 'assets/unreadable.png', false) diff --git a/src/main/git/status-submodule.test.ts b/src/main/git/status-submodule.test.ts index 138d2874b19..b67a4eca9a0 100644 --- a/src/main/git/status-submodule.test.ts +++ b/src/main/git/status-submodule.test.ts @@ -123,11 +123,11 @@ describe('submodule diff routing', () => { expect(gitExecFileAsyncBufferMock).toHaveBeenCalledWith( ['show', '--end-of-options', `${OLD_OID}:lib/main.dart`], - { cwd: SUBMODULE, maxBuffer: 10 * 1024 * 1024 } + { cwd: SUBMODULE, maxBuffer: 10 * 1024 * 1024, preferWslDirectGit: true } ) expect(gitExecFileAsyncBufferMock).toHaveBeenCalledWith( ['show', '--end-of-options', `${NEW_OID}:lib/main.dart`], - { cwd: SUBMODULE, maxBuffer: 10 * 1024 * 1024 } + { cwd: SUBMODULE, maxBuffer: 10 * 1024 * 1024, preferWslDirectGit: true } ) expect(result.kind).toBe('text') expect(result.originalContent).toBe('v1\n') @@ -167,11 +167,11 @@ describe('submodule diff routing', () => { expect(gitExecFileAsyncBufferMock).toHaveBeenCalledWith( ['show', '--end-of-options', `${OLD_OID}:lib/main.dart`], - { cwd: SUBMODULE, maxBuffer: 10 * 1024 * 1024 } + { cwd: SUBMODULE, maxBuffer: 10 * 1024 * 1024, preferWslDirectGit: true } ) expect(gitExecFileAsyncBufferMock).toHaveBeenCalledWith( ['show', '--end-of-options', `${NEW_OID}:lib/main.dart`], - { cwd: SUBMODULE, maxBuffer: 10 * 1024 * 1024 } + { cwd: SUBMODULE, maxBuffer: 10 * 1024 * 1024, preferWslDirectGit: true } ) expect(result.kind).toBe('text') expect(result.originalContent).toBe('v1\n') @@ -202,7 +202,8 @@ describe('submodule diff routing', () => { expect(gitExecFileAsyncBufferMock).toHaveBeenCalledWith(['show', ':lib/main.dart'], { cwd: SUBMODULE, - maxBuffer: 10 * 1024 * 1024 + maxBuffer: 10 * 1024 * 1024, + preferWslDirectGit: true }) expect(readFileMock).toHaveBeenCalledWith(path.join(SUBMODULE, 'lib/main.dart')) expect(result.kind).toBe('text') diff --git a/src/main/git/status-test-harness.ts b/src/main/git/status-test-harness.ts index eda7e2f5014..99925f90363 100644 --- a/src/main/git/status-test-harness.ts +++ b/src/main/git/status-test-harness.ts @@ -15,6 +15,8 @@ export type FsPromisesMocks = { readFileMock: MockFn statMock: MockFn rmMock: MockFn + /** Optional: defaults to "nothing exists", which is what most git-read tests assume. */ + accessMock?: MockFn } export function createGitRunnerModuleMock(mocks: GitRunnerMocks): Record { @@ -49,7 +51,12 @@ export function createFsPromisesModuleMock(mocks: FsPromisesMocks): Record { + throw Object.assign(new Error(`ENOENT: ${target}`), { code: 'ENOENT' }) + }) } } diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index e658e773743..9663becb622 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -15,6 +15,7 @@ const { readFileMock, statMock, rmMock, + accessMock, existsSyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), @@ -25,6 +26,7 @@ const { readFileMock: vi.fn(), statMock: vi.fn(), rmMock: vi.fn(), + accessMock: vi.fn(), existsSyncMock: vi.fn() })) @@ -37,9 +39,17 @@ vi.mock('./runner', () => ) vi.mock('fs/promises', () => - createFsPromisesModuleMock({ lstatMock, realpathMock, readFileMock, statMock, rmMock }) + createFsPromisesModuleMock({ + lstatMock, + realpathMock, + readFileMock, + statMock, + rmMock, + accessMock + }) ) +// Why still here: unmerged-entry parsing probes the working tree through node:fs directly. vi.mock('fs', () => ({ existsSync: existsSyncMock })) @@ -62,6 +72,8 @@ describe('getStatus', () => { lstatMock.mockReset() readFileMock.mockReset() existsSyncMock.mockReset() + accessMock.mockReset() + accessMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) // Why: untracked line counting stats a file before reading it; any // under-limit size routes the read to readFileMock. statMock.mockReset() @@ -75,7 +87,12 @@ describe('getStatus', () => { it('parses unmerged porcelain v2 entries into unresolved conflict rows', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockImplementation((target: string) => target.endsWith('MERGE_HEAD')) + accessMock.mockImplementation(async (target: string) => { + if (target.endsWith('MERGE_HEAD')) { + return undefined + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'u UU N... 100644 100644 100644 100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc src/app.ts\n' @@ -97,7 +114,6 @@ describe('getStatus', () => { it('maps deleted conflicts to deleted when the working tree file is absent', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'u UD N... 100644 100644 000000 100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc src/deleted.ts\n' @@ -132,7 +148,6 @@ describe('getStatus', () => { it('passes core.quotePath=false and round-trips UTF-8 paths', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '1 .M N... 100644 100644 100644 ce013625030ba8dba906f756967f9e9ca394464a ce013625030ba8dba906f756967f9e9ca394464a docs/日本語/sample.md\n' @@ -159,7 +174,6 @@ describe('getStatus', () => { it('preserves porcelain v2 submodule dirtiness flags on status rows', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '1 AM S..U 000000 160000 160000 0000000000000000000000000000000000000000 7844cb64e631f17a9ca5b548f3500ef7cecd2f17 nested-repo\n' @@ -185,7 +199,6 @@ describe('getStatus', () => { it('omits ignored files by default and parses them when requested', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '! dist/\n! generated/file.js\n' }) @@ -206,7 +219,6 @@ describe('getStatus', () => { it('parses branch identity from porcelain v2 branch headers', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '# branch.oid abcdef1234567890\n# branch.head feature/prompts\n1 .M N... 100644 100644 100644 ce013625030ba8dba906f756967f9e9ca394464a ce013625030ba8dba906f756967f9e9ca394464a src/app.ts\n' @@ -222,7 +234,6 @@ describe('getStatus', () => { it('folds upstream ahead/behind from porcelain v2 into the status result', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '# branch.oid abcdef1234567890\n# branch.head feature/prompts\n# branch.upstream origin/feature/prompts\n# branch.ab +2 -3\n' @@ -241,7 +252,6 @@ describe('getStatus', () => { it('reports no upstream from porcelain v2 status when no same-name origin branch exists', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockImplementation((args: string[]) => { if (args[0] === '-c' && args.includes('status')) { return Promise.resolve({ @@ -274,7 +284,6 @@ describe('getStatus', () => { it('uses same-name origin branch status for legacy base-tracking worktrees', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: @@ -297,7 +306,6 @@ describe('getStatus', () => { it('omits --ignored and ignoredPaths when includeIgnored is not requested', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) const result = await getStatus('/repo') @@ -315,7 +323,6 @@ describe('getStatus', () => { it('parses ! porcelain v2 records into ignoredPaths when includeIgnored is true', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '! dist/\n! .env\n! coverage/\n' }) @@ -337,7 +344,6 @@ describe('getStatus', () => { it('attaches per-area line counts from staged and unstaged numstat', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockImplementation((args: string[]) => { if (args.includes('status')) { return Promise.resolve({ @@ -364,7 +370,6 @@ describe('getStatus', () => { it('reuses unchanged line stats only when the safety hint is present', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockImplementation((args: string[]) => { if (args.includes('status')) { return Promise.resolve({ @@ -392,7 +397,6 @@ describe('getStatus', () => { it('recomputes after a scan whose numstat failed instead of pinning missing counts', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(false) let failNumstat = true gitExecFileAsyncMock.mockImplementation((args: string[]) => { if (args.includes('status')) { @@ -422,7 +426,6 @@ describe('getStatus', () => { it('invalidates safety reuse for a new head and for known mutations', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(false) let head = 'head-1' gitExecFileAsyncMock.mockImplementation((args: string[]) => { if (args.includes('status')) { @@ -450,7 +453,6 @@ describe('getStatus', () => { it('isolates line-stat reuse between WSL distributions', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockImplementation((args: string[]) => { if (args.includes('status')) { return Promise.resolve({ @@ -474,7 +476,6 @@ describe('getStatus', () => { it('attaches numstat counts for literal paths containing rename markers', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockImplementation((args: string[]) => { if (args.includes('status')) { return Promise.resolve({ @@ -503,7 +504,6 @@ describe('getStatus', () => { it('attaches staged rename counts to the new path', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockImplementation((args: string[]) => { if (args.includes('status')) { return Promise.resolve({ @@ -531,7 +531,6 @@ describe('getStatus', () => { }) it('counts untracked file contents as additions', async () => { - existsSyncMock.mockReturnValue(false) lstatMock.mockResolvedValue({ size: 14, mtimeMs: 1, @@ -555,7 +554,6 @@ describe('getStatus', () => { it('leaves binary working-tree changes without counts', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockImplementation((args: string[]) => { if (args.includes('status')) { return Promise.resolve({ @@ -578,7 +576,6 @@ describe('getStatus', () => { it('skips numstat entirely for a clean working tree', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) await getStatus('/repo') @@ -588,7 +585,6 @@ describe('getStatus', () => { it('truncates and flags didHitLimit when entries exceed the limit', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(false) const stdout = `${Array.from({ length: 25 }, (_, i) => `? file${i}.txt`).join('\n')}\n` gitExecFileAsyncMock.mockReset() gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) @@ -651,7 +647,6 @@ describe('getStatus', () => { it('does not flag didHitLimit for a normal repo under the limit', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockReset() gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '? a.txt\n? b.txt\n' }) diff --git a/src/main/git/wsl-git-read-environment.ts b/src/main/git/wsl-git-read-environment.ts index 7c809453498..e75efee30d4 100644 --- a/src/main/git/wsl-git-read-environment.ts +++ b/src/main/git/wsl-git-read-environment.ts @@ -7,10 +7,18 @@ import { export type WslGitReadEnvironment = { gitPath: string; home: string; path: string } const PROBE_TIMEOUT_MS = 10_000 +/** + * How long a read may wait for a cold probe before taking the login shell. + * Short enough that a wedged distro cannot stall the panel, long enough that a + * healthy one resolves and every later read runs shell-free. + */ +export const WSL_GIT_READ_ENVIRONMENT_WAIT_MS = 1_500 const PROBE_MAX_BUFFER = 64 * 1024 const TRANSIENT_PROBE_RETRY_MS = 30_000 const environmentByDistro = new Map>() -const resolvedEnvironmentByDistro = new Map() +// Why the null entries matter: a settled "no direct route" answer is what lets a read skip the +// bounded probe wait entirely instead of racing an already-decided promise on every call. +const settledEnvironmentByDistro = new Map() const transientRetryAfterByDistro = new Map() type ProbeOutcome = @@ -76,6 +84,7 @@ export function getWslGitReadEnvironment(distro: string): Promise= retryAfter) { environmentByDistro.delete(distro) + settledEnvironmentByDistro.delete(distro) transientRetryAfterByDistro.delete(distro) } let environment = environmentByDistro.get(distro) @@ -85,10 +94,11 @@ export function getWslGitReadEnvironment(distro: string): Promise ({ diffCache: settledDiffCache.stats() }) }) function focusExistingWindow(): void { focusExistingMainWindow({ From 0096e47850c37dc51a643af531c1078c469e0638 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:15:03 -0700 Subject: [PATCH 026/152] fix(windows): keep windows-process-tree gyp paths absolute under pnpm (#16688) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(windows): keep windows-process-tree gyp paths absolute under pnpm Hourly Windows builds have failed since #16598 at `build-windows-process-tree-relay-addon`: `require('node-addon-api').targets` is cwd-relative, so node-gyp evaluates it from the pnpm store realpath and then loads it from the `node_modules` symlink. That resolves `node_addon_api.gyp` outside the repo. Use `require.resolve` for an absolute path, matching the node-pty patch. * i18n: keep ja skill-filter labels on the catalog's Agent brand #16682 merged with a failing localization catalog: ja used エージェント in three new skill-filter strings, and repair-locale-catalog rewrites those to Agent. Match the rest of ja.json so static analysis can pass. --- .../@vscode__windows-process-tree@0.8.0.patch | 17 +++++-- ...build-windows-process-tree-relay-addon.mjs | 16 +++++-- .../windows-process-tree-gyp-path.test.mjs | 38 ++++++++++++++++ docs/reference/windows-process-enumeration.md | 17 ++++--- pnpm-lock.yaml | 6 +-- src/renderer/src/i18n/locales/ja.json | 44 ++++++++++++++++--- 6 files changed, 114 insertions(+), 24 deletions(-) create mode 100644 config/scripts/windows-process-tree-gyp-path.test.mjs diff --git a/config/patches/@vscode__windows-process-tree@0.8.0.patch b/config/patches/@vscode__windows-process-tree@0.8.0.patch index cf68eea1985..8e6e8e86e25 100644 --- a/config/patches/@vscode__windows-process-tree@0.8.0.patch +++ b/config/patches/@vscode__windows-process-tree@0.8.0.patch @@ -2,13 +2,22 @@ diff --git a/binding.gyp b/binding.gyp index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..5f15551cb1520af996f216500a83ac68b57f5104 100644 --- a/binding.gyp +++ b/binding.gyp +@@ -3,7 +3,7 @@ + { + "target_name": "windows_process_tree", + "dependencies": [ +- " { + it('keeps the gyp project path absolute so pnpm Windows source builds find it', () => { + expect(PATCH).toContain( + `+ " { + const resolved = execFileSync(process.execPath, ['-p', ABSOLUTE_GYP], { + cwd: PACKAGE_DIR, + encoding: 'utf8' + }).trim() + expect(isAbsolute(resolved)).toBe(true) + expect(existsSync(resolved)).toBe(true) + }) +}) diff --git a/docs/reference/windows-process-enumeration.md b/docs/reference/windows-process-enumeration.md index 236d6adbf47..115cc7040a9 100644 --- a/docs/reference/windows-process-enumeration.md +++ b/docs/reference/windows-process-enumeration.md @@ -116,11 +116,11 @@ it as an optional relay artifact. `config/scripts/build-windows-process-tree-relay-addon.mjs` builds it from the source pnpm has already patched, on a Windows runner, and refuses to run if -either patch hunk is missing — the Spectre hunk fails loudly, but the -1024-process hunk fails *silently*, so the source is checked rather than the -install trusted. It also reads the PE machine field of the output, because a -cross-build that quietly emitted host arch would ship a binary the target cannot -load. +any patch hunk is missing — the Spectre hunk fails loudly, the 1024-process +hunk fails *silently*, and the relative gyp path dies at configure on Windows. +The source is checked rather than the install trusted. It also reads the PE +machine field of the output, because a cross-build that quietly emitted host +arch would ship a binary the target cannot load. Windows arm64 cross-compiles from the x64 runner — verified on real hardware, producing `IMAGE_FILE_MACHINE_ARM64` (0xaa64) against x64's 0x8664. It needs the @@ -143,7 +143,7 @@ on any other OS keeps using the scan. ## Why the package is patched -`config/patches/@vscode__windows-process-tree@0.8.0.patch` carries two hunks. +`config/patches/@vscode__windows-process-tree@0.8.0.patch` carries three hunks. 1. **Spectre mitigation.** The upstream `binding.gyp` requires Spectre-mitigated libraries, which Orca's Windows build agents do not install. `node-pty` is @@ -153,6 +153,11 @@ on any other OS keeps using the scan. 1024 and the querying process was itself among the 27 missing. A truncated snapshot silently hides the descendants a teardown is trying to reap — the exact failure the native path exists to remove. +3. **Absolute `node-addon-api` gyp path.** `require('node-addon-api').targets` + is cwd-relative. node-gyp on Windows evaluates it from the pnpm store + realpath, then loads the relative path from the `node_modules` symlink, so + `node_addon_api.gyp` resolves outside the repo and hourly Windows builds + die at configure. `node-pty` is patched the same way for the same reason. The typings claim `commandLine` is truncated at 512 characters. Measured, it is not: the longest observed on a real host was 26,059. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d739a42d25..0d80c024dff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,7 +9,7 @@ overrides: patchedDependencies: '@vscode/windows-process-tree@0.8.0': - hash: 7c08bebce9b36829be6035218db2383f1a889c21ec907ea7e85c36fc54795a05 + hash: 73a90530e6b95c05dac50f2c48787fb51cb292587773016ebe57eb05e41075a0 path: config/patches/@vscode__windows-process-tree@0.8.0.patch '@xterm/addon-ligatures@0.11.0-beta.287': hash: 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920 @@ -407,7 +407,7 @@ importers: optionalDependencies: '@vscode/windows-process-tree': specifier: 0.8.0 - version: 0.8.0(patch_hash=7c08bebce9b36829be6035218db2383f1a889c21ec907ea7e85c36fc54795a05) + version: 0.8.0(patch_hash=73a90530e6b95c05dac50f2c48787fb51cb292587773016ebe57eb05e41075a0) sherpa-onnx-darwin-arm64: specifier: 1.12.37 version: 1.12.37 @@ -9605,7 +9605,7 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - '@vscode/windows-process-tree@0.8.0(patch_hash=7c08bebce9b36829be6035218db2383f1a889c21ec907ea7e85c36fc54795a05)': + '@vscode/windows-process-tree@0.8.0(patch_hash=73a90530e6b95c05dac50f2c48787fb51cb292587773016ebe57eb05e41075a0)': dependencies: node-addon-api: 7.1.0 optional: true diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 3b9ebdd2393..1c72acfa367 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3888,7 +3888,7 @@ "35b9a724a0": "利用可能", "c13b82793c": "インストールを管理", "aee7b99cc6": "リンクからインストール", - "filterProvider": "エージェントで絞り込み", + "filterProvider": "Agent で絞り込み", "filterSource": "ソースで絞り込み", "allSources": "すべて", "clearFilters": "フィルターをクリア", @@ -3914,13 +3914,43 @@ "deleteSkill": "削除…" }, "SkillsList": { "listLabel": "スキル" }, - "sourceStatus": { "missing": "フォルダーが見つかりません", "remoteRepo": "リモートリポジトリ — 未スキャン", "unavailable": "未スキャン" }, + "sourceStatus": { + "missing": "フォルダーが見つかりません", + "remoteRepo": "リモートリポジトリ — 未スキャン", + "unavailable": "未スキャン" + }, "sources": { "heading": "スキルフォルダー" }, - "sourceKind": { "home": "ホーム", "workspace": "ワークスペース", "bundled": "バンドル済み", "plugin": "プラグイン" }, - "count": { "skillOne": "{{count}} 件のスキル", "skillOther": "{{count}} 件のスキル", "sourceOne": "{{count}} 件のソース", "sourceOther": "{{count}} 件のソース", "fileOne": "{{count}} 個のファイル", "fileOther": "{{count}} 個のファイル", "resultOne": "{{count}} 件の結果", "resultOther": "{{count}} 件の結果", "selected": "{{count}} 件を選択", "shareOne": "{{count}} 件のスキルを共有", "shareOther": "{{count}} 件のスキルを共有", "linkOne": "{{count}} 件のリンク", "linkOther": "{{count}} 件のリンク" }, - "filter": { "allAgents": "すべてのエージェント", "sharedAgent": "共有 (.agents)" }, - "SkillsSelectionHeader": { "exit": "選択を終了", "exitTooltip": "選択を終了 · Esc", "title": "共有するスキルを選択", "selectAll": "対象の {{count}} 件をすべて選択", "clear": "クリア", "deleteTitle": "削除するスキルを選択" }, - "SkillDetailDialog": { "agents": "エージェント", "updated": "更新日", "copy": "コピー" }, + "sourceKind": { + "home": "ホーム", + "workspace": "ワークスペース", + "bundled": "バンドル済み", + "plugin": "プラグイン" + }, + "count": { + "skillOne": "{{count}} 件のスキル", + "skillOther": "{{count}} 件のスキル", + "sourceOne": "{{count}} 件のソース", + "sourceOther": "{{count}} 件のソース", + "fileOne": "{{count}} 個のファイル", + "fileOther": "{{count}} 個のファイル", + "resultOne": "{{count}} 件の結果", + "resultOther": "{{count}} 件の結果", + "selected": "{{count}} 件を選択", + "shareOne": "{{count}} 件のスキルを共有", + "shareOther": "{{count}} 件のスキルを共有", + "linkOne": "{{count}} 件のリンク", + "linkOther": "{{count}} 件のリンク" + }, + "filter": { "allAgents": "すべての Agent", "sharedAgent": "共有 (.agents)" }, + "SkillsSelectionHeader": { + "exit": "選択を終了", + "exitTooltip": "選択を終了 · Esc", + "title": "共有するスキルを選択", + "selectAll": "対象の {{count}} 件をすべて選択", + "clear": "クリア", + "deleteTitle": "削除するスキルを選択" + }, + "SkillDetailDialog": { "agents": "Agent", "updated": "更新日", "copy": "コピー" }, "SkillFreshnessNudge": { "titleOne": "インストール済みの Orca スキルが古くなっています", "titleMany": "インストール済みの Orca スキル {{value0}} 件が古くなっています", From 9135b6f004154cc2b37769c26b102996bbec8ee0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:16:05 -0700 Subject: [PATCH 027/152] feat(orchestration): surface nested worker depth and propagate it across hosts (#16669) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(orchestration): surface nested worker depth and propagate it across hosts Builds on the depth enforcement in the previous commit, which shipped with the setting reachable only by editing settings.json and with workers never told they could nest. Adds the Settings -> Agents control (a 1/2/3 select rather than a free-form number, which bounds the value without inventing a numeric input primitive). The key stays absent from the SettingsUpdate RPC schema, matching agentSkillSharingEnabled: settings.update is reachable from the CLI, so an RPC-writable depth would let a worker raise its own cap. Adds a SUB-DISPATCH block to the dispatch preamble, emitted only when the worker actually has budget left. A worker told it "usually cannot" delegate still tries and then reports the refusal as a blocker, so the section is omitted entirely rather than softened. Propagates depth to federated worker hosts. Previously the home side computed and stored a depth the remote host never received, so a remote attachment always read as depth 1. That is correct at the default cap and wrong as soon as the cap is raised — precisely when someone starts relying on nesting. The field is optional, so an older Run home simply omits it and the attachment's NOT NULL DEFAULT 1 keeps the fail-closed behaviour. Enforcement still runs on the executing host against that host's own cap, consistent with the SSH execution boundary. * fix(orchestration): close nested depth readiness gaps * fix(settings): defer nested depth translations * fix(orchestration): drop federated depth keys that main already landed The enforcement PR's review pass added the same federated depth propagation before it merged, so replaying this branch onto main produced duplicate object keys. Keep main's versions -- its schema entry validates an integer >= 1 rather than any finite number. * fix(settings): label nested worker depth select * fix(settings): move nested depth to orchestration * fix(settings): refine nested depth placement --- skill-guides/orchestration.md | 2 +- src/cli/bundled-skill-guides.ts | 2 +- .../coordinator-task-dispatch.ts | 1 + .../runtime/orchestration/preamble.test.ts | 34 +++++++ src/main/runtime/orchestration/preamble.ts | 26 +++++- .../rpc/methods/orchestration-federation.ts | 3 + .../orchestration-tasks-dispatch.test.ts | 24 +++++ .../rpc/methods/orchestration-workers.ts | 1 + src/main/runtime/rpc/methods/orchestration.ts | 8 ++ .../settings/OrchestrationPane.test.tsx | 93 ++++++++++++++++++- .../components/settings/OrchestrationPane.tsx | 40 +++++++- .../src/components/settings/Settings.tsx | 4 +- .../settings/SettingsFormControls.tsx | 9 +- .../settings/nested-worker-depth-copy.ts | 29 ++++++ .../settings/orchestration-search.ts | 30 +++++- .../useSettingsNavigationMetadata.test.ts | 22 +++++ .../hooks/useSettingsNavigationMetadata.ts | 4 +- src/renderer/src/i18n/locales/en.json | 4 +- src/shared/nested-worker-depth.test.ts | 1 + src/shared/nested-worker-depth.ts | 6 +- 20 files changed, 323 insertions(+), 20 deletions(-) create mode 100644 src/renderer/src/components/settings/nested-worker-depth-copy.ts diff --git a/skill-guides/orchestration.md b/skill-guides/orchestration.md index ef8181dd4e3..5236a7f47fc 100644 --- a/skill-guides/orchestration.md +++ b/skill-guides/orchestration.md @@ -182,7 +182,7 @@ A dispatched worker normally cannot dispatch sub-workers. Attempting it fails wi `nested_worker_depth_exceeded` and a message telling the worker to complete the task itself. Do that — do not try to route around it. -The limit is a number, not an on/off switch. `Settings -> Agents -> Nested worker depth` +The limit is a number, not an on/off switch. `Settings -> Orchestration -> Nested worker depth` sets how many generations are allowed: - `1` (default): a coordinator dispatches workers; those workers do not dispatch. diff --git a/src/cli/bundled-skill-guides.ts b/src/cli/bundled-skill-guides.ts index 66f55c065e7..4385841d2f8 100644 --- a/src/cli/bundled-skill-guides.ts +++ b/src/cli/bundled-skill-guides.ts @@ -30,7 +30,7 @@ const ORCA_LINEAR_MARKDOWN = "---\nname: orca-linear\ndescription: >-\n Use Orc const ORCA_PER_WORKSPACE_ENV_MARKDOWN = "---\nname: orca-per-workspace-env\ndescription: >-\n Set up, review, debug, or validate Orca per-workspace environment recipes —\n on-demand, disposable runtimes (cloud sandboxes, VMs, or local) created fresh\n for each workspace. Covers first-time setup (provider prerequisites, the\n reusable base snapshot, the coding-agent auth snapshot, credentials, and\n state), not just the per-workspace lifecycle scripts. Use to stand up\n per-workspace environments, fix an `environmentRecipes` entry in `orca.yaml`, scaffold\n provider lifecycle scripts, or resolve an `orca vm recipe doctor` failure.\n---\n\n# Per-Workspace Environments\n\nHelp a user stand up and maintain a repo-owned per-workspace environment recipe end to end. Each\nworkspace gets its own on-demand, disposable runtime (a cloud sandbox, a VM, or a local one),\ncreated fresh and torn down after.\n\nOrca is a **thin wrapper**: you guide, detect, and scaffold; you never own the user's cloud account,\nbilling, images, or credentials.\n\n- **You DO:** sequence the setup, detect what's detectable (provider CLI present/logged-in? recipe\n present? `doctor` passing?), scaffold provider-templated scripts the user fills in, drive the slow\n snapshot/auth phases with the user, and always show the next action.\n- **You DO NOT:** create accounts, choose plans/regions, invent org/project/scope ids, store or print\n secrets, or run anything that spends money without an explicit user OK.\n\nFirst-time setup has **four phases before the per-workspace recipe runs** — easy to miss, so walk\nthem in order:\n\n1. **Prerequisites** — cloud account, provider CLI, scope/project, plan limits, git token (§2).\n2. **Base snapshot** — reusable image: tools + repo + headless build, snapshotted once (§3).\n3. **Agent-auth snapshot** — boot the base, run interactive device-auth, re-snapshot (§4).\n4. **State** — thread snapshot id / scope / project / port between phases via a state file (§6).\n\nThen the **per-workspace contract** (create/suspend/resume/destroy) runs fast (§8).\n\n**The one branch that shapes everything — connection mode:** **Orca-server** (`create` runs `orca serve`\nin the env and emits a `pairingCode`; §7c/§7f) vs **SSH** (`create` runs no server and emits a\n`connection.type:\"ssh\"` block Orca dials into; §7g/§7h). Settle this first — it changes the `create`\noutput shape and half the templates.\n\nKeep Orca's checkout behavior unchanged by default: omit `checkoutMode`, emit schema version 1, and\nlet Orca create a linked worktree. Only use `checkoutMode: provisioned-root` when the user explicitly\nwants one ephemeral machine to clone the finished workspace itself. This niche mode currently requires\ndirect SSH, an ordinary non-bare/non-sparse primary checkout at `projectRoot`, and schema version 2.\n\n**Quick-start (happy path):** interview the user (connection mode Orca-server vs SSH, provider, agent CLI,\ngit auth — §1.2) + read the provider's CLI docs → scaffold `scripts/orca-vm/` from §7 → run the\nbase-snapshot script, then the auth script (you invoke these by hand; not via `orca.yaml`) → wire\n`environmentRecipes` in `orca.yaml` → `orca vm recipe doctor --json` (free) → then the `--provision`\nself-test loop (§9) until it passes.\n\n---\n\n## 1. Setup workflow\n\nDrive these with the user. **[CHECKPOINT]** steps need explicit confirmation — they spend money, take\na long time, or need the user at the keyboard. Never create an Orca workspace or commit unless asked.\n\n1. **Inspect the repo** for an existing `environmentRecipes` entry, `scripts/orca-vm/`, a state file, or setup\n notes. If a working recipe exists, jump to Doctor (§9) instead of rebuilding.\n2. **Interview the user up front** — gather these choices and confirm them back before scaffolding\n anything. Don't pick for them (§11); don't guess.\n - **Connection mode:** how Orca attaches to the environment — an **Orca server** (the VM runs\n `orca serve` and Orca pairs over its pairing URL; worked example §7f) or **SSH** (Orca connects to\n the host over SSH; §7g). This decides the recipe's connection shape, so settle it first.\n - **Checkout ownership:** do not ask by default. Only when the user requires the environment to\n create the exact final checkout, confirm `provisioned-root` and direct SSH; otherwise omit it.\n - **Provider:** Vercel Sandbox, Fly, Modal, an existing SSH host, … For non-obvious providers, also\n ask scope/project/region and plan limits (§2). Then **read that provider's CLI/SDK docs** (or\n ` --help`) before scaffolding — you need its exact create/exec/snapshot/remove verbs.\n If a provider advertises `ssh`, verify whether it exposes a real dialable SSH target\n (host/port/user/key or proxy command) or only a provider-mediated interactive shell; Orca SSH mode\n needs the former.\n - **Coding-agent CLI + account:** which agent runs in the VM (`codex`, `claude`, …) and that the user\n has an account for it — it gets logged in during the Phase-3 auth snapshot (§4).\n - **Git auth:** the token source for cloning a private repo (`GH_TOKEN`/`GITHUB_TOKEN` or `gh auth\n token`; §5).\n3. **Check prerequisites (§2)** — detect the provider CLI + auth and confirm the items above are in\n place before any paid step.\n4. **Scaffold scripts + state file** from §7 (worked Vercel example: §7f; SSH host: §7g; Docker SSH:\n §7h; Windows: §7i), filling in the provider's real commands. Make them executable.\n5. **[CHECKPOINT] Build the base snapshot (§3)** — paid, slow.\n6. **[CHECKPOINT] Authenticate the agent (§4)** — interactive; the user follows a URL/code. **You cannot\n drive this step** — you run commands non-interactively, so there's no TTY for `docker exec -it` /\n `ssh -t` to prompt against. The **user** runs the Phase-3 login in their own terminal (or via the\n Claude Code harness bang-prefix — `! `, with the required space after `!`); you scaffold and drive\n the non-interactive phases around it. After kicking it off, **ask the user to report back once the login\n finishes** — you can't observe it completing, and you need that confirmation before resuming the\n non-interactive steps (base/auth commit, doctor, provision).\n7. **Wire the recipe** so `orca.yaml` points create/suspend/resume/destroy at the scripts (§8). The\n workspace composer reads `environmentRecipes` from the project's primary checkout of `orca.yaml`, **not** from\n a feature branch or worktree. So a recipe added only on a branch won't appear as a \"Run on\" option\n until that `orca.yaml` change is committed and merged to the project's primary branch. Tell the user\n this up front: `doctor`/`--provision` validate the scripts from the working copy on any branch, but\n creating a workspace from the recipe in the picker needs it on primary.\n8. **Dry-run doctor** — `orca vm recipe doctor --repo-path --json` (free, static; §9).\n Fix every failure before going live.\n9. **[CHECKPOINT] Live self-test** — get the user's OK once, then run\n `orca vm recipe doctor --provision --json` as a loop: it runs create → validates →\n destroys, and on failure returns a full transcript. Read it, fix the scripts, and re-run yourself until\n it passes (§9). Spends cloud money; the one approval covers the loop.\n10. **[CHECKPOINT] Optional workspace test** — only if asked: create a workspace via the picker, then\n verify sleep/wake/delete.\n\n---\n\n## 2. Phase 1 — Prerequisites\n\nThe user's responsibility; verify what's verifiable, ask for the rest, invent nothing. State which\nitems you verified vs. which the user asserted.\n\n- **Connection mode** (Orca server vs SSH) confirmed with the user — see §1 step 2; it shapes the recipe.\n- **Cloud account + plan** that allows sandboxes/VMs. Ask.\n- **Provider CLI installed + authenticated** — detect (`command -v `), check auth (e.g.\n `vercel whoami`). If missing, point at the provider's docs; don't log them in.\n- **Scope / project / region** the sandboxes live under. Ask; flows into every script via state.\n- **Plan / timeout / RAM caps.** Record them — e.g. Vercel Hobby caps sandbox timeout at **45m**,\n which limits both the base build and per-workspace runtime (see §10).\n- **Git token for private repos** (`GH_TOKEN`/`GITHUB_TOKEN`, or the provider's git auth; can fall back\n to `gh auth token`). See §5.\n- **Coding-agent CLI choice** (`codex`, `claude`…) and that the user has an account — it gets\n authenticated into the VM in Phase 3.\n\n---\n\n## 3. Phase 2 — Base snapshot (the reusable image)\n\nBuild **once**, snapshot, and every workspace boots from it in seconds instead of rebuilding.\nProvisioning + building takes a while (often ~20–30 min), so it runs behind a checkpoint. The script\nshape is §7a; key points:\n\n- Build the **headless Electron main only** (not the renderer) so it fits in plan RAM.\n- Use the VM image's package manager (`apt`/`dnf`/`apk`, per the base distro — not the provider brand).\n- Clone with the git token via `GIT_ASKPASS` (§5).\n- **Trap errors and remove the half-built sandbox** so a crash doesn't leave a paid resource running.\n- Snapshot the stopped sandbox, parse the snapshot id, and write it + scope/project/port/repo to state.\n\n---\n\n## 4. Phase 3 — Agent-auth snapshot (interactive)\n\nThe base snapshot has the agent CLI installed but **not logged in**, and per-workspace VMs are\nephemeral — so authenticate once and bake it into a second snapshot layer. Script shape is §7b:\n\n1. Boot a sandbox from the base `snapshotId` (from state).\n2. Run the agent's login **interactively** (`--interactive --tty`); the user completes the URL/code in\n their browser. On a **headless VM this must be the device-auth flow** (e.g. `codex login --device-auth`),\n **not** plain `codex login`: the default OAuth login starts a loopback callback server on a container\n port the host browser can't reach, so it hangs. Device-auth instead prints a URL + code the user opens\n on the **host**.\n3. Verify login; **refuse to snapshot an unauthenticated VM.** Prefer the status command's **exit code**\n (most agent CLIs exit non-zero when unauthenticated). If you grep instead, agent status often goes to\n **stderr** (e.g. `codex login status` prints \"Logged in using ChatGPT\" there), so **fold stderr first**\n (`... 2>&1 | grep …`) and match the agent's **exact success line** — never `grep -qi 'logged in'`, which\n also matches \"**not** logged in\" and would commit an unauthenticated image.\n4. Re-snapshot, parse the new id, and overwrite `snapshotId` in state to the authenticated image\n (recording `authSourceSnapshotId`). Remove the auth sandbox.\n\n**You can't drive step 2 yourself** (you run commands non-interactively — no TTY). The **user** runs it in\ntheir own terminal, or via the Claude Code harness bang-prefix (`! `, with the required space after\n`!`). You scaffold/boot the sandbox and run steps 3–4, but **you cannot observe the interactive login\nfinishing** — so **ask the user to tell you when it's done** before you verify and re-snapshot.\n\nIf the agent's credentials are short-lived, warn that the snapshot may need periodic re-auth (§10).\n\nFor disposable runtimes, do **not** treat a host agent config directory (for example `~/.codex`) as the\nauth snapshot by bind-mounting or copying it wholesale. Agent homes often contain sqlite state, hook\napproval state, caches, logs, and host-specific env/config. Instead, authenticate/configure the agent\ninside the disposable runtime and snapshot/commit that runtime layer.\n\n---\n\n## 5. Credentials\n\n- **Never** commit secrets or put them in `userData`, recipe JSON, comments, docs, or the state file.\n- **Git token:** read from env (`GH_TOKEN`/`GITHUB_TOKEN`), falling back to `gh auth token`. Pass to the\n VM only via the provider's ephemeral `--env`. Inside the VM, use a `GIT_ASKPASS` helper with\n `x-access-token` (not the token in the clone URL) and `GIT_TERMINAL_PROMPT=0` so a missing token fails\n fast instead of hanging. When you write the helper from inside `bash -lc` under `set -u`, escape the\n positional arg and the token (`\\$1`, `\\$GH_TOKEN`) so they land **literally** and resolve at git-runtime\n — an unescaped `$1` aborts with \"unbound variable\", and a literal `$GH_TOKEN` keeps the real token out of\n the written file. `rm -f` the helper after the clone/fetch.\n- **Provider auth:** rely on the provider CLI's logged-in session, not checked-in keys.\n- **Agent auth:** lives in the authenticated snapshot (Phase 3) — never a file you write or commit.\n- State holds only **non-secret** wiring (snapshot ids, scope, project, port, repo url/ref).\n\n---\n\n## 6. State file\n\nA repo-local JSON file (e.g. `scripts/orca-vm/-state.json`) threads non-secret values between\nphases. Each script resolves values as **env var → state → built-in fallback**, and merges its outputs\nback. Phase 2 writes the base `snapshotId`; Phase 3 overwrites it with the authenticated snapshot;\nper-workspace `create` boots from `snapshotId`.\n\n```json\n{\n \"baseName\": \"orca-base\",\n \"snapshotId\": \"snap_authenticated_image_id\",\n \"authSourceSnapshotId\": \"snap_base_image_id\",\n \"scope\": \"\",\n \"project\": \"\",\n \"port\": 7331,\n \"repoUrl\": \"https://host/org/repo.git\",\n \"repoRef\": \"main\",\n \"projectRoot\": \"/abs/path/on/remote/repo\"\n}\n```\n\n---\n\n## 7. Script templates (provider-agnostic shapes)\n\nScaffold under `scripts/orca-vm/`. These are **shapes** — fill in the provider's real commands. All\nreserve stdout for the final JSON and log progress to stderr. Include a shared `json_value ` /\n`env_value ` reader (env → state → fallback) in each.\n\n**Where each script runs:**\n\n- **Local-side** (`create`/`suspend`/`resume`/`destroy` + the base-snapshot/auth scripts the user\n invokes) runs **on the user's desktop**, so it must run on their OS. macOS/Linux: `#!/usr/bin/env\n bash`, `set -euo pipefail`, quoted paths. **Windows:** a bare `.sh` won't run — scaffold `.ps1`/`.cmd`\n or require WSL/Git-Bash and point `orca.yaml` at the right launcher.\n- **Remote-side** (commands you `exec` *inside* the Linux VM) always runs in the VM's Linux shell, so\n bash is fine there regardless of the user's OS.\n\n### 7a. Base-snapshot (`-base-snapshot.sh`) — Phase 2\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve base_name/repo_url/repo_ref/project_root/port/scope/project/timeout (env→state→fallback)\n# resolve gh token: GH_TOKEN | GITHUB_TOKEN | `gh auth token`\n# 1. provision a sandbox (timeout/vcpus/published port/snapshot retention); trap: remove on error\n# 2. remote exec (long timeout): install pkgs + gh + corepack/pnpm + agent CLI;\n# clone with GIT_ASKPASS(token); write headless main-only build config;\n# dev setup; pnpm install; build CLI; build headless electron main; smoke-check tools\n# 3. snapshot stopped sandbox; parse snapshot id (fail if unparseable)\n# 4. merge { baseName, snapshotId, projectRoot, repoUrl, repoRef, port, scope, project } into state\n# print only the state JSON to stdout\n```\n\nWorked Vercel commands for this phase are in §7f. You run this script by hand (not via `orca.yaml`),\nafter exporting the first-run inputs the state file doesn't have yet — e.g. provider scope/project, the\nrepo URL/ref, and a git token (`GH_TOKEN`); later runs read them back from state.\n\n### 7b. Auth (`-base-auth.sh`) — Phase 3\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read source snapshot from state.snapshotId (fail if absent); auth_name=\"${base_name}-auth\"\n# 1. boot sandbox from source snapshot; trap: remove on error\n# 2. INTERACTIVE/TTY remote exec: agent login — user completes URL/code. Headless VM: MUST use the\n# device-auth flow (e.g. `codex login --device-auth`) — plain OAuth login binds a loopback callback\n# port the host can't reach and hangs. User runs this themselves (you have no interactive TTY); ask\n# them to report back when it's done before continuing.\n# 3. verify login, then refuse to snapshot if not logged in. Prefer the status command's EXIT CODE (most\n# agent CLIs exit non-zero when unauthenticated) over string-matching. If you must grep, fold stderr\n# first (`status 2>&1 | grep …` — many agents print the success line there) and match the agent's exact\n# success line; never `grep -qi 'logged in'`, which also matches \"not logged in\". Codex example: §7f.\n# 4. snapshot; parse new id\n# 5. merge { snapshotId:, authSourceSnapshotId: } into state; remove auth sandbox\n# print only the state JSON to stdout\n```\n\n### 7c. Create (`-create.sh`) — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read authenticated snapshotId/scope/project/port/repo*/project_root (env→state→fallback)\n# fail clearly if snapshotId is missing (point back to Phases 2–3)\n# name = orca-${ORCA_RECIPE_ID}-${ORCA_VM_INSTANCE_ID} (sanitized, length-capped)\n# 1. boot sandbox from snapshotId with a published port; capture the public URL → pairing address\n# (an externally reachable wss:// URL); trap: remove sandbox on error\n# 2. remote exec: ensure repo at desired commit; rebuild only if commit changed (cache marker)\n# 3. remote exec: start orca serve in the background and read the recipe JSON it writes (see below)\n# 4. print serve's JSON to stdout, optionally enriched with userData:\n# { schemaVersion:1, pairingCode, projectRoot, userData:{ provider, resourceId:name, snapshotId } }\n```\n\n**The exact `orca serve` invocation and its output (verified — do not improvise the flags).** Inside the\nVM, run:\n\n```bash\norca serve \\\n --port \"$PORT\" \\\n --project-root \"$ABS_REPO_PATH_ON_REMOTE\" \\\n --pairing-address \"$EXTERNAL_WSS_URL\" \\\n --recipe-json\n```\n\n**Binary name:** in a VM built from source (the Phase-2 flow), run it as `pnpm exec orca-dev serve …`\nfrom the repo root — `orca-dev` is the in-repo entrypoint and is what the §7f example uses. Plain\n`orca serve …` is the same command when the built CLI is installed on the VM's PATH. The flags/output\nare identical either way.\n\nThere is **no `--host` flag**. `--project-root` must be an absolute directory on the remote. With\n`--recipe-json` the server **stays running** and prints exactly this single object to **stdout**, then\nkeeps serving:\n\n```json\n{ \"schemaVersion\": 1, \"pairingCode\": \"\", \"projectRoot\": \"\" }\n```\n\n`pairingCode` is the pairing URL, already pointing at whatever you passed as `--pairing-address` — so set\n`--pairing-address` to the externally reachable address and **pass `pairingCode` through unchanged; never\nhand-rewrite it**. Because serve runs in the foreground and doesn't exit, redirect its stdout to a file\nand poll until that file parses as JSON (and bail if the process dies — dump its stderr log). Your\n`create` script then prints that JSON (optionally merging `userData`). Concrete pattern: §7f.\n\n### 7d. Suspend / resume / destroy — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\npayload=\"$(cat)\" # Orca passes lifecycle JSON on stdin\nresource_id=\"$(node -e 'const d=JSON.parse(process.argv[1]); process.stdout.write(d.recipeResult?.userData?.resourceId ?? \"\")' \"$payload\")\"\n[ -n \"$resource_id\" ] || { echo \"No resource id in lifecycle payload\" >&2; exit 1; }\n# suspend: provider suspend \"$resource_id\"\n# resume: provider resume \"$resource_id\"; then RE-EMIT fresh recipe JSON (pairing may change)\n# destroy: provider remove \"$resource_id\" (or set destroy: none in orca.yaml)\n```\n\n### 7e. State file — scaffold with scope/project/repo filled in and snapshot ids empty (§6).\n\n### 7f. Worked example — Vercel Sandbox (all three phases)\n\nA real, working shape (the Vercel surface is a CLI: `vercel sandbox create|exec|snapshot|remove`). Adapt\nnames; verify flags against `vercel sandbox --help` for the user's CLI version before relying on them.\nThese ground §7a (base snapshot) and §7b (auth), which are otherwise generic skeletons.\n\n**Phase 2 — base snapshot (§7a):** provision → install tools + clone + headless build → snapshot.\n\n```bash\n# provision a fresh build sandbox (retain a couple of snapshots); trap-remove on error\nvercel sandbox create --name \"$base\" --runtime node24 --timeout 30m --vcpus 4 --publish-port \"$port\" \\\n --snapshot-expiration 30d --keep-last-snapshots 2 \"${vercel_args[@]}\" >&2\n# remote build (long timeout): install pkgs+gh+pnpm+agent CLI, clone with GIT_ASKPASS (write the helper\n# with LITERAL \\$1/\\$GH_TOKEN so they resolve at git-runtime, not write-time — see §5/§7f create — then\n# `rm -f /tmp/askpass.sh`), write the headless main-only build config (drop the renderer), dev setup,\n# build CLI + headless main, smoke-check\nvercel sandbox exec \"$base\" \"${vercel_args[@]}\" --timeout 25m --env \"GH_TOKEN=$gh_token\" … -- bash -lc '…build…' >&2\n# snapshot the STOPPED sandbox and parse the id from CLI output (fail if unparseable)\nout=\"$(vercel sandbox snapshot \"$base\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nsnapshot_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# merge { baseName, snapshotId, scope, project, port, repoUrl, repoRef, projectRoot } into state; print state JSON\n```\n\n**Phase 3 — agent-auth snapshot (§7b):** boot the base, log the agent in interactively, re-snapshot.\n(`codex` below is an example — substitute the user's chosen agent's login/status verbs, e.g. `claude`.)\n\n```bash\nvercel sandbox create --name \"$auth\" --snapshot \"$snapshot_id\" --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" >&2\n# INTERACTIVE — the USER runs this in their own terminal (you have no interactive TTY) and completes the\n# URL/code on the HOST. --device-auth is MANDATORY on a headless VM: plain `codex login` binds a loopback\n# callback port the host browser can't reach and hangs. Ask the user to report back when login finishes.\nvercel sandbox exec --interactive --tty \"$auth\" \"${vercel_args[@]}\" -- bash -lc 'codex login --device-auth'\n# refuse to snapshot an unauthenticated VM — fold stderr, match codex's exact success line (§4)\nvercel sandbox exec \"$auth\" \"${vercel_args[@]}\" --timeout 30s -- bash -lc 'codex login status 2>&1' | grep -Eqi 'Logged in using ChatGPT|Logged in via device' \\\n || { echo \"agent not logged in; not snapshotting\" >&2; exit 1; }\nout=\"$(vercel sandbox snapshot \"$auth\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nnew_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# overwrite state.snapshotId = new_id, record authSourceSnapshotId = snapshot_id; remove the auth sandbox\n```\n\n**Per-workspace `create`** (the fast path):\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback: snapshot_id, scope, project, port, repo_url, repo_ref, project_root\nvercel_args=(); [ -n \"$scope\" ] && vercel_args+=(--scope \"$scope\"); [ -n \"$project\" ] && vercel_args+=(--project \"$project\")\n[ -n \"$snapshot_id\" ] || { echo \"snapshotId missing — run Phases 2–3 first\" >&2; exit 1; }\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nrecipe_id=\"${ORCA_RECIPE_ID:-vercel-sandbox}\"\nrecipe_id=\"${recipe_id//./-}\" # Vercel names forbid dots.\ninstance_id=\"${ORCA_VM_INSTANCE_ID:-$(date +%s)}\"\nmax_recipe_id_length=$((128 - ${#instance_id} - 6)) # Preserve the unique instance suffix.\n[ \"$max_recipe_id_length\" -gt 0 ] || { echo \"ORCA_VM_INSTANCE_ID is too long for a Vercel sandbox name\" >&2; exit 1; }\nname=\"orca-${recipe_id:0:max_recipe_id_length}-${instance_id}\"\n\n# Arm cleanup BEFORE create so a failing create can't leak a half-built paid sandbox.\ncleanup_on_error() { [ \"$?\" -ne 0 ] && vercel sandbox remove \"$name\" \"${vercel_args[@]}\" >/dev/null 2>&1 || true; }\ntrap cleanup_on_error EXIT\n\n# 1. boot from the authenticated snapshot, publish the serve port\ncreate_output=\"$(vercel sandbox create --name \"$name\" --snapshot \"$snapshot_id\" \\\n --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$create_output\" >&2\n# Vercel prints the published https URL; derive the external wss:// pairing address from it\npublic_url=\"$(printf '%s\\n' \"$create_output\" | sed -nE 's#.*(https://[^[:space:]]+\\.vercel\\.run).*#\\1#p' | head -1)\"\n[ -n \"$public_url\" ] || { echo \"no published URL in create output\" >&2; exit 1; }\npairing_ws=\"${public_url/https:\\/\\//wss://}\"\n\n# 2. (remote) ensure the repo is at the right commit; rebuild only if the commit changed (cache marker)\nvercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 20m \\\n --env \"GH_TOKEN=$gh_token\" --env \"ORCA_PROJECT_ROOT=$project_root\" \\\n --env \"ORCA_REPO_URL=$repo_url\" --env \"ORCA_REPO_REF=$repo_ref\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; \\\n # Re-establish git auth for the private-repo fetch (why + full rationale: §5); else it hangs on a prompt.\n # Load-bearing escaping: \\$1 and \\$GH_TOKEN must land LITERALLY and resolve at git-runtime. Test after\n # any edit here — reformatting the nested printf/node quoting silently breaks the fetch or leaks the token.\n if [ -n \"${GH_TOKEN:-}\" ]; then \\\n printf \"%s\\n\" \"#!/usr/bin/env bash\" \"case \\\"\\$1\\\" in *Username*) echo x-access-token;; *Password*) echo \\\"\\$GH_TOKEN\\\";; esac\" > /tmp/askpass.sh; \\\n chmod 700 /tmp/askpass.sh; export GIT_ASKPASS=/tmp/askpass.sh GIT_TERMINAL_PROMPT=0; fi; \\\n git fetch origin \"$ORCA_REPO_REF\"; \\\n git checkout -B \"$ORCA_REPO_REF\" FETCH_HEAD; \\\n rm -f /tmp/askpass.sh; \\\n c=\"$(git rev-parse HEAD)\"; [ -f .orca-built ] && [ \"$(cat .orca-built)\" = \"$c\" ] || { \\\n pnpm install --prefer-offline && pnpm run build:cli && \\\n node config/scripts/run-electron-vite-build.mjs --config config/electron-vite.vm-serve.config.ts && \\\n printf \"%s\" \"$c\" > .orca-built; }' >&2\n\n# 3. (remote) start orca serve in the background, writing recipe JSON to a file; poll until it parses\nrecipe_json=\"$(vercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 60s \\\n --env \"ORCA_PORT=$port\" --env \"ORCA_PROJECT_ROOT=$project_root\" --env \"ORCA_PAIRING_ADDRESS=$pairing_ws\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; rm -f /tmp/orca-recipe.json /tmp/orca-serve.log; \\\n nohup pnpm exec orca-dev serve --port \"$ORCA_PORT\" --project-root \"$ORCA_PROJECT_ROOT\" \\\n --pairing-address \"$ORCA_PAIRING_ADDRESS\" --recipe-json >/tmp/orca-recipe.json 2>/tmp/orca-serve.log /dev/null 2>&1 && { cat /tmp/orca-recipe.json; exit 0; }; \\\n kill -0 \"$pid\" 2>/dev/null || { cat /tmp/orca-serve.log >&2; exit 1; }; sleep 0.25; \\\n done; cat /tmp/orca-serve.log >&2; echo \"serve recipe JSON timed out\" >&2; exit 1')\"\n\n# 4. print serve's JSON enriched with userData (single object on stdout)\nnode -e 'const p=JSON.parse(process.argv[1]); console.log(JSON.stringify({...p, schemaVersion:1,\n userData:{...p.userData, provider:\"vercel-sandbox\", resourceId:process.argv[2], snapshotId:process.argv[3]}}))' \\\n \"$recipe_json\" \"$name\" \"$snapshot_id\"\ntrap - EXIT\n```\n\n`suspend`/`resume`/`destroy` use `vercel sandbox stop|...|remove \"$resource_id\"` reading\n`userData.resourceId` from stdin (§7d). This is the **Orca-server** connection mode (the recipe emits a\npairing URL). If the user chose **SSH** in the §1 interview, use §7g instead.\n\n### 7g. Worked example — existing SSH host (SSH connection mode)\n\nSSH mode is **fundamentally different from §7c/§7f**, not a relabeling of them:\n\n- **`create` does NOT run `orca serve` and does NOT emit a `pairingCode`.** Orca itself connects to the\n host over its SSH relay, brings up the git + filesystem providers, and imports the repo. The script's\n only job is to make the host ready and **print SSH connection details** Orca will dial.\n- The result uses a `connection` block with `type: \"ssh\"` and a `target`, **not** the flat\n `pairingCode`/`projectRoot` shape. Exact shape (Orca rejects anything else):\n\n```json\n{\n \"schemaVersion\": 1,\n \"connection\": {\n \"type\": \"ssh\",\n \"projectRoot\": \"/abs/path/to/repo/on/host\",\n \"target\": {\n \"label\": \"my-box\",\n \"host\": \"192.0.2.10\",\n \"port\": 22,\n \"username\": \"ubuntu\",\n \"identityFile\": \"~/.ssh/id_ed25519\",\n \"jumpHost\": \"bastion.example.com\",\n \"proxyCommand\": \"cloudflared access ssh --hostname %h\",\n \"relayGracePeriodSeconds\": 0,\n \"portForwards\": []\n }\n }\n}\n```\n\n`label`, `host`, `port`, `username` are required; the rest are optional — omit any you don't need.\n\nFor an explicitly requested one-VM-per-workspace checkout, the create script must read\n`ORCA_RECIPE_RESULT_SCHEMA_VERSION`, `ORCA_REPO_URL`, `ORCA_REPO_REF`, `ORCA_REPO_REF_HEAD`, and\n`ORCA_REPO_BRANCH`. Use `ORCA_REPO_REF` to fetch the selected source, but create\n`ORCA_REPO_BRANCH` at the exact `ORCA_REPO_REF_HEAD` commit; resolving the symbolic ref again can race\nwith an upstream update. `ORCA_REPO_URL` and `ORCA_REPO_REF` are a matched fetch pair, including when\nthe desktop source uses multiple remotes. Return that primary checkout at `projectRoot` and emit the\nsame SSH result with:\n\n```bash\n[ -n \"${ORCA_REPO_REF_HEAD:-}\" ] || { echo \"missing pinned source commit\" >&2; exit 1; }\ngit fetch origin \"$ORCA_REPO_REF\"\ngit cat-file -e \"${ORCA_REPO_REF_HEAD}^{commit}\"\ngit checkout -B \"$ORCA_REPO_BRANCH\" \"$ORCA_REPO_REF_HEAD\"\n```\n\n```json\n{\n \"schemaVersion\": 2,\n \"checkoutMode\": \"provisioned-root\",\n \"connection\": {\n \"type\": \"ssh\",\n \"projectRoot\": \"/abs/repo\",\n \"target\": { \"label\": \"my-box\", \"host\": \"192.0.2.10\", \"port\": 22, \"username\": \"ubuntu\" }\n }\n}\n```\n\nFail if the requested schema is not `2`; do not silently fall back to the ordinary recipe shape.\n\n**Networking → which `target` fields to set** (how *your desktop* reaches the box — there is no\n`orca serve` URL in SSH mode):\n\n- Public IP / DNS, or a Tailscale/VPN address → `host`; SSH port → `port` (usually 22).\n- Key auth → `identityFile` (add `identitiesOnly: true` if the agent has many keys).\n- Through a bastion → `jumpHost` (a `user@host` ProxyJump) **or** a full `proxyCommand` (e.g. an access\n proxy). Use one, not both.\n- A service port the workspace needs → add entries to `portForwards`.\n- `relayGracePeriodSeconds` (optional): how long Orca keeps the SSH relay alive after the workspace\n detaches before tearing it down; `0` = tear down immediately. Leave it off unless the user wants a\n reconnect grace window.\n\n**Toolchain & agent auth on a persistent (no-snapshot) host — do this ONCE, by hand, before wiring the\nrecipe** (there's no base image to bake; the host *is* the base). Run the §7f Phase-2 install steps and\nthe §7f Phase-3 ` login --device-auth` **directly over SSH on the host** (interactive, e.g.\n`ssh -t user@host ' login --device-auth'`). After that the host stays ready across workspaces.\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback (default unset optionals to \"\"): ssh_username, host,\n# ssh_port (default 22), identity_file, jump_host, proxy_command, project_root, repo_url, repo_ref\n: \"${identity_file:=}\"; : \"${jump_host:=}\"; : \"${proxy_command:=}\" # avoid set -u aborts on optionals\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nssh_target=\"${ssh_username}@${host}\"\nssh_opts=(-p \"$ssh_port\"); [ -n \"$identity_file\" ] && ssh_opts+=(-i \"$identity_file\")\n# Why: a fresh host's key isn't in known_hosts; a StrictHostKeyChecking prompt would HANG a\n# non-interactive create. Pre-add the key (or set the option) so it can't block.\nssh-keyscan -p \"$ssh_port\" \"$host\" >> \"$HOME/.ssh/known_hosts\" 2>/dev/null || true\n\n# 1. ensure the repo is present and at the right commit on the host (NO orca serve here)\nssh \"${ssh_opts[@]}\" \"$ssh_target\" \\\n \"GH_TOKEN='$gh_token' GIT_TERMINAL_PROMPT=0 bash -lc '\n set -euo pipefail\n [ -d \\\"$project_root/.git\\\" ] || git clone \\\"$repo_url\\\" \\\"$project_root\\\"\n cd \\\"$project_root\\\" && git fetch origin \\\"$repo_ref\\\" && git checkout -B \\\"$repo_ref\\\" FETCH_HEAD\n '\" >&2\n\n# 2. print the SSH connection block (NO pairingCode, NO orca serve). host/port/username tell Orca's\n# relay how to dial in; identityFile/jumpHost/proxyCommand/portForwards are emitted when set.\nnode -e 'const [host,port,user,idf,jh,pc,root]=process.argv.slice(1);\n const target={ label:\"per-workspace-host\", host, port:Number(port), username:user };\n if(idf) target.identityFile=idf; if(jh) target.jumpHost=jh; if(pc) target.proxyCommand=pc;\n // add target.portForwards=[...] here if the workspace needs forwarded service ports\n console.log(JSON.stringify({ schemaVersion:1, connection:{ type:\"ssh\", projectRoot:root, target } }))' \\\n \"$host\" \"$ssh_port\" \"$ssh_username\" \"$identity_file\" \"$jump_host\" \"$proxy_command\" \"$project_root\"\n```\n\n`suspend`/`resume`/`destroy`: on a persistent host there's usually nothing to tear down — set\n`destroy: none` and omit suspend/resume. (Orca still disconnects/reconnects its own SSH relay on\nsleep/wake/delete — that's separate from these scripts.)\n\nIf the SSH host is instead an **ephemeral/snapshot-capable VM** (your hypervisor, or a cloud VM with\nimage support), keep the §7f Phase-2/3 base-image model for provisioning, but still emit the\n`connection.type:\"ssh\"` block above instead of starting `orca serve`.\n\n### 7h. Worked example — local Docker SSH (SSH connection mode)\n\nLocal Docker can model an ephemeral SSH VM without cloud cost: build a base image with `sshd`, tools,\nrepo prerequisites, and the agent CLI; run an **interactive auth container** once; then `docker commit`\nthat container as the authenticated image used by per-workspace `create`.\n\nKey points:\n\n- Publish container SSH to a random localhost port (`-p 127.0.0.1::22`) and emit\n `connection.type:\"ssh\"` with `host:\"127.0.0.1\"`, that port, `username`, `identityFile`, and\n `identitiesOnly:true`.\n- Generate a repo-local SSH key if needed, but gitignore the private/public key files.\n- **Bake SSH host keys into the base image** (`ssh-keygen -A` at **build** time; at runtime only generate\n if absent). Ephemeral containers all present the **same** host key, so `known_hosts` on `127.0.0.1`\n doesn't churn as the published port rotates across workspaces (otherwise every container's freshly\n generated key collides on `localhost` and trips host-key-changed warnings).\n- The auth image is the Docker equivalent of Phase 3: the **user** runs the agent login **inside** the\n container (you can't drive it — you have no interactive TTY), configures proxy env/config, approves\n hooks, and you commit once they report it's done. On a headless container use the **device-auth** flow\n (§4). Verify login before committing — exit code, or fold stderr and match the exact success line (§4).\n- Do not bind-mount or copy the host's full agent home into the image. Let each container have writable\n agent state; only the committed auth image should carry reusable authenticated state.\n- If committing from an interactive shell, force the runtime entrypoint back to `sshd`:\n `docker commit --change='ENTRYPOINT [\"/usr/local/bin/orca-docker-ssh-entrypoint\"]' …`.\n- `destroy` should read `recipeResult.userData.resourceId` and run `docker rm -f \"$resource_id\"`.\n\nValidation before wiring/live use:\n\n```bash\ndocker image inspect \"$auth_image\" --format '{{json .Config.Entrypoint}}'\ndocker run -d --name \"$name\" -p 127.0.0.1::22 -e \"ORCA_SSH_PUBLIC_KEY=$pubkey\" \"$auth_image\"\ndocker ps -a --filter \"name=$name\"\ndocker logs \"$name\"\nssh -i \"$key\" -p \"$port\" -o IdentitiesOnly=yes user@127.0.0.1 'codex --version'\n```\n\nIf the container exits immediately, inspect logs before the cleanup trap removes it; a committed\ninteractive image with `ENTRYPOINT [\"bash\"]` is a common cause.\n\nAlso confirm the **host key is stable** across containers: the SSH `ssh -i … 127.0.0.1` dial should not\ntrigger a host-key-changed warning when a second container reuses the port. If it does, the host keys\nweren't baked into the base image (see the `ssh-keygen -A` point above).\n\n### 7i. Windows local-side scripts\n\nThe local-side scripts run on the user's desktop. On **Windows**, a bare `.sh` won't execute. Either\nrequire WSL/Git-Bash (and point `orca.yaml` at e.g. `bash ./scripts/orca-vm/.sh` via a `.cmd`\nlauncher), or scaffold PowerShell equivalents. Minimal PowerShell shape:\n\n```powershell\n#requires -Version 5\n$ErrorActionPreference = 'Stop'\n# resolve env→state→fallback; run the provider CLI / ssh the same way;\n# capture provider output; build the result object for the chosen mode and write ONE line of JSON to stdout.\n# Orca-server mode: @{ schemaVersion=1; pairingCode=$pairingCode; projectRoot=$projectRoot; userData=@{...} }\n# SSH mode: @{ schemaVersion=1; connection=@{ type=\"ssh\"; projectRoot=$projectRoot;\n# target=@{ label=$label; host=$host; port=$port; username=$user } } } (see §7g/§7h)\n($result | ConvertTo-Json -Compress -Depth 6)\n# progress/errors → Write-Error / the error stream, never stdout.\n```\n\nThe remote-side commands you run *inside* the Linux VM stay bash regardless of the desktop OS.\n\n---\n\n## 8. Per-workspace recipe contract (the fast path)\n\nOnce the authenticated snapshot exists, this runs on every workspace create. Define recipes in\n`orca.yaml`:\n\n```yaml\nenvironmentRecipes:\n - id: cloud-sandbox\n name: Cloud Sandbox\n create: ./scripts/orca-vm/cloud-sandbox-create.sh\n suspend: ./scripts/orca-vm/cloud-sandbox-suspend.sh\n resume: ./scripts/orca-vm/cloud-sandbox-resume.sh\n destroy: ./scripts/orca-vm/cloud-sandbox-destroy.sh\n```\n\n`create` runs **locally from the repo root** and prints **one** JSON object to stdout. Its shape depends\non the connection mode chosen in §1:\n\n**Orca-server mode** — boot the env, start `orca serve` in it, and print serve's result:\n\n```json\n{\n \"schemaVersion\": 1,\n \"pairingCode\": \"orca-pairing-code-or-url\",\n \"projectRoot\": \"/absolute/path/to/repo/on/remote\",\n \"userData\": { \"provider\": \"example\", \"resourceId\": \"provider-resource-id\" }\n}\n```\n\nHere `pairingCode` (from `orca serve --recipe-json`) and `projectRoot` are required; `schemaVersion` (`1`)\nand `userData` are optional.\n\n**SSH mode** — do **not** run `orca serve`; print the `connection.type:\"ssh\"` block instead (full shape +\nworked script in §7g). `pairingCode` is **not** used in SSH mode.\n\n**Optional provisioned root** — only for direct SSH and only when explicitly requested. Add\n`checkoutMode: provisioned-root` to the recipe, require `ORCA_RECIPE_RESULT_SCHEMA_VERSION=2`, create\nthe requested `ORCA_REPO_BRANCH` at the pinned `ORCA_REPO_REF_HEAD` commit (use `ORCA_REPO_REF` only\nto fetch that commit) at the returned `projectRoot`, and emit schema version 2 with\n`checkoutMode: \"provisioned-root\"`. All recipes without this field retain the schema-v1 behavior above.\n\nLifecycle hooks (all run locally):\n\n- `create`: required. Prints recipe result JSON.\n- `suspend`: optional. Sleep; reads lifecycle payload on stdin.\n- `resume`: optional. Wake; reads payload on stdin and **prints fresh recipe JSON** (pairing may change).\n- `destroy`: optional unless `destroy: none`. Delete/cleanup; reads payload on stdin.\n\nStart Orca remotely with `orca serve --port \"$PORT\" --project-root \"$ABS_ROOT\" --pairing-address\n\"$EXTERNAL_WSS_URL\" --recipe-json` (exact flags + output in §7c). Set `--pairing-address` to the\nexternally reachable address so the emitted `pairingCode` is reachable; tunneling/port mapping is the\nscript's job.\n\nBackward compatibility: `command`→`create`, `cleanup`→`destroy`, `cleanup: none`→`destroy: none`.\nPrefer the lifecycle names.\n\n---\n\n## 9. Doctor and validation\n\nValidate in two stages — the cheap dry run first, then the live self-test.\n\n### Dry run (free, non-destructive) — always do this first\n\n`orca vm recipe doctor --repo-path --json` validates **static wiring only** — it does\n**not** boot anything. It checks: local-host execution (v1), repo path, recipe id exists,\ncreate/destroy/suspend/resume command paths resolve, suspend/resume are paired, and each script is\nexecutable (POSIX exec bit; skipped on Windows). Fix every failure here before spending any cloud money.\n\n### Live self-test (`--provision`) — diagnose and iterate yourself\n\n`orca vm recipe doctor --repo-path --provision --json` actually runs the recipe end\nto end: it executes `create`, validates the returned recipe JSON, then runs `destroy` to **tear the\nenvironment back down** (so the test leaves nothing running, as long as `destroy` works). It spends real\ncloud money, so get the user's OK **once** before starting — that one approval covers the whole loop\nbelow; do not re-ask before each run.\n\nOn failure, the JSON result includes a `provisionTranscript` with the **complete** captured output of\neach stage so you can self-diagnose without asking the user to relay logs:\n\n```json\n{\n \"ok\": false,\n \"checks\": [ { \"id\": \"recipe.provision\", \"status\": \"fail\", \"message\": \"…\" } ],\n \"provisionTranscript\": {\n \"provision\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\", \"parseError\": \"…\" },\n \"destroy\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\" }\n }\n}\n```\n\n**Run it as a loop:** read `provisionTranscript.provision.stderr` / `.stdout` / `.parseError` (and\n`destroy.*`), fix the script, and re-run `--provision` until `ok` is `true` — iterating on your own\nrather than waiting for the user to paste errors. Common reads: a non-empty `stderr` with `exitCode 0`\nplus a `parseError` means `create` ran but printed something other than the single recipe-result JSON on\nstdout (often a stray `echo` — route it to stderr, see §10); a non-zero `exitCode` is a provider/script\nfailure described in `stderr`. Each stream is redacted and capped (head+tail) — large logs keep both the\nsetup context and the failure.\n\nThe self-test cannot see provider-side truth beyond what the scripts print, so still confirm: state has a\npopulated **authenticated** `snapshotId` (Phases 2–3 done), and `destroy` is implemented/tested (or\nexplicitly `none` — in which case the self-test won't tear down, so clean up manually).\n\nFor SSH recipes, also smoke-test the exact emitted target before declaring success: dial the host/port\nwith the identity/proxy settings, run `pwd`, verify the repo path, check the agent binary, and confirm\n`destroy` removes the provider resource/container. For Docker, inspect the auth image entrypoint and do a\nstartup-only `docker run` before the full clone/install path.\n\n---\n\n## 10. Failure modes\n\n- **Build exceeds plan timeout (e.g. Hobby 45m).** Use enough vCPUs and a timeout covering the build;\n else split work or use a higher plan. The cap also limits per-workspace runtime — surface it.\n- **Build exceeds plan RAM.** Build the **headless main only** (drop the renderer) — the biggest fitter.\n- **Private-repo clone hangs/fails.** Wrong/missing token. Use `GIT_ASKPASS` + `GIT_TERMINAL_PROMPT=0`\n so it fails fast instead of prompting.\n- **`GIT_ASKPASS` helper aborts the clone with \"`$1: unbound variable`\".** The `printf`/heredoc that writes\n the helper inside `bash -lc` under `set -u` expanded `$1`/`$GH_TOKEN` at **write** time. Escape them\n (`\\$1`, `\\$GH_TOKEN`) so they land literally and resolve at git-runtime; this also keeps the real token\n out of the file. `rm -f` the helper afterward (§5, §7f).\n- **Agent verified as \"not logged in\" despite a good login.** `codex login status` (and similar) print\n \"Logged in …\" to **stderr**; an stdout-only `grep` misses it. Prefer the status **exit code**; if you\n grep, fold stderr first (`status 2>&1 | grep …`) and match the exact success line — not `grep -qi\n 'logged in'`, which also matches \"not logged in\".\n- **Headless agent login hangs.** Plain OAuth `login` starts a loopback callback server on a VM/container\n port the host browser can't reach. Use the **device-auth** flow (`login --device-auth`) — it prints a\n URL + code the user opens on the host.\n- **`known_hosts` host-key churn on local Docker.** Each ephemeral container regenerating its SSH host key\n collides on `127.0.0.1` as the published port rotates. Bake host keys into the base image at build time\n (`ssh-keygen -A`; runtime generates only if absent) so all containers share one stable key (§7h).\n- **Snapshot expired/evicted.** If `create` hits an unknown snapshot id, rerun Phases 2–3 and update\n `snapshotId`.\n- **Agent auth didn't persist.** Confirm `snapshotId` points at the **authenticated** snapshot; re-run\n Phase 3. Warn that short-lived tokens may need periodic re-auth.\n- **Agent auth copied from the host breaks.** Do not bind-mount/copy a full host agent home; sqlite\n files can be unwritable or host-specific, hooks may need approval again, and config may reference\n local-only env vars. Authenticate inside the runtime and snapshot/commit that layer.\n- **Docker auth image exits immediately.** Inspect `docker image inspect … .Config.Entrypoint` and\n `docker logs`. If the image was committed from an interactive shell, reset the entrypoint to the SSH\n entrypoint during `docker commit`.\n- **Leaked paid resource.** Every long script must trap errors and remove the sandbox it created.\n- **`create` emits non-JSON on stdout.** A stray `echo` corrupts the result — stdout is for the final\n JSON only; everything else to stderr. The `--provision` self-test surfaces this as `exitCode 0` + a\n `parseError` with the offending stdout in `provisionTranscript` (§9).\n\n---\n\n## 11. Boundaries\n\n- Don't create accounts, choose plans/regions, or invent scope/project/org/image/billing ids.\n- Don't invent or store credentials; no secrets in `userData`, state, comments, docs, or commits.\n- Don't run paid/long phases (base snapshot, auth, live test) without an explicit OK.\n- Don't hide provider errors behind generic messages — preserve actionable stderr.\n- Don't make Orca own provider lifecycle beyond invoking the configured scripts.\n- Don't commit or create an Orca workspace unless asked.\n" // oxfmt-ignore -const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, coordinator loops, or decomposing work\n across agents. Use `orca-cli` instead for full ownership handoffs, including\n requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", or \"another worktree\" when the user did not explicitly ask to\n supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for\n ordinary terminal control, lightweight terminal prompts, shell commands, Orca\n worktree management, reading or waiting on terminals, and automation of the\n browser embedded inside Orca. Use Computer Use for browser windows, webviews,\n Orca app UI, or desktop UI outside Orca's embedded browser.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Contract Migration\n\nOrca adopts a live pre-update orchestration assignment into an ordinary Run. Adoption preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch; it never restarts or replaces the worker. The retired scheduler is not revived, and a newly created attempt uses the current grammar.\n\nTreat the authority label on injected or formatted messages as definitive:\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported command printed with the message, using the same CLI executable and arguments that the original prompt supplied.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded, at-least-once cutover replay. Process it idempotently and acknowledge it only through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or lifecycle action.\n- An unlabeled current message uses the current guide and current grammar.\n\nAn explicitly selected current Run, attested current Run binding, current Dispatch, or federated attachment takes precedence over legacy fallback. A retained adoption record alone never turns a current command into a legacy call.\n\nDatabase provenance, an old-looking terminal, or a legacy Run ID does not prove mutation authority. If the runtime cannot prove liveness, principal ownership, capability, or the exact legacy contract, it degrades to read-only inspection and must not fall back to local execution. Exact recovery may restore the already-live PTY once in its original inactive background tab. It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal. Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.\n\nCompatibility retries have narrow guarantees. A pending ask, a reply, a final Dispatch settlement, and a consuming check have durable recovery identities. A-era heartbeat and escalation calls remain at-least-once across a manual A-to-B retry because identical later signals may be intentional. If an A-era ask may already have been answered, run the exact non-consuming recovery check printed by the runtime first; after its answer is printed and acknowledged, a new invocation with the same question creates a new question. Never guess among multiple identical question threads.\n\nWhen a compatibility or recovery command returns structured next-step arguments, run those exact arguments with the same CLI executable. The arguments intentionally omit the executable name so the guidance works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command. Do not translate the command from memory, broaden its recipient, or retry it as a current mutation unless the returned guidance explicitly says to.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The initial command durably commits the question, prints its exact `ask --resume ` command, and exits with launcher status `75`; it does not wait for the answer. Run that exact resume command after the launcher or update boundary. Resume is idempotent and read-oriented: it waits for the already-committed question and does not create another one. For a WSL process that received compatibility proof at launch, use the printed executable `orca-ide` WSL resume command so the same distro and packaged launcher authority are preserved; do not substitute a PATH-resolved local CLI. Older WSL processes that never received the hidden launch token remain lifecycle read-only after the update, even while their terminal and filesystem work continue.\n\nLegacy inspection remains available without consuming mail:\n\n```bash\norca orchestration run-list --json\n# run_legacy_local is an empty audit tombstone after adoption.\norca orchestration run-show --id run_legacy_local --json\n# In run-list, find the ordinary Run whose objective is:\n# \"Recovered orchestration work from a contract update\"\norca orchestration run-show --id --json\norca orchestration task-list --run --json\norca orchestration inbox --full --json\norca orchestration check --terminal --peek --format --json\norca terminal read --terminal --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\nIf the original coordinator is unavailable or cannot prove its retained authority, a current coordinator may explicitly take over the adopted Run from its own live agent terminal:\n\n```bash\norca orchestration run-use --id --takeover-legacy --json\norca orchestration check --run --json\n```\n\nTakeover fences only the old coordinator, binds the current one, and moves pending worker mail into current Run Delivery. It is bound to the authenticated invoking terminal; `--from` cannot name another coordinator. Live legacy workers keep their original Tasks, Dispatches, processes, filesystems, and old prompt commands; their later questions, escalations, and completion reports route to the current coordinator. Do not use takeover while the original coordinator is still actively coordinating, because its later lifecycle mutations are rejected.\n\nDo not launch a replacement editor merely because the desktop app or runtime was updated. If adoption cannot prove continuing authority, keep the original worker as the only editor until it reaches a stable handoff point, then use a new current Dispatch in a conflict-free placement for any remaining work.\n\n## Ownership\n\nNew orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --subject [--to ] [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--ack ] [--peek|--all] [--types ] [--format] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask (--question |--resume ) [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack `. Process every message before acknowledging; `check --ack --wait` acknowledges, checks, and waits in one operation.\n- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.\n- Use `dispatch:` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.\n- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.\n- `terminal list --json` omits `visualLayouts` because handle recovery does not need topology. Add `--include-visual-layouts` only for explicit tab and pane inspection.\n- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms ` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id --body --json`, then acknowledge and keep waiting.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again.\n- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop.\n\n```bash\norca orchestration run-create --objective --json\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## How deep workers can nest\n\nA dispatched worker normally cannot dispatch sub-workers. Attempting it fails with\n`nested_worker_depth_exceeded` and a message telling the worker to complete the task\nitself. Do that — do not try to route around it.\n\nThe limit is a number, not an on/off switch. `Settings -> Agents -> Nested worker depth`\nsets how many generations are allowed:\n\n- `1` (default): a coordinator dispatches workers; those workers do not dispatch.\n- `2`: workers may dispatch one further generation.\n\nDepth is counted from the terminal that issues the command, not from the Run. Creating a\nnew Run does not reset it — a worker that runs `run-create` then `worker-start` is still a\nworker, and still counted. This is the part that changed: the old behaviour rejected\nsub-dispatch only because a worker's terminal was not bound to a Run, so creating a Run was\nenough to slip past it.\n\nTwo limits worth knowing:\n\n- **It is a guardrail, not a security boundary.** A caller that declares another terminal's\n handle while its own launch evidence is unverifiable (an ordinary restored terminal, for\n example) can be counted as that terminal instead. Orca does not treat workers as hostile.\n- **It applies while a Dispatch is active.** After `worker_done`, or after a coordinator\n settles the task, the terminal is no longer a worker and is counted as a root again. The\n process may still be alive; that is the documented boundary, not an accident.\n\n## Preferred Supervised Worker Loop\n\nUse `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.\n\nCreate the Run and every independent Task first, then start all independent workers before waiting:\n\n```bash\norca orchestration run-create --objective \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration worker-start --task --worktree current --agent codex --json\norca orchestration worker-start --task --worktree current --agent claude --json\n```\n\n`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `.\n\nFor a per-invocation Claude, Codex, or Cursor launch, pass an opaque provider model id with `--model`; add `--effort` only when that agent/model supports the level. These options apply only to fresh agent terminals, override general agent default arguments, and are reported under `launch.requested` and `launch.effective` in the receipt:\n\n```bash\norca orchestration worker-start --task --worktree current --agent claude --model opus --effort high --json\n```\n\n`--effort` requires `--model`, and neither option can combine with `--terminal`. A connected worker server must advertise launch-preference support before Orca forwards either option.\n\nFor a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:\n\n```bash\norca orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n# Independent/top-level:\norca orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nSetup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason.\n\nRead the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure.\n\nTo run the worker on another connected Orca server, add `--on `. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`:\n\n```bash\n# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home)\norca orchestration worker-start --task --on windows --worktree new-top-level --repo --name --agent codex --setup run --json\norca orchestration worker-show --dispatch --json\norca orchestration worker-read --dispatch --limit 50 --json\norca orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nRemote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector.\n\nThe follow-up is structured inbox mail, not prompt injection. The worker's next\n`orchestration check` receives it even when the Dispatch is on another connected Orca server.\n\n`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: \"terminal\"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path.\n\nWait until every expected Dispatch settles, not for a fixed number of batches:\n\n```bash\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n# Process every message. For each accepted worker_done that is not immediately reused:\norca orchestration worker-release --dispatch --json\n# Acknowledge only after every message and required release decision is handled:\norca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\nAfter processing each accepted `worker_done`, choose the terminal's next owner before you acknowledge the Delivery or wait again. If the same exact agent has an immediate follow-up Task, read the `worker.agent_terminal_handle` field of `worker-show --dispatch --json`, then run `orca orchestration worker-start --task --terminal --json` so Orca transfers cleanup ownership to the new Dispatch. Otherwise run `orca orchestration worker-release --dispatch --json`.\n\nRun `worker-release` after both succeeded and failed `worker_done` reports unless the user explicitly asked to keep that worker live. Release is post-completion cleanup, not cancellation: Orca first preserves inspectable output, then closes only the exact agent terminal owned by that settled Dispatch. Reused or pre-existing terminals, setup terminals, coordinators, active workers, user-taken-over terminals, and identities Orca cannot prove are retained. If the user explicitly asks to keep the live terminal for debugging, record that exception with `orca orchestration worker-retain --dispatch --json` instead of silently skipping cleanup. When the user is finished, the same Dispatch can be passed to `worker-release`, which clears the requested retention and releases the terminal.\n\nDo not release a worker because of a timeout, TUI idle state, heartbeat, status, question, escalation, or rejected/stale `worker_done`. If release returns `release_pending` or `release_unknown`, do not substitute `terminal close`; follow the exact recovery action in the receipt. A replayed Delivery may repeat `worker-release` safely.\n\nWorkers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:\n\n```bash\norca orchestration send --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a,path/b\" --json\n# On failure, use --outcome failed; never encode failure only in prose.\n```\n\nA worker question defaults to its owning Run. Timeout leaves it pending:\n\n```bash\norca orchestration ask --question \"\" --options \"yes,no\" --timeout-ms 600000 --json\norca orchestration ask --resume --timeout-ms 600000 --json\n# Coordinator:\norca orchestration reply --id --body \"\" --json\n```\n\nRecovery is conditional, never a fixed destructive sequence:\n\n- `worker-show --dispatch ` says `ready`: keep waiting or read bounded output.\n- It proves `failed` or `stopped`: start a replacement with `worker-start --task --retry-of ` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement.\n- It remains `outcome_unknown`: either `worker-stop --dispatch ` and inspect again, or explicitly `worker-abandon --dispatch ` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action.\n- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes.\n\nLow-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.\n\n`dispatch --inject` deliberately keeps an operator-started terminal unsupervised: it never creates a `worker_dispatches` row and `worker-stop`/`worker-abandon` never close that process. The dispatch context is still authoritative, so `worker-show`, `worker-read`, and `worker-list` report it as `unsupervised`; settled `worker-retain` and `worker-release` report `retained` with `no_owned_resource` and take no process action. Use `worker-start --terminal ` when supervision and worker lifecycle state are required.\n\n## Gates And Legacy Inspection\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\n```\n\nUse `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\n`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --setup run --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nThe two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --setup run --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.\n\nWhen a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\nFor every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees.\n\n```bash\norca worktree create --name --agent codex --setup run --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from agentTerminalHandle, falling back to startupTerminal.handle.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--include-visual-layouts] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:\n `orca orchestration send --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a\" --report-path \"\" --json`\n- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.\n- After sending `worker_done`, end that dispatched turn and idle at the agent prompt. Do not autonomously start more work, poll, or attempt to close the terminal yourself. A direct user instruction takes precedence and starts ordinary user-owned work: follow it without coordinator approval or a fresh Dispatch, never refuse it because of worker/coordinator roles, and do not reuse the settled Dispatch's lifecycle IDs. A coordinator-supervised follow-up still arrives with a fresh preamble + TASK block.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators must account for every settled worker terminal before waiting again or ending the turn: immediately reuse the exact worker for a new Dispatch, explicitly retain it at the user's request with `worker-retain`, or run `worker-release`. Do not leave a completed worker live merely to inspect output; released workers remain readable through `worker-read`.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology. After every accepted `worker_done`, either transfer the exact terminal to an immediate follow-up Dispatch or run `worker-release` before the next wait.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" +const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, coordinator loops, or decomposing work\n across agents. Use `orca-cli` instead for full ownership handoffs, including\n requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", or \"another worktree\" when the user did not explicitly ask to\n supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for\n ordinary terminal control, lightweight terminal prompts, shell commands, Orca\n worktree management, reading or waiting on terminals, and automation of the\n browser embedded inside Orca. Use Computer Use for browser windows, webviews,\n Orca app UI, or desktop UI outside Orca's embedded browser.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Contract Migration\n\nOrca adopts a live pre-update orchestration assignment into an ordinary Run. Adoption preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch; it never restarts or replaces the worker. The retired scheduler is not revived, and a newly created attempt uses the current grammar.\n\nTreat the authority label on injected or formatted messages as definitive:\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported command printed with the message, using the same CLI executable and arguments that the original prompt supplied.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded, at-least-once cutover replay. Process it idempotently and acknowledge it only through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or lifecycle action.\n- An unlabeled current message uses the current guide and current grammar.\n\nAn explicitly selected current Run, attested current Run binding, current Dispatch, or federated attachment takes precedence over legacy fallback. A retained adoption record alone never turns a current command into a legacy call.\n\nDatabase provenance, an old-looking terminal, or a legacy Run ID does not prove mutation authority. If the runtime cannot prove liveness, principal ownership, capability, or the exact legacy contract, it degrades to read-only inspection and must not fall back to local execution. Exact recovery may restore the already-live PTY once in its original inactive background tab. It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal. Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.\n\nCompatibility retries have narrow guarantees. A pending ask, a reply, a final Dispatch settlement, and a consuming check have durable recovery identities. A-era heartbeat and escalation calls remain at-least-once across a manual A-to-B retry because identical later signals may be intentional. If an A-era ask may already have been answered, run the exact non-consuming recovery check printed by the runtime first; after its answer is printed and acknowledged, a new invocation with the same question creates a new question. Never guess among multiple identical question threads.\n\nWhen a compatibility or recovery command returns structured next-step arguments, run those exact arguments with the same CLI executable. The arguments intentionally omit the executable name so the guidance works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command. Do not translate the command from memory, broaden its recipient, or retry it as a current mutation unless the returned guidance explicitly says to.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The initial command durably commits the question, prints its exact `ask --resume ` command, and exits with launcher status `75`; it does not wait for the answer. Run that exact resume command after the launcher or update boundary. Resume is idempotent and read-oriented: it waits for the already-committed question and does not create another one. For a WSL process that received compatibility proof at launch, use the printed executable `orca-ide` WSL resume command so the same distro and packaged launcher authority are preserved; do not substitute a PATH-resolved local CLI. Older WSL processes that never received the hidden launch token remain lifecycle read-only after the update, even while their terminal and filesystem work continue.\n\nLegacy inspection remains available without consuming mail:\n\n```bash\norca orchestration run-list --json\n# run_legacy_local is an empty audit tombstone after adoption.\norca orchestration run-show --id run_legacy_local --json\n# In run-list, find the ordinary Run whose objective is:\n# \"Recovered orchestration work from a contract update\"\norca orchestration run-show --id --json\norca orchestration task-list --run --json\norca orchestration inbox --full --json\norca orchestration check --terminal --peek --format --json\norca terminal read --terminal --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\nIf the original coordinator is unavailable or cannot prove its retained authority, a current coordinator may explicitly take over the adopted Run from its own live agent terminal:\n\n```bash\norca orchestration run-use --id --takeover-legacy --json\norca orchestration check --run --json\n```\n\nTakeover fences only the old coordinator, binds the current one, and moves pending worker mail into current Run Delivery. It is bound to the authenticated invoking terminal; `--from` cannot name another coordinator. Live legacy workers keep their original Tasks, Dispatches, processes, filesystems, and old prompt commands; their later questions, escalations, and completion reports route to the current coordinator. Do not use takeover while the original coordinator is still actively coordinating, because its later lifecycle mutations are rejected.\n\nDo not launch a replacement editor merely because the desktop app or runtime was updated. If adoption cannot prove continuing authority, keep the original worker as the only editor until it reaches a stable handoff point, then use a new current Dispatch in a conflict-free placement for any remaining work.\n\n## Ownership\n\nNew orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --subject [--to ] [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--ack ] [--peek|--all] [--types ] [--format] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask (--question |--resume ) [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack `. Process every message before acknowledging; `check --ack --wait` acknowledges, checks, and waits in one operation.\n- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.\n- Use `dispatch:` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.\n- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.\n- `terminal list --json` omits `visualLayouts` because handle recovery does not need topology. Add `--include-visual-layouts` only for explicit tab and pane inspection.\n- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms ` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id --body --json`, then acknowledge and keep waiting.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again.\n- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop.\n\n```bash\norca orchestration run-create --objective --json\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## How deep workers can nest\n\nA dispatched worker normally cannot dispatch sub-workers. Attempting it fails with\n`nested_worker_depth_exceeded` and a message telling the worker to complete the task\nitself. Do that — do not try to route around it.\n\nThe limit is a number, not an on/off switch. `Settings -> Orchestration -> Nested worker depth`\nsets how many generations are allowed:\n\n- `1` (default): a coordinator dispatches workers; those workers do not dispatch.\n- `2`: workers may dispatch one further generation.\n\nDepth is counted from the terminal that issues the command, not from the Run. Creating a\nnew Run does not reset it — a worker that runs `run-create` then `worker-start` is still a\nworker, and still counted. This is the part that changed: the old behaviour rejected\nsub-dispatch only because a worker's terminal was not bound to a Run, so creating a Run was\nenough to slip past it.\n\nTwo limits worth knowing:\n\n- **It is a guardrail, not a security boundary.** A caller that declares another terminal's\n handle while its own launch evidence is unverifiable (an ordinary restored terminal, for\n example) can be counted as that terminal instead. Orca does not treat workers as hostile.\n- **It applies while a Dispatch is active.** After `worker_done`, or after a coordinator\n settles the task, the terminal is no longer a worker and is counted as a root again. The\n process may still be alive; that is the documented boundary, not an accident.\n\n## Preferred Supervised Worker Loop\n\nUse `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.\n\nCreate the Run and every independent Task first, then start all independent workers before waiting:\n\n```bash\norca orchestration run-create --objective \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration worker-start --task --worktree current --agent codex --json\norca orchestration worker-start --task --worktree current --agent claude --json\n```\n\n`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `.\n\nFor a per-invocation Claude, Codex, or Cursor launch, pass an opaque provider model id with `--model`; add `--effort` only when that agent/model supports the level. These options apply only to fresh agent terminals, override general agent default arguments, and are reported under `launch.requested` and `launch.effective` in the receipt:\n\n```bash\norca orchestration worker-start --task --worktree current --agent claude --model opus --effort high --json\n```\n\n`--effort` requires `--model`, and neither option can combine with `--terminal`. A connected worker server must advertise launch-preference support before Orca forwards either option.\n\nFor a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:\n\n```bash\norca orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n# Independent/top-level:\norca orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nSetup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason.\n\nRead the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure.\n\nTo run the worker on another connected Orca server, add `--on `. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`:\n\n```bash\n# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home)\norca orchestration worker-start --task --on windows --worktree new-top-level --repo --name --agent codex --setup run --json\norca orchestration worker-show --dispatch --json\norca orchestration worker-read --dispatch --limit 50 --json\norca orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nRemote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector.\n\nThe follow-up is structured inbox mail, not prompt injection. The worker's next\n`orchestration check` receives it even when the Dispatch is on another connected Orca server.\n\n`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: \"terminal\"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path.\n\nWait until every expected Dispatch settles, not for a fixed number of batches:\n\n```bash\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n# Process every message. For each accepted worker_done that is not immediately reused:\norca orchestration worker-release --dispatch --json\n# Acknowledge only after every message and required release decision is handled:\norca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\nAfter processing each accepted `worker_done`, choose the terminal's next owner before you acknowledge the Delivery or wait again. If the same exact agent has an immediate follow-up Task, read the `worker.agent_terminal_handle` field of `worker-show --dispatch --json`, then run `orca orchestration worker-start --task --terminal --json` so Orca transfers cleanup ownership to the new Dispatch. Otherwise run `orca orchestration worker-release --dispatch --json`.\n\nRun `worker-release` after both succeeded and failed `worker_done` reports unless the user explicitly asked to keep that worker live. Release is post-completion cleanup, not cancellation: Orca first preserves inspectable output, then closes only the exact agent terminal owned by that settled Dispatch. Reused or pre-existing terminals, setup terminals, coordinators, active workers, user-taken-over terminals, and identities Orca cannot prove are retained. If the user explicitly asks to keep the live terminal for debugging, record that exception with `orca orchestration worker-retain --dispatch --json` instead of silently skipping cleanup. When the user is finished, the same Dispatch can be passed to `worker-release`, which clears the requested retention and releases the terminal.\n\nDo not release a worker because of a timeout, TUI idle state, heartbeat, status, question, escalation, or rejected/stale `worker_done`. If release returns `release_pending` or `release_unknown`, do not substitute `terminal close`; follow the exact recovery action in the receipt. A replayed Delivery may repeat `worker-release` safely.\n\nWorkers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:\n\n```bash\norca orchestration send --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a,path/b\" --json\n# On failure, use --outcome failed; never encode failure only in prose.\n```\n\nA worker question defaults to its owning Run. Timeout leaves it pending:\n\n```bash\norca orchestration ask --question \"\" --options \"yes,no\" --timeout-ms 600000 --json\norca orchestration ask --resume --timeout-ms 600000 --json\n# Coordinator:\norca orchestration reply --id --body \"\" --json\n```\n\nRecovery is conditional, never a fixed destructive sequence:\n\n- `worker-show --dispatch ` says `ready`: keep waiting or read bounded output.\n- It proves `failed` or `stopped`: start a replacement with `worker-start --task --retry-of ` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement.\n- It remains `outcome_unknown`: either `worker-stop --dispatch ` and inspect again, or explicitly `worker-abandon --dispatch ` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action.\n- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes.\n\nLow-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.\n\n`dispatch --inject` deliberately keeps an operator-started terminal unsupervised: it never creates a `worker_dispatches` row and `worker-stop`/`worker-abandon` never close that process. The dispatch context is still authoritative, so `worker-show`, `worker-read`, and `worker-list` report it as `unsupervised`; settled `worker-retain` and `worker-release` report `retained` with `no_owned_resource` and take no process action. Use `worker-start --terminal ` when supervision and worker lifecycle state are required.\n\n## Gates And Legacy Inspection\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\n```\n\nUse `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\n`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --setup run --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nThe two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --setup run --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.\n\nWhen a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\nFor every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees.\n\n```bash\norca worktree create --name --agent codex --setup run --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from agentTerminalHandle, falling back to startupTerminal.handle.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--include-visual-layouts] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:\n `orca orchestration send --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a\" --report-path \"\" --json`\n- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.\n- After sending `worker_done`, end that dispatched turn and idle at the agent prompt. Do not autonomously start more work, poll, or attempt to close the terminal yourself. A direct user instruction takes precedence and starts ordinary user-owned work: follow it without coordinator approval or a fresh Dispatch, never refuse it because of worker/coordinator roles, and do not reuse the settled Dispatch's lifecycle IDs. A coordinator-supervised follow-up still arrives with a fresh preamble + TASK block.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators must account for every settled worker terminal before waiting again or ending the turn: immediately reuse the exact worker for a new Dispatch, explicitly retain it at the user's request with `worker-retain`, or run `worker-release`. Do not leave a completed worker live merely to inspect output; released workers remain readable through `worker-read`.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology. After every accepted `worker_done`, either transfer the exact terminal to an immediate follow-up Dispatch or run `worker-release` before the next wait.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" // Why: no current guide has bundled reference documents, so --full is byte-identical for now. // oxfmt-ignore diff --git a/src/main/runtime/orchestration/coordinator-task-dispatch.ts b/src/main/runtime/orchestration/coordinator-task-dispatch.ts index 1a882ca3b73..8dae105726a 100644 --- a/src/main/runtime/orchestration/coordinator-task-dispatch.ts +++ b/src/main/runtime/orchestration/coordinator-task-dispatch.ts @@ -112,6 +112,7 @@ export async function dispatchTaskToWorker(params: { const preamble = buildDispatchPreamble({ taskId: task.id, dispatchId: dispatch.id, + canDispatchSubWorkers: dispatch.depth < params.nestedWorkerMaxDepth, // Why (§3.4): strippedSpec drops the allow-stale-base line so the worker doesn't read the infra flag as an instruction. taskSpec: strippedSpec, coordinatorHandle: params.coordinatorHandle, diff --git a/src/main/runtime/orchestration/preamble.test.ts b/src/main/runtime/orchestration/preamble.test.ts index 3cedb9fa242..79e06f5a55a 100644 --- a/src/main/runtime/orchestration/preamble.test.ts +++ b/src/main/runtime/orchestration/preamble.test.ts @@ -290,3 +290,37 @@ describe('buildDispatchPreamble', () => { expect(result).toMatchSnapshot() }) }) + +describe('sub-dispatch section', () => { + const base = { + taskId: 'task_1', + dispatchId: 'ctx_1', + taskSpec: 'do the thing', + coordinatorHandle: 'term_coord', + workerHandle: 'term_worker' + } + + it('is omitted when the worker has no nesting budget', () => { + const preamble = buildDispatchPreamble(base) + expect(preamble).not.toContain('=== SUB-DISPATCH ===') + expect(preamble).not.toContain('worker-start') + }) + + it('is omitted explicitly when nesting is disallowed', () => { + expect(buildDispatchPreamble({ ...base, canDispatchSubWorkers: false })).not.toContain( + '=== SUB-DISPATCH ===' + ) + }) + + it('appears with the run-create sequence when budget remains', () => { + const preamble = buildDispatchPreamble({ ...base, canDispatchSubWorkers: true }) + expect(preamble).toContain('=== SUB-DISPATCH ===') + expect(preamble).toContain('orchestration run-create') + expect(preamble).toContain('orchestration worker-start') + }) + + it('keeps the task block last so the spec is not buried', () => { + const preamble = buildDispatchPreamble({ ...base, canDispatchSubWorkers: true }) + expect(preamble.indexOf('=== SUB-DISPATCH ===')).toBeLessThan(preamble.indexOf('=== TASK ===')) + }) +}) diff --git a/src/main/runtime/orchestration/preamble.ts b/src/main/runtime/orchestration/preamble.ts index d98f33763d2..7d426184954 100644 --- a/src/main/runtime/orchestration/preamble.ts +++ b/src/main/runtime/orchestration/preamble.ts @@ -31,6 +31,8 @@ export type PreambleParams = { // Why: prompt-returning agents should idle after worker_done, while bare // shells have no agent prompt for Orca to reuse. workerKind?: 'prompt-returning-agent' | 'bare-shell' + // Why gated: advertising a verb the depth cap will reject just burns a turn. + canDispatchSubWorkers?: boolean } // Why: 5 minutes is frequent enough that the coordinator's stale-heartbeat @@ -138,7 +140,9 @@ ${postDoneInstructions}` const drift = params.baseDrift && params.baseDrift.behind > 0 ? buildDriftSection(params.baseDrift) : '' - return `${header}${drift} + const subDispatch = params.canDispatchSubWorkers ? buildSubDispatchSection(cli) : '' + + return `${header}${drift}${subDispatch} === TASK === ${params.taskSpec}` @@ -186,6 +190,26 @@ preamble + TASK block, which arrives as new input. Treat that as supervised work under the new Dispatch; ignore stale follow-ups from the settled task.` } +// Why the whole section is omitted rather than softened when nesting is off: a +// worker told it "usually cannot" delegate still tries, then reports the refusal +// as a blocker. +function buildSubDispatchSection(cli: string): string { + return ` + +=== SUB-DISPATCH === +You may dispatch sub-workers for this task. Bind your own Run first, then create +and start each one: + + ${cli} orchestration run-create --objective "" --json + ${cli} orchestration task-create --spec "" --json + ${cli} orchestration worker-start --task --worktree current --agent --json + +You own those sub-workers: wait for their worker_done, and do not report your own +until they have settled. Nesting is capped, so a sub-worker of yours may not be +able to dispatch further. +---` +} + function buildDriftSection(drift: NonNullable): string { const subjects = drift.recentSubjects.map((s) => ` - ${s}`).join('\n') return ` diff --git a/src/main/runtime/rpc/methods/orchestration-federation.ts b/src/main/runtime/rpc/methods/orchestration-federation.ts index da4e29d4fc0..4b48632c39a 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation.ts @@ -236,6 +236,9 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ workerHandle: terminalHandle, dispatchCapability: capability, devMode: params.devMode, + // Why the worker host's own setting: enforcement runs here, with this + // host's code, against this host's cap. + canDispatchSubWorkers: (params.depth ?? 1) < runtime.getNestedWorkerMaxDepth(), cliCommand: runtime.getTerminalOrchestrationCliCommand(terminalHandle) }) ) diff --git a/src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts b/src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts index e77f1fd78fe..4a87c53059d 100644 --- a/src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts @@ -433,6 +433,30 @@ describe('orchestration RPC methods', () => { expect(db.getDispatchContext(task.id)).toBeUndefined() }) + it('dry-run previews the same sub-dispatch section as a real dispatch', async () => { + setup() + vi.spyOn(runtime, 'getNestedWorkerMaxDepth').mockReturnValue(2) + const task = db.createTask({ spec: 'work' }) + + const preview = (await call('orchestration.dispatch', { + task: task.id, + to: 'term_a', + dryRun: true, + from: 'term_coord' + })) as { preamble: string } + const dispatched = (await call('orchestration.dispatch', { + task: task.id, + to: 'term_a', + returnPreamble: true, + from: 'term_coord' + })) as { preamble: string } + + const section = (preamble: string) => + preamble.match(/=== SUB-DISPATCH ===[\s\S]*?(?=\n=== TASK ===)/)?.[0] + expect(section(preview.preamble)).toBeDefined() + expect(section(preview.preamble)).toBe(section(dispatched.preamble)) + }) + it('returnPreamble includes preamble in the response', async () => { setup() const task = db.createTask({ spec: 'work' }) diff --git a/src/main/runtime/rpc/methods/orchestration-workers.ts b/src/main/runtime/rpc/methods/orchestration-workers.ts index 6bc37f0c0b3..0b8891481cd 100644 --- a/src/main/runtime/rpc/methods/orchestration-workers.ts +++ b/src/main/runtime/rpc/methods/orchestration-workers.ts @@ -234,6 +234,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ failedStage = 'dispatch_input' const preamble = buildDispatchPreamble({ + canDispatchSubWorkers: started.dispatch.depth < runtime.getNestedWorkerMaxDepth(), taskId: task.id, dispatchId: started.dispatch.id, taskSpec: task.spec, diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 2cf7c8b1a10..4045ce11409 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -1618,9 +1618,15 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ // Why: dry-run previews the preamble without mutating state, so it skips the ready-status check and uses a placeholder dispatchId. if (params.dryRun) { + const maxDepth = runtime.getNestedWorkerMaxDepth() + const previewDepth = db.resolveChildDispatchDepth( + resolveDispatchCreator(runtime, params.from), + maxDepth + ) const preamble = buildDispatchPreamble({ taskId: task.id, dispatchId: 'ctx_dryrun', + canDispatchSubWorkers: previewDepth < maxDepth, taskSpec: task.spec, coordinatorHandle: params.from ?? 'coordinator', workerHandle: params.to ?? 'worker', @@ -1685,6 +1691,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ const preamble = buildDispatchPreamble({ taskId: task.id, dispatchId: ctx.id, + canDispatchSubWorkers: ctx.depth < runtime.getNestedWorkerMaxDepth(), taskSpec: task.spec, coordinatorHandle: params.from ?? 'coordinator', workerHandle: to, @@ -1733,6 +1740,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ taskId: task.id, // Why: use the real ctx.id when present so the preview matches what was injected; placeholder when no dispatch has occurred yet. dispatchId: ctx?.id ?? 'ctx_preview', + canDispatchSubWorkers: (ctx?.depth ?? 1) < runtime.getNestedWorkerMaxDepth(), taskSpec: task.spec, coordinatorHandle: params.from ?? 'coordinator', workerHandle, diff --git a/src/renderer/src/components/settings/OrchestrationPane.test.tsx b/src/renderer/src/components/settings/OrchestrationPane.test.tsx index 231991205d4..e8652197240 100644 --- a/src/renderer/src/components/settings/OrchestrationPane.test.tsx +++ b/src/renderer/src/components/settings/OrchestrationPane.test.tsx @@ -5,7 +5,12 @@ import { createRoot, type Root } from 'react-dom/client' import { renderToStaticMarkup } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getOrchestrationUsageExamples } from '@/lib/orchestration-usage-examples' +import { getDefaultSettings } from '../../../../shared/constants' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { useAppStore } from '../../store' import { OrchestrationPane } from './OrchestrationPane' +import { getOrchestrationPaneSearchEntries } from './orchestration-search' +import { matchesSettingsSearch } from './settings-search' const INSTALL_COMMAND = 'npx skills add https://github.com/stablyai/orca --skill orchestration --global' @@ -16,7 +21,8 @@ const WINDOWS_INSTALL_COMMAND = const mocks = vi.hoisted(() => ({ dialogProps: [] as Record[], panelProps: [] as Record[], - skillInstalled: true + skillInstalled: true, + updateSettings: vi.fn() })) vi.mock('./AgentSkillSetupPanel', () => ({ @@ -98,18 +104,32 @@ vi.mock('@/hooks/useDetectedAgents', () => ({ let root: Root | null = null let container: HTMLDivElement | null = null -async function renderPane(): Promise { +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +function setNativeValue(input: HTMLInputElement, text: string): void { + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + setValue?.call(input, text) +} + +function getPaneProps(settings: GlobalSettings = getDefaultSettings('/tmp')) { + return { settings, updateSettings: mocks.updateSettings } +} + +async function renderPane( + settings: GlobalSettings = getDefaultSettings('/tmp') +): Promise { container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) await act(async () => { - root?.render() + root?.render() }) return container } describe('OrchestrationPane', () => { beforeEach(() => { + useAppStore.setState({ settingsSearchQuery: '' }) Object.defineProperty(window, 'api', { configurable: true, value: { @@ -146,10 +166,12 @@ describe('OrchestrationPane', () => { mocks.dialogProps.length = 0 mocks.panelProps.length = 0 mocks.skillInstalled = true + mocks.updateSettings.mockReset() + delete (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ }) it('keeps skill setup visible after install and shows agent coverage plus examples', () => { - const markup = renderToStaticMarkup() + const markup = renderToStaticMarkup() expect(markup).toContain('Orchestration skill') expect(markup).toContain('Installed') @@ -170,6 +192,69 @@ describe('OrchestrationPane', () => { expect(markup).toContain('Re-check') }) + it('renders nested worker depth as an unbounded positive whole-number input', () => { + const markup = renderToStaticMarkup() + + expect(markup).toContain('Nested worker depth') + expect(markup).toContain('type="number"') + expect(markup).toContain('aria-label="Nested worker depth"') + expect(markup).toContain('min="1"') + expect(markup).not.toContain('max=') + expect(markup).not.toContain('Default:') + expect(markup.indexOf('Nested worker depth')).toBeGreaterThan( + markup.indexOf('Orchestration skill') + ) + expect(matchesSettingsSearch('nested worker', getOrchestrationPaneSearchEntries())).toBe(true) + }) + + it('keeps the nested depth row visible when settings search routes to Orchestration', () => { + useAppStore.setState({ settingsSearchQuery: 'Nested worker' }) + + const markup = renderToStaticMarkup() + + expect(markup).toContain('Nested worker depth') + expect(markup).toContain('aria-label="Nested worker depth"') + }) + + it('commits a whole-number depth and rejects fractional values', async () => { + const rendered = await renderPane() + const input = rendered.querySelector( + 'input[aria-label="Nested worker depth"]' + ) + if (!input) { + throw new Error('Nested worker depth input was not rendered') + } + + await act(async () => { + setNativeValue(input, '5') + input.dispatchEvent(new Event('input', { bubbles: true })) + input.dispatchEvent(new FocusEvent('focusout', { bubbles: true })) + }) + expect(mocks.updateSettings).toHaveBeenCalledWith({ nestedWorkerMaxDepth: 5 }) + + mocks.updateSettings.mockClear() + await act(async () => { + setNativeValue(input, '2.5') + input.dispatchEvent(new Event('input', { bubbles: true })) + input.dispatchEvent(new FocusEvent('focusout', { bubbles: true })) + }) + expect(mocks.updateSettings).not.toHaveBeenCalled() + expect(input.value).toBe('1') + }) + + it('keeps host-only nested depth out of paired web clients', () => { + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + const markup = renderToStaticMarkup() + + expect(markup).not.toContain('Nested worker depth') + expect( + matchesSettingsSearch( + 'nested worker', + getOrchestrationPaneSearchEntries({ includeNestedWorkerDepth: false }) + ) + ).toBe(false) + }) + it('passes update commands to the main panel without an installed manual-copy path', async () => { const rendered = await renderPane() diff --git a/src/renderer/src/components/settings/OrchestrationPane.tsx b/src/renderer/src/components/settings/OrchestrationPane.tsx index f60290968f2..e3230f4cf86 100644 --- a/src/renderer/src/components/settings/OrchestrationPane.tsx +++ b/src/renderer/src/components/settings/OrchestrationPane.tsx @@ -30,6 +30,14 @@ import { OrchestrationSkillAgentCoverage } from './OrchestrationSkillAgentCovera import { SkillUsageExamplesSection } from './SkillUsageExamplesSection' import { OrchestrationSkillPromptDialog } from './OrchestrationSkillPromptDialog' import { translate } from '@/i18n/i18n' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { resolveNestedWorkerMaxDepth } from '../../../../shared/nested-worker-depth' +import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' +import { NumberField } from './SettingsFormControls' +import { + getNestedWorkerDepthDescription, + getNestedWorkerDepthTitle +} from './nested-worker-depth-copy' const EXAMPLE_ICONS = { handoff: ArrowRightLeft, @@ -43,9 +51,21 @@ function resolveOrchestrationExampleIcon(example: SkillUsageExample): LucideIcon return EXAMPLE_ICONS[example.id as keyof typeof EXAMPLE_ICONS] ?? Workflow } -export function OrchestrationPane(): React.JSX.Element { +type OrchestrationPaneProps = { + settings: GlobalSettings + updateSettings: (updates: Partial) => void | Promise +} + +export function OrchestrationPane({ + settings, + updateSettings +}: OrchestrationPaneProps): React.JSX.Element { const searchQuery = useAppStore((s) => s.settingsSearchQuery) - const showOrchestration = matchesSettingsSearch(searchQuery, getOrchestrationPaneSearchEntries()) + const showNestedWorkerDepth = !isPairedWebClientWindow() + const searchEntries = getOrchestrationPaneSearchEntries({ + includeNestedWorkerDepth: showNestedWorkerDepth + }) + const showOrchestration = matchesSettingsSearch(searchQuery, searchEntries) const [skillPromptOpen, setSkillPromptOpen] = useState(false) const activeSkillRuntime = useActiveProjectSkillRuntime() const orchestrationInstallCommand = !activeSkillRuntime.installDisabledReason @@ -87,7 +107,8 @@ export function OrchestrationPane(): React.JSX.Element { 'auto.components.settings.OrchestrationPane.2aacdb0517', 'Coordinate coding agents across handoffs, worktree handovers, and child-agent work.' )} - keywords={getOrchestrationPaneSearchEntries()[0].keywords} + keywords={searchEntries[0].keywords} + forceVisible className="space-y-5 py-2" > + {showNestedWorkerDepth ? ( + { + void updateSettings({ nestedWorkerMaxDepth }) + }} + /> + ) : null} + - {isSectionMounted('orchestration') ? : null} + {isSectionMounted('orchestration') ? ( + + ) : null} {linearConnected ? ( diff --git a/src/renderer/src/components/settings/SettingsFormControls.tsx b/src/renderer/src/components/settings/SettingsFormControls.tsx index b82e90a0984..bb38bcc8d4e 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.tsx @@ -265,8 +265,9 @@ type NumberFieldProps = { value: number defaultValue?: number min: number - max: number + max?: number step?: number + integer?: boolean onChange: (value: number) => void suffix?: string } @@ -312,6 +313,7 @@ export function NumberField({ min, max, step = 1, + integer = false, onChange, suffix }: NumberFieldProps): React.JSX.Element { @@ -332,8 +334,8 @@ export function NumberField({ return } const next = Number(trimmed) - if (Number.isFinite(next)) { - const clamped = Math.min(max, Math.max(min, next)) + if (Number.isFinite(next) && (!integer || Number.isSafeInteger(next))) { + const clamped = max === undefined ? Math.max(min, next) : Math.min(max, Math.max(min, next)) onChange(clamped) setDraft(String(clamped)) } else { @@ -363,6 +365,7 @@ export function NumberField({ min={min} max={max} step={step} + aria-label={label} value={draft} onChange={(e) => setDraft(e.target.value)} onBlur={commit} diff --git a/src/renderer/src/components/settings/nested-worker-depth-copy.ts b/src/renderer/src/components/settings/nested-worker-depth-copy.ts new file mode 100644 index 00000000000..6f2754b637d --- /dev/null +++ b/src/renderer/src/components/settings/nested-worker-depth-copy.ts @@ -0,0 +1,29 @@ +import { translate } from '@/i18n/i18n' +import { searchKeywords } from './settings-search-keywords' + +export function getNestedWorkerDepthTitle(): string { + return translate( + 'auto.components.settings.OrchestrationPane.nestedWorkerDepthTitle', + 'Nested worker depth' + ) +} + +export function getNestedWorkerDepthDescription(): string { + return translate( + 'auto.components.settings.OrchestrationPane.nestedWorkerDepthDescription', + 'How many generations of dispatched workers may spawn their own workers. 1 keeps the agent tree flat: a coordinator dispatches workers, and those workers do not dispatch.' + ) +} + +export function getNestedWorkerDepthSearchKeywords(): string[] { + return searchKeywords([ + { key: 'auto.components.settings.agents.search.96ba2373b6', fallback: 'agent' }, + { key: 'auto.components.settings.general.search.ec5049e510', fallback: 'nested' }, + { key: 'auto.components.settings.orchestration.search.741dfc03fa', fallback: 'worker' }, + { key: 'auto.components.settings.orchestration.search.eee028ae14', fallback: 'dispatch' }, + { + key: 'auto.components.settings.orchestration.search.f5d39af41e', + fallback: 'child agents' + } + ]) +} diff --git a/src/renderer/src/components/settings/orchestration-search.ts b/src/renderer/src/components/settings/orchestration-search.ts index a9d22636030..5d15a6dd88c 100644 --- a/src/renderer/src/components/settings/orchestration-search.ts +++ b/src/renderer/src/components/settings/orchestration-search.ts @@ -1,8 +1,19 @@ import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' import { createLocalizedCatalog } from '@/i18n/localized-catalog' +import { + getNestedWorkerDepthDescription, + getNestedWorkerDepthSearchKeywords, + getNestedWorkerDepthTitle +} from './nested-worker-depth-copy' -export const getOrchestrationPaneSearchEntries = createLocalizedCatalog(() => [ +type OrchestrationPaneSearchOptions = { + includeNestedWorkerDepth?: boolean +} + +const NESTED_WORKER_DEPTH_SEARCH_ENTRY_ID = 'nested-worker-depth' + +const getAllOrchestrationPaneSearchEntries = createLocalizedCatalog(() => [ { title: translate( 'auto.components.settings.orchestration.search.c34045764e', @@ -68,5 +79,22 @@ export const getOrchestrationPaneSearchEntries = createLocalizedCatalog(() => [ 'child agents' ) ] + }, + { + id: NESTED_WORKER_DEPTH_SEARCH_ENTRY_ID, + title: getNestedWorkerDepthTitle(), + description: getNestedWorkerDepthDescription(), + keywords: getNestedWorkerDepthSearchKeywords() } ]) + +export function getOrchestrationPaneSearchEntries({ + includeNestedWorkerDepth = true +}: OrchestrationPaneSearchOptions = {}) { + const entries = getAllOrchestrationPaneSearchEntries() + return includeNestedWorkerDepth + ? entries + : entries.filter( + (entry) => !('id' in entry) || entry.id !== NESTED_WORKER_DEPTH_SEARCH_ENTRY_ID + ) +} diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts index b0b694afcf1..7cc5c009f9d 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts @@ -47,6 +47,22 @@ describe('settings navigation metadata', () => { ]) }) + it('owns nested worker depth under Orchestration on desktop', () => { + const sections = buildSettingsNavigationMetadata({ + isMac: false, + isWindows: false, + isWebClient: false, + repos: [repo] + }) + const agents = sections.find((section) => section.id === 'agents') + const orchestration = sections.find((section) => section.id === 'orchestration') + + expect(agents?.searchEntries.map((entry) => entry.title)).not.toContain('Nested worker depth') + expect(orchestration?.searchEntries.map((entry) => entry.title)).toContain( + 'Nested worker depth' + ) + }) + it('adds the Linear capability section right after Orchestration only when connected', () => { expect(ids()).not.toContain('linear') @@ -157,6 +173,12 @@ describe('settings navigation metadata', () => { expect(shortcuts?.searchEntries.map((entry) => entry.title)).not.toContain( 'New mobile emulator tab' ) + const agents = webSections.find((section) => section.id === 'agents') + expect(agents?.searchEntries.map((entry) => entry.title)).not.toContain('Nested worker depth') + const orchestration = webSections.find((section) => section.id === 'orchestration') + expect(orchestration?.searchEntries.map((entry) => entry.title)).not.toContain( + 'Nested worker depth' + ) }) it('keeps the Browser shortcut searchable for a capable web runtime', () => { diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index 07f69bbbcb1..9983ba38f0a 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -202,7 +202,9 @@ export function buildSettingsNavigationMetadata({ 'Coordinate multiple coding agents through Orca.' ), icon: Network, - searchEntries: getOrchestrationPaneSearchEntries(), + searchEntries: getOrchestrationPaneSearchEntries({ + includeNestedWorkerDepth: !isWebClient + }), group: 'capabilities' }, // Why: only surfaced once Linear is connected — a capability that needs a diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2432d4c5952..bbffbf8a974 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -7459,7 +7459,9 @@ "9bedd2a6e5": "Enables agents to hand off context and coordinate work through Orca.", "07641b9768": "Orchestration skill", "2aacdb0517": "Coordinate coding agents across handoffs, worktree handovers, and child-agent work.", - "191ac34567": "Agent Orchestration" + "191ac34567": "Agent Orchestration", + "nestedWorkerDepthTitle": "Nested worker depth", + "nestedWorkerDepthDescription": "How many generations of dispatched workers may spawn their own workers. 1 keeps the agent tree flat: a coordinator dispatches workers, and those workers do not dispatch." }, "OrchestrationSetupCard": { "e7d2a5146c": "Enables agents to hand off context and coordinate work through Orca.", diff --git a/src/shared/nested-worker-depth.test.ts b/src/shared/nested-worker-depth.test.ts index a91dd87ad00..f3c173988a6 100644 --- a/src/shared/nested-worker-depth.test.ts +++ b/src/shared/nested-worker-depth.test.ts @@ -27,6 +27,7 @@ describe('resolveNestedWorkerMaxDepth', () => { ['fractional', 1.5], ['NaN', Number.NaN], ['Infinity', Number.POSITIVE_INFINITY], + ['unsafe integer', Number.MAX_SAFE_INTEGER + 1], ['null', null] ])('falls back to the default for %s', (_label, value) => { expect(resolveNestedWorkerMaxDepth({ nestedWorkerMaxDepth: value as unknown as number })).toBe( diff --git a/src/shared/nested-worker-depth.ts b/src/shared/nested-worker-depth.ts index 927f9801c12..d927bfea77e 100644 --- a/src/shared/nested-worker-depth.ts +++ b/src/shared/nested-worker-depth.ts @@ -23,11 +23,11 @@ export function nestedWorkerDepthExceededMessage(childDepth: number, maxDepth: n export const NESTED_WORKER_DEPTH_EXCEEDED_NEXT_STEPS: readonly string[] = [ 'Do the work in this terminal instead of dispatching a sub-worker.', - 'To allow deeper nesting, open Settings → Agents in the Orca desktop app and raise "Nested worker depth".' + 'To allow deeper nesting, open Settings → Orchestration in the Orca desktop app and raise "Nested worker depth".' ] /** - * Clamp to a usable integer. Anything that is not a whole number >= 1 falls back + * Clamp to a usable integer. Anything that is not a safe whole number >= 1 falls back * to the default rather than disabling the fence: a malformed setting must not * be a way to get unlimited nesting. */ @@ -35,7 +35,7 @@ export function resolveNestedWorkerMaxDepth( settings: Pick | null | undefined ): number { const raw = settings?.nestedWorkerMaxDepth - if (typeof raw !== 'number' || !Number.isInteger(raw) || raw < 1) { + if (typeof raw !== 'number' || !Number.isSafeInteger(raw) || raw < 1) { return NESTED_WORKER_MAX_DEPTH_DEFAULT } return raw From 08a447dfb2413381c99be53a661125dc7a01582d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:30:26 -0700 Subject: [PATCH 028/152] fix(terminal): size the pre-Enter wait to what the host actually ingests (#15925) (#16586) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): size the pre-Enter wait to what the host actually ingests The Windows agent-prompt submit delay was a flat 1_500 ms frozen from the client's process.platform at import. Measured on two real Win11 hosts, ConPTY ingests a bracketed paste linearly at ~0.009-0.010 ms/byte, so the constant was both far too long for a 2-8 KB prompt (14-89 ms of real cost) and too short past ~145 KB — at 160 KB one host took 1_499 ms, meaning Enter landed mid-paste, exactly the corruption the delay exists to prevent, up to the 16 MB input ceiling. Replace it with getTerminalPasteIngestMs(platform, byteLength) and derive every pre-Enter wait from it: - open-loop fallback = 500 ms settle + ingest bound, uncapped - claude/codex render gate cannot start its quiet window before the ingest bound elapses (an agent that repaints mid-ingest could otherwise satisfy marker-then-quiet while ConPTY was still feeding the paste), and its 8 s hard cap now sits on top of the ingest bound instead of standing in for it - the plain terminal.send suffix path, which had an undocumented flat 500 ms The rate follows the host that owns the pty transport, not the client: a WSL pane is spawned as wsl.exe behind the Windows pseudoconsole so it still pays ConPTY, while an SSH pane follows the relay's reported remotePlatform. Also swap the inter-chunk setTimeout(0) for setImmediate. It cost a full ~15 ms Windows timer tick per 16 KiB chunk (~0.95 s/MB) while pacing ~1.07 MB/s — 11x above ConPTY's drain rate — so it never provided backpressure; the event-loop yield it did provide is preserved. * fix(terminal): stop double-charging paste ingest in the render gate The render gate's hard cap is armed twice -- once at arm() and again when the show-cursor marker arrives -- but it re-added the whole ingest window each time while the ingest clock itself runs once from gate construction. A marker seen mid-ingest pushed the cap out by a second full ingest term (~34 s instead of ~24 s for a 1 MB prompt on ConPTY). Capture the ingest deadline absolutely and arm with what is left of it. Also thread the request AbortSignal through terminal.send so the now payload-scaled suffix wait can be cancelled: at 16 MB it runs ~262 s, well past the CLI's 60 s request budget, and previously nothing stopped the eventual Enter. Cleanups: a pty record's connectionId is only ever an SSH target id, so the wsl: relay-id guard in getPtyWriteHostPlatform was dead; and hoisting action.text removes both non-null assertions in writeTerminalAction. --- .../agent-prompt-submission-runtime.test.ts | 13 +- ...pt-submission-windows-submit-delay.test.ts | 377 +++++++++++++++++- src/main/runtime/orca-runtime.test.ts | 30 +- src/main/runtime/orca-runtime.ts | 143 +++++-- .../methods/terminal/terminal-send-method.ts | 1 + .../rpc/terminal-agent-prompt-send.test.ts | 31 +- src/shared/agent-prompt-injection.test.ts | 59 ++- src/shared/agent-prompt-injection.ts | 48 ++- 8 files changed, 636 insertions(+), 66 deletions(-) diff --git a/src/main/runtime/agent-prompt-submission-runtime.test.ts b/src/main/runtime/agent-prompt-submission-runtime.test.ts index f26ff8030b8..69eb0a01079 100644 --- a/src/main/runtime/agent-prompt-submission-runtime.test.ts +++ b/src/main/runtime/agent-prompt-submission-runtime.test.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { AGENT_PROMPT_BRACKETED_PASTE_END, - AGENT_PROMPT_SUBMIT_DELAY_MS + buildAgentPromptPasteBytes, + getAgentPromptSubmitDelayMs } from '../../shared/agent-prompt-injection' import { AGENT_PROMPT_TEST_WORKTREE_PATH, @@ -664,8 +665,14 @@ describe('agent prompt submission runtime', () => { }) const rejected = expect(submission).rejects.toThrow('request_aborted') - // Why: the submit delay is 1_500 on Windows (ConPTY); a hardcoded 500 aborts before the Enter there. - await vi.advanceTimersByTimeAsync(AGENT_PROMPT_SUBMIT_DELAY_MS) + // Why compute it: the submit delay now follows the payload size and the executing host, + // so a hardcoded number aborts before the Enter on some lanes. + await vi.advanceTimersByTimeAsync( + getAgentPromptSubmitDelayMs( + process.platform, + Buffer.byteLength(buildAgentPromptPasteBytes('review this'), 'utf8') + ) + ) // Why: pin the phase boundary so drift fails here instead of as an empty post-abort array. expect(writes.filter((data) => data === '\r')).toHaveLength(1) controller.abort() diff --git a/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts b/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts index fdb90700de1..f6298df8b17 100644 --- a/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts +++ b/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts @@ -1,18 +1,22 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import type * as AgentPromptInjection from '../../shared/agent-prompt-injection' +import { + buildAgentPromptPasteBytes, + getAgentPromptSubmitDelayMs, + getTerminalPasteIngestMs +} from '../../shared/agent-prompt-injection' +import { setSshTargetRegistryHandlers } from '../ssh/ssh-target-registry' import { OrcaRuntimeService } from './orca-runtime' import { makeStore } from './runtime-rpc-worktree-store-fixtures' -// Why: vi.mock factories are hoisted above module-scope consts, so the delay must be hoisted too. -const { WINDOWS_SUBMIT_DELAY_MS } = vi.hoisted(() => ({ WINDOWS_SUBMIT_DELAY_MS: 1_500 })) const WORKTREE_PATH = '/tmp/worktree-a' +const PTY_ID = 'pty-prompt' +// Why: the submit delay is resolved per send from the *executing* host and the payload size, +// so these suites drive it by stubbing that host rather than by mocking the shared module. +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! -// Why: AGENT_PROMPT_SUBMIT_DELAY_MS is frozen from process.platform at import, so the 1_500 ConPTY -// path is otherwise unreachable on the Linux/macOS lanes that run this suite. -vi.mock('../../shared/agent-prompt-injection', async (importOriginal) => ({ - ...(await importOriginal()), - AGENT_PROMPT_SUBMIT_DELAY_MS: WINDOWS_SUBMIT_DELAY_MS -})) +function useHostPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) +} vi.mock('../git/worktree', () => ({ listWorktrees: vi.fn().mockResolvedValue([ @@ -35,18 +39,24 @@ vi.mock('../git/worktree', () => ({ ]) })) -// Why: 'aider' is not a settlement agent, so submission takes the fixed-delay branch under test. +// Why: 'aider' is not a settlement agent, so submission takes the open-loop delay under test. async function createPromptRuntime(): Promise<{ runtime: OrcaRuntimeService handle: string writes: string[] + submitTimes: number[] }> { const runtime = new OrcaRuntimeService(makeStore() as never) const writes: string[] = [] + const submitTimes: number[] = [] + const startedAt = Date.now() runtime.setPtyController({ - spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }), + spawn: vi.fn().mockResolvedValue({ id: PTY_ID }), write: (_ptyId, data) => { writes.push(data) + if (data === '\r') { + submitTimes.push(Date.now() - startedAt) + } return true }, kill: () => true, @@ -55,23 +65,56 @@ async function createPromptRuntime(): Promise<{ const terminal = await runtime.createTerminal(`path:${WORKTREE_PATH}`, { launchAgent: 'aider' }) - return { runtime, handle: terminal.handle, writes } + return { runtime, handle: terminal.handle, writes, submitTimes } +} + +// Why 12 KB: one chunk (so the write loop costs no clock), yet large enough that the ConPTY +// term and the fast-platform term are far apart. +const HOST_PROBE_PROMPT = 'p'.repeat(12_000) + +/** The pane's execution host is spawn-time state the fixture cannot express; patch the + * record the runtime actually consults so remote and WSL panes are reachable here. */ +function patchPtyRecord(runtime: OrcaRuntimeService, patch: Record): void { + const ptys = (runtime as unknown as { ptysById: Map> }).ptysById + const record = ptys.get(PTY_ID) + expect(record).toBeDefined() + Object.assign(record!, patch) +} + +function registerSshRemotePlatform(platform: NodeJS.Platform | undefined): void { + setSshTargetRegistryHandlers({ + connect: null, + getState: () => ({ remotePlatform: platform }) as never + }) } function countSubmits(writes: readonly string[]): number { return writes.filter((data) => data === '\r').length } -describe('agent prompt submission at the Windows submit delay', () => { - afterEach(() => vi.useRealTimers()) +function submitDelayFor(prompt: string, platform: NodeJS.Platform): number { + return getAgentPromptSubmitDelayMs( + platform, + Buffer.byteLength(buildAgentPromptPasteBytes(prompt), 'utf8') + ) +} - it('holds Enter until the full platform delay elapses', async () => { +describe('agent prompt submit delay on a ConPTY host', () => { + afterEach(() => { + vi.useRealTimers() + Object.defineProperty(process, 'platform', originalPlatform) + setSshTargetRegistryHandlers({ connect: null, getState: null }) + }) + + it('holds Enter for the payload ingest plus the settle window', async () => { + useHostPlatform('win32') vi.useFakeTimers() const { runtime, handle, writes } = await createPromptRuntime() + const delayMs = submitDelayFor('review this', 'win32') const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') - await vi.advanceTimersByTimeAsync(WINDOWS_SUBMIT_DELAY_MS - 1) + await vi.advanceTimersByTimeAsync(delayMs - 1) expect(countSubmits(writes)).toBe(0) await vi.advanceTimersByTimeAsync(1) expect(countSubmits(writes)).toBe(1) @@ -80,7 +123,46 @@ describe('agent prompt submission at the Windows submit delay', () => { await stalled }) + it('no longer burns the flat 1_500 ms on a common-sized prompt', async () => { + useHostPlatform('win32') + vi.useFakeTimers() + const { runtime, handle, writes } = await createPromptRuntime() + const prompt = 'x'.repeat(8_000) + // Measured ConPTY ingest for 8 KB is 60-89 ms; the old constant charged 1_500 ms. + const delayMs = submitDelayFor(prompt, 'win32') + expect(delayMs).toBeLessThan(700) + const submission = runtime.sendTerminalAgentPrompt(handle, prompt) + const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') + + await vi.advanceTimersByTimeAsync(delayMs) + expect(countSubmits(writes)).toBe(1) + + await vi.runAllTimersAsync() + await stalled + }) + + it('does not write Enter while ConPTY is still ingesting a large paste', async () => { + useHostPlatform('win32') + vi.useFakeTimers() + const { runtime, handle, writes, submitTimes } = await createPromptRuntime() + const prompt = 'y'.repeat(320_000) + const submission = runtime.sendTerminalAgentPrompt(handle, prompt) + const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') + + // Every byte is already handed to node-pty here -- the hazard is that the *host* is + // still feeding them to the child. 3_342 ms is the measured 320 KB ConPTY ingest. + await vi.advanceTimersByTimeAsync(3_342) + expect(writes.filter((data) => data.includes('\x1b[201~'))).toHaveLength(1) + expect(countSubmits(writes)).toBe(0) + + await vi.runAllTimersAsync() + expect(submitTimes).toHaveLength(1) + expect(submitTimes[0]).toBeGreaterThan(3_342) + await stalled + }) + it('does not send another Enter after cancellation during verification', async () => { + useHostPlatform('win32') vi.useFakeTimers() const controller = new AbortController() const { runtime, handle, writes } = await createPromptRuntime() @@ -89,7 +171,7 @@ describe('agent prompt submission at the Windows submit delay', () => { }) const rejected = expect(submission).rejects.toThrow('request_aborted') - await vi.advanceTimersByTimeAsync(WINDOWS_SUBMIT_DELAY_MS) + await vi.advanceTimersByTimeAsync(submitDelayFor('review this', 'win32')) expect(countSubmits(writes)).toBe(1) controller.abort() await vi.runAllTimersAsync() @@ -97,4 +179,265 @@ describe('agent prompt submission at the Windows submit delay', () => { await rejected expect(countSubmits(writes)).toBe(1) }) + + it('charges a non-Windows host only the settle window', async () => { + useHostPlatform('darwin') + vi.useFakeTimers() + const { runtime, handle, writes } = await createPromptRuntime() + const delayMs = submitDelayFor(HOST_PROBE_PROMPT, 'darwin') + expect(delayMs).toBeLessThan(submitDelayFor(HOST_PROBE_PROMPT, 'win32')) + const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT) + const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') + + await vi.advanceTimersByTimeAsync(delayMs - 1) + expect(countSubmits(writes)).toBe(0) + await vi.advanceTimersByTimeAsync(1) + expect(countSubmits(writes)).toBe(1) + + await vi.runAllTimersAsync() + await stalled + }) +}) + +describe('agent prompt submit delay follows the execution host', () => { + afterEach(() => { + vi.useRealTimers() + Object.defineProperty(process, 'platform', originalPlatform) + setSshTargetRegistryHandlers({ connect: null, getState: null }) + }) + + it('still charges ConPTY for a WSL pane, which is spawned through it', async () => { + useHostPlatform('win32') + vi.useFakeTimers() + const { runtime, handle, writes } = await createPromptRuntime() + // The shell is Linux, but node-pty spawned wsl.exe behind the Windows pseudoconsole, + // so the paste still pays ConPTY ingest -- unlike the reported `hostPlatform`. + patchPtyRecord(runtime, { isWsl: true, wslDistro: 'Ubuntu' }) + const delayMs = submitDelayFor(HOST_PROBE_PROMPT, 'win32') + expect(delayMs).toBeGreaterThan(submitDelayFor(HOST_PROBE_PROMPT, 'linux')) + const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT) + const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') + + await vi.advanceTimersByTimeAsync(delayMs - 1) + expect(countSubmits(writes)).toBe(0) + await vi.advanceTimersByTimeAsync(1) + expect(countSubmits(writes)).toBe(1) + + await vi.runAllTimersAsync() + await stalled + }) + + it('waits the ConPTY delay for a Windows SSH host driven from macOS', async () => { + useHostPlatform('darwin') + vi.useFakeTimers() + const { runtime, handle, writes } = await createPromptRuntime() + patchPtyRecord(runtime, { connectionId: 'ssh-target-1' }) + registerSshRemotePlatform('win32') + const clientDelayMs = submitDelayFor(HOST_PROBE_PROMPT, 'darwin') + const hostDelayMs = submitDelayFor(HOST_PROBE_PROMPT, 'win32') + const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT) + const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') + + await vi.advanceTimersByTimeAsync(clientDelayMs) + expect(countSubmits(writes)).toBe(0) + await vi.advanceTimersByTimeAsync(hostDelayMs - clientDelayMs) + expect(countSubmits(writes)).toBe(1) + + await vi.runAllTimersAsync() + await stalled + }) + + it('skips the ConPTY delay for a Linux SSH host driven from Windows', async () => { + useHostPlatform('win32') + vi.useFakeTimers() + const { runtime, handle, writes } = await createPromptRuntime() + patchPtyRecord(runtime, { connectionId: 'ssh-target-1' }) + registerSshRemotePlatform('linux') + const delayMs = submitDelayFor(HOST_PROBE_PROMPT, 'linux') + expect(delayMs).toBeLessThan(submitDelayFor(HOST_PROBE_PROMPT, 'win32')) + const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT) + const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') + + await vi.advanceTimersByTimeAsync(delayMs - 1) + expect(countSubmits(writes)).toBe(0) + await vi.advanceTimersByTimeAsync(1) + expect(countSubmits(writes)).toBe(1) + + await vi.runAllTimersAsync() + await stalled + }) + + it('falls back to the remote worktree path flavor before the relay reports a platform', async () => { + useHostPlatform('darwin') + vi.useFakeTimers() + const { runtime, handle, writes } = await createPromptRuntime() + patchPtyRecord(runtime, { + connectionId: 'ssh-target-1', + worktreeId: 'repo-1::C:\\worktrees\\worktree-a' + }) + registerSshRemotePlatform(undefined) + const delayMs = submitDelayFor(HOST_PROBE_PROMPT, 'win32') + const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT) + const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') + + await vi.advanceTimersByTimeAsync(delayMs - 1) + expect(countSubmits(writes)).toBe(0) + await vi.advanceTimersByTimeAsync(1) + expect(countSubmits(writes)).toBe(1) + + await vi.runAllTimersAsync() + await stalled + }) +}) + +describe('agent prompt render gate on a ConPTY host', () => { + afterEach(() => { + vi.useRealTimers() + Object.defineProperty(process, 'platform', originalPlatform) + }) + + /** Claude/Codex take the closed-loop gate; the paste-end write emits the show-cursor + * marker and then goes silent, which is what an agent that repaints mid-ingest looks like. */ + async function createSettlementRuntime( + // `noiseUntilMs` keeps the pane emitting inside every quiet window, so the gate can only + // end on its hard cap -- which is what the cap's arithmetic has to be measured against. + agentOutput: { markerDelayMs?: number; noiseUntilMs?: number } = {} + ): Promise<{ + runtime: OrcaRuntimeService + handle: string + writes: string[] + submitTimes: number[] + }> { + const markerDelayMs = agentOutput.markerDelayMs ?? 100 + const runtime = new OrcaRuntimeService(makeStore() as never) + const writes: string[] = [] + const submitTimes: number[] = [] + const startedAt = Date.now() + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: PTY_ID }), + write: (_ptyId, data) => { + writes.push(data) + if (data === '\r') { + submitTimes.push(Date.now() - startedAt) + } + if (data.includes('\x1b[201~')) { + setTimeout(() => runtime.onPtyData(PTY_ID, '\x1b[?25h', Date.now()), markerDelayMs) + for (let at = markerDelayMs + 500; at <= (agentOutput.noiseUntilMs ?? 0); at += 500) { + setTimeout(() => runtime.onPtyData(PTY_ID, '.', Date.now()), at) + } + } + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + const terminal = await runtime.createTerminal(`path:${WORKTREE_PATH}`, { + launchAgent: 'claude' + }) + return { runtime, handle: terminal.handle, writes, submitTimes } + } + + it('does not let a mid-ingest marker plus quiet settle a large paste early', async () => { + useHostPlatform('win32') + vi.useFakeTimers() + const { runtime, handle, writes, submitTimes } = await createSettlementRuntime() + const submission = runtime.sendTerminalAgentPrompt(handle, 'y'.repeat(320_000)) + const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') + + // Marker at 100 ms + a 1_500 ms quiet window would have submitted at ~1_600 ms, while + // ConPTY needs 2_969-3_342 ms just to hand the paste to the child. + await vi.advanceTimersByTimeAsync(3_342) + expect(writes.filter((data) => data.includes('\x1b[201~'))).toHaveLength(1) + expect(countSubmits(writes)).toBe(0) + + await vi.runAllTimersAsync() + expect(submitTimes).toHaveLength(1) + expect(submitTimes[0]).toBeGreaterThan(3_342) + await stalled + }) + + it('caps a never-quiet pane at one ingest window past the render timeout', async () => { + useHostPlatform('win32') + vi.useFakeTimers() + const prompt = 'y'.repeat(320_000) + const ingestMs = getTerminalPasteIngestMs( + 'win32', + Buffer.byteLength(buildAgentPromptPasteBytes(prompt), 'utf8') + ) + // The marker lands mid-ingest, which re-arms the cap; the ingest term must not be charged + // a second time from that later moment. + const { runtime, handle, writes, submitTimes } = await createSettlementRuntime({ + markerDelayMs: ingestMs - 1_000, + noiseUntilMs: ingestMs + 20_000 + }) + const submission = runtime.sendTerminalAgentPrompt(handle, prompt) + const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') + + await vi.advanceTimersByTimeAsync(ingestMs + 8_000 - 1) + expect(countSubmits(writes)).toBe(0) + + await vi.runAllTimersAsync() + expect(submitTimes).toHaveLength(1) + expect(submitTimes[0]).toBeGreaterThanOrEqual(ingestMs + 8_000) + expect(submitTimes[0]).toBeLessThan(ingestMs + 8_500) + await stalled + }) + + it('still settles a normal prompt on the marker plus one quiet window', async () => { + useHostPlatform('win32') + vi.useFakeTimers() + const { runtime, handle, submitTimes } = await createSettlementRuntime() + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') + + await vi.runAllTimersAsync() + expect(submitTimes).toHaveLength(1) + // 100 ms marker + 1_500 ms quiet: a sub-chunk paste adds no measurable ingest. + expect(submitTimes[0]).toBeGreaterThanOrEqual(1_600) + expect(submitTimes[0]).toBeLessThan(1_700) + await stalled + }) +}) + +describe('plain terminal send suffix delay', () => { + afterEach(() => { + vi.useRealTimers() + Object.defineProperty(process, 'platform', originalPlatform) + }) + + it('scales the Enter that follows plain text with the payload the host must ingest', async () => { + useHostPlatform('win32') + vi.useFakeTimers() + const { runtime, handle, writes, submitTimes } = await createPromptRuntime() + const send = runtime.sendTerminal(handle, { text: 'z'.repeat(320_000), enter: true }) + + // Same hazard as the agent-prompt path: a flat 500 ms wrote Enter mid-paste here. + await vi.advanceTimersByTimeAsync(3_342) + expect(countSubmits(writes)).toBe(0) + + await vi.runAllTimersAsync() + await send + expect(submitTimes).toHaveLength(1) + expect(submitTimes[0]).toBeGreaterThan(3_342) + }) + + it('abandons the scaled suffix wait when the request is aborted', async () => { + useHostPlatform('win32') + vi.useFakeTimers() + const controller = new AbortController() + const { runtime, handle, writes } = await createPromptRuntime() + const send = runtime.sendTerminal( + handle, + { text: 'z'.repeat(320_000), enter: true }, + { signal: controller.signal } + ) + const rejected = expect(send).rejects.toThrow('request_aborted') + + await vi.advanceTimersByTimeAsync(100) + controller.abort() + await vi.runAllTimersAsync() + + await rejected + expect(countSubmits(writes)).toBe(0) + }) }) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 1f9d810e977..ea21fda6fff 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -97,8 +97,9 @@ import { MAX_QUICK_COMMANDS } from '../../shared/terminal-quick-commands' import { AGENT_PROMPT_BRACKETED_PASTE_END, AGENT_PROMPT_BRACKETED_PASTE_START, - AGENT_PROMPT_SUBMIT_DELAY_MS, - buildAgentPromptPasteBytes + buildAgentPromptPasteBytes, + getAgentPromptSubmitDelayMs, + getTerminalPasteIngestMs } from '../../shared/agent-prompt-injection' import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text' import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection' @@ -1049,6 +1050,17 @@ const TEST_REPO_ID = 'repo-1' const TEST_REPO_PATH = '/tmp/repo' const TEST_WORKTREE_PATH = '/tmp/worktree-a' const TEST_WORKTREE_ID = `${TEST_REPO_ID}::${TEST_WORKTREE_PATH}` +/** The render gate's hard cap bounds the wait *after* the paste lands, so it carries the + * payload's ingest bound on top of the flat 8 s settlement budget. */ +function renderGateCapMs(prompt: string): number { + return ( + 8_000 + + getTerminalPasteIngestMs( + process.platform, + Buffer.byteLength(buildAgentPromptPasteBytes(prompt), 'utf8') + ) + ) +} const TEST_FOLDER_PROJECT_GROUP_ID = 'folder-project-group-1' const TEST_FOLDER_WORKSPACE_ID = 'folder-workspace-1' const TEST_FOLDER_WORKSPACE_KEY = `folder:${TEST_FOLDER_WORKSPACE_ID}` @@ -17490,7 +17502,7 @@ describe('OrcaRuntimeService', () => { (Object.keys(TUI_AGENT_CONFIG) as TuiAgent[]).filter( (agent) => agent !== 'claude' && agent !== 'codex' ) - )('preserves the legacy fixed submit delay for %s', async (agent) => { + )('holds Enter for the full open-loop submit delay for %s', async (agent) => { vi.useFakeTimers() try { const writes: string[] = [] @@ -17509,8 +17521,12 @@ describe('OrcaRuntimeService', () => { launchAgent: agent }) + const submitDelayMs = getAgentPromptSubmitDelayMs( + process.platform, + Buffer.byteLength(buildAgentPromptPasteBytes('review this change'), 'utf8') + ) const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') - await vi.advanceTimersByTimeAsync(AGENT_PROMPT_SUBMIT_DELAY_MS - 1) + await vi.advanceTimersByTimeAsync(submitDelayMs - 1) expect(writes).not.toContain('\r') await vi.advanceTimersByTimeAsync(1) @@ -17581,7 +17597,7 @@ describe('OrcaRuntimeService', () => { }) const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') - await vi.advanceTimersByTimeAsync(7_999) + await vi.advanceTimersByTimeAsync(renderGateCapMs('review this change') - 1) expect(writes).not.toContain('\r') await vi.advanceTimersByTimeAsync(1) @@ -17662,7 +17678,9 @@ describe('OrcaRuntimeService', () => { }) const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') - await vi.advanceTimersByTimeAsync(8_099) + // The marker at 100 ms re-arms the cap, but the ingest term is absolute: a prompt this + // small is already ingested by then, so the fallback is one flat render timeout later. + await vi.advanceTimersByTimeAsync(100 + 8_000 - 1) expect(writes).not.toContain('\r') await vi.advanceTimersByTimeAsync(1) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index db9be9e7aa0..71c71b7291d 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -100,8 +100,9 @@ import { import { AGENT_PROMPT_BRACKETED_PASTE_END, AGENT_PROMPT_SUBMIT, - AGENT_PROMPT_SUBMIT_DELAY_MS, - buildAgentPromptPasteBytes + buildAgentPromptPasteBytes, + getAgentPromptSubmitDelayMs, + getTerminalPasteIngestMs } from '../../shared/agent-prompt-injection' import { type AgentPromptActivity, @@ -2120,6 +2121,10 @@ const FOREGROUND_AGENT_WRAPPER_RETRY_TIMEOUT_MS = 6_500 const BRACKETED_PASTE_BEGIN = '\x1b[200~' const BRACKETED_PASTE_END = '\x1b[201~' const BRACKETED_PASTE_QUIET_MS = 1500 +// Why: both are windows *after* the paste is ingested, so each is added to the +// payload's ingest bound rather than standing in for it (see getTerminalPasteIngestMs). +// The quiet window stays at 1500: nothing measured describes an agent's post-paste +// redraw cadence, and a shorter window submits mid-redraw. const AGENT_PROMPT_RENDER_TIMEOUT_MS = 8000 const AGENT_PROMPT_RENDER_QUIET_MS = 1500 // Why: Claude and Codex emit show-cursor after accepting bracketed paste. @@ -2163,6 +2168,19 @@ async function waitForAgentPromptPromise(promise: Promise, signal?: AbortS }) } +// Why not setTimeout(0): it costs a full ~15.19 ms Windows timer tick per chunk (~0.95 s/MB) +// and never bought backpressure -- 16 KiB per tick paces ~1.07 MB/s, 11x above ConPTY's +// ~96 KB/s drain, so the in-flight buffer grew regardless. setImmediate keeps the only thing +// the yield actually did (let abort/permission/data callbacks run between chunks) at ~0.01 ms, +// and TERMINAL_INPUT_MAX_BYTES still bounds what can be in flight either way. +// Why the global and not node:timers/promises: only the global is intercepted by fake timers, +// so a chunked paste stays observable on the test clock. +function yieldBetweenTerminalInputChunks(): Promise { + return new Promise((resolve) => { + setImmediate(resolve) + }) +} + async function waitForAgentPromptDelay(delayMs: number, signal?: AbortSignal): Promise { if (!signal) { await new Promise((resolve) => setTimeout(resolve, delayMs)) @@ -19113,6 +19131,9 @@ export class OrcaRuntimeService { reserveWrite?: (ptyId: string) => void afterWrite?: (ptyId: string) => void | Promise suffixFailureError?: string + // Why: the pre-Enter wait now scales with the payload, so an abandoned request must be + // able to stop it instead of writing Enter minutes after the caller gave up. + signal?: AbortSignal } = {} ): Promise { const pty = this.getLivePtyForHandle(handle) @@ -19809,19 +19830,28 @@ export class OrcaRuntimeService { reserveWrite?: (ptyId: string) => void afterWrite?: (ptyId: string) => void | Promise suffixFailureError?: string + signal?: AbortSignal } = {} ): Promise { // Why: direct terminal.send can carry paste-sized text from RPC/mobile // clients; chunk text before PTY/ConPTY while preserving suffix separation. - const hasText = typeof action.text === 'string' && action.text.length > 0 + const text = typeof action.text === 'string' ? action.text : '' const hasSuffix = action.enter || action.interrupt - if (hasText) { - await this.writeTerminalInputChunks(ptyId, action.text!, options) + if (text) { + await this.writeTerminalInputChunks(ptyId, text, options) } if (hasSuffix) { const suffix = (action.enter ? '\r' : '') + (action.interrupt ? '\x03' : '') - if (hasText) { - await new Promise((resolve) => setTimeout(resolve, 500)) + if (text) { + // Why: same hazard as the agent-prompt path -- Enter must not overtake text the + // execution host is still ingesting, and a flat 500 ms cannot cover 16 MB. + await waitForAgentPromptDelay( + getAgentPromptSubmitDelayMs( + this.getPtyWriteHostPlatform(ptyId), + Buffer.byteLength(text, 'utf8') + ), + options.signal + ) } try { await options.beforeWrite?.(ptyId) @@ -19839,7 +19869,7 @@ export class OrcaRuntimeService { await options.afterWrite?.(ptyId) return } - if (hasText) { + if (text) { return } @@ -19873,11 +19903,32 @@ export class OrcaRuntimeService { await options.afterWrite?.(ptyId) chunk = chunks.next() if (!chunk.done) { - await new Promise((resolve) => setTimeout(resolve, 0)) + await yieldBetweenTerminalInputChunks() } } } + /** Platform of the host whose pty transport ingests our writes -- deliberately NOT the OS + * the command runs under. A WSL pane is spawned as `wsl.exe` through the Windows ConPTY + * (see local-pty-provider), so it pays the ConPTY ingest cost even though its shell is + * Linux; an SSH pane is spawned by node-pty on the remote host, so the client's + * process.platform says nothing about it. */ + private getPtyWriteHostPlatform(ptyId: string): NodeJS.Platform { + const pty = this.ptysById.get(ptyId) + const connectionId = pty?.connectionId + if (!connectionId) { + return process.platform + } + const remotePlatform = getRegisteredSshState(connectionId)?.remotePlatform + if (remotePlatform) { + return remotePlatform + } + // Why: remotePlatform only arrives with the relay handshake; until then the worktree path + // flavor is the same signal getAgentLaunchPlatformForRepo already trusts for a remote repo. + const worktreePath = pty ? splitWorktreeIdForFilesystem(pty.worktreeId)?.worktreePath : null + return worktreePath && isWindowsAbsolutePathLike(worktreePath) ? 'win32' : 'linux' + } + private async writeTerminalAgentPrompt( handle: string, ptyId: string, @@ -19893,7 +19944,12 @@ export class OrcaRuntimeService { this.assertAgentPromptGeneration(ptyId, generation) const permissionBaseline = this.getAgentPromptActivity(handle, ptyId) this.assertAgentPromptPermissionSafe(permissionBaseline, permissionBaseline) - const renderGate = this.createAgentPromptRenderGate(ptyId) + // Why: the floor for every wait below. Enter must never overtake bytes the execution + // host is still feeding the child, and that cost is proportional to the payload. + const writeHostPlatform = this.getPtyWriteHostPlatform(ptyId) + const pasteByteLength = Buffer.byteLength(pastePayload, 'utf8') + const pasteIngestMs = getTerminalPasteIngestMs(writeHostPlatform, pasteByteLength) + const renderGate = this.createAgentPromptRenderGate(ptyId, pasteIngestMs) let wrotePasteBytes = false let completedPaste = false try { @@ -19920,7 +19976,7 @@ export class OrcaRuntimeService { wrotePasteBytes = true chunk = nextChunk if (!chunk.done) { - await new Promise((resolve) => setTimeout(resolve, 0)) + await yieldBetweenTerminalInputChunks() } } completedPaste = true @@ -19943,7 +19999,10 @@ export class OrcaRuntimeService { renderGate.dispose() } } else { - await waitForAgentPromptDelay(AGENT_PROMPT_SUBMIT_DELAY_MS, options.signal) + await waitForAgentPromptDelay( + getAgentPromptSubmitDelayMs(writeHostPlatform, pasteByteLength), + options.signal + ) } assertAgentPromptRequestActive(options.signal) this.assertAgentPromptGeneration(ptyId, generation) @@ -20044,7 +20103,13 @@ export class OrcaRuntimeService { } } - private createAgentPromptRenderGate(ptyId: string): { + /** `pasteIngestMs` is the payload's ingest bound on the executing host. Nothing here may + * settle before it elapses: the agent can repaint mid-ingest, so marker-then-quiet alone + * would fire Enter into a paste ConPTY is still feeding. */ + private createAgentPromptRenderGate( + ptyId: string, + pasteIngestMs: number + ): { arm: () => void wait: () => Promise dispose: () => void @@ -20057,19 +20122,20 @@ export class OrcaRuntimeService { let armed = false let canSettle = false let settled = false + let ingested = pasteIngestMs <= 0 + // Why absolute: the ingest clock starts once, here, but the cap is armed twice (at arm() + // and again on the marker). Re-adding the whole window would charge ingest twice. + const ingestDeadlineAt = Date.now() + pasteIngestMs let markerCarry = '' let quietTimer: NodeJS.Timeout | null = null let hardTimer: NodeJS.Timeout | null = null + let ingestTimer: NodeJS.Timeout | null = null let resolveRender!: () => void const rendered = new Promise((resolve) => { resolveRender = resolve }) - const finish = (): void => { - if (settled) { - return - } - settled = true + const clearGateTimers = (): void => { if (quietTimer) { clearTimeout(quietTimer) quietTimer = null @@ -20078,9 +20144,25 @@ export class OrcaRuntimeService { clearTimeout(hardTimer) hardTimer = null } + if (ingestTimer) { + clearTimeout(ingestTimer) + ingestTimer = null + } + } + const finish = (): void => { + if (settled) { + return + } + settled = true + clearGateTimers() resolveRender() } const armQuietTimer = (): void => { + // Why: the quiet window measures the agent going still after a *complete* paste. + // Silence during ingest is not settlement, so it cannot start the clock. + if (!ingested) { + return + } if (quietTimer) { clearTimeout(quietTimer) } @@ -20090,7 +20172,21 @@ export class OrcaRuntimeService { if (hardTimer) { clearTimeout(hardTimer) } - hardTimer = setTimeout(finish, AGENT_PROMPT_RENDER_TIMEOUT_MS) + // Why: the cap bounds the wait *after* the bytes land; a flat 8000 ms would expire + // mid-paste past ~770 KB on Windows and write Enter into it. + hardTimer = setTimeout( + finish, + AGENT_PROMPT_RENDER_TIMEOUT_MS + Math.max(0, ingestDeadlineAt - Date.now()) + ) + } + if (!ingested) { + ingestTimer = setTimeout(() => { + ingestTimer = null + ingested = true + if (canSettle) { + armQuietTimer() + } + }, pasteIngestMs) } const unsubscribe = this.subscribeToTerminalData(ptyId, (data) => { if (!armed || settled) { @@ -20122,14 +20218,7 @@ export class OrcaRuntimeService { }, dispose: () => { unsubscribe() - if (quietTimer) { - clearTimeout(quietTimer) - quietTimer = null - } - if (hardTimer) { - clearTimeout(hardTimer) - hardTimer = null - } + clearGateTimers() } } } diff --git a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts index a4c58fbab68..9baa4827a55 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts @@ -179,6 +179,7 @@ export const TERMINAL_SEND_METHODS: RpcAnyMethod[] = [ }, { beforeWrite, + signal, ...(reserveWrite ? { reserveWrite } : {}), ...(params.inputKind !== 'query-reply' && mobileFloorClientId ? { afterWrite: () => commitMobileInputFloorClaim(mobileFloorClaim) } diff --git a/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts b/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts index eb7624c95e4..29254fbcf3a 100644 --- a/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts +++ b/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts @@ -81,8 +81,37 @@ describe('terminal agent prompt send RPC', () => { expect(sendTerminal).toHaveBeenCalledWith( 'terminal-1', { text: 'echo x', enter: true, interrupt: false }, - { beforeWrite: undefined } + { beforeWrite: undefined, signal: undefined } ) expect(sendTerminalAgentPrompt).not.toHaveBeenCalled() }) + + it('forwards the request signal to a plain send so an abandoned call stops before Enter', async () => { + const sendTerminal = vi.fn().mockResolvedValue({ + handle: 'terminal-1', + accepted: true, + bytesWritten: 7 + }) + const runtime = makeRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + isTerminalRunningSettledPromptAgent: vi.fn().mockResolvedValue(false), + sendTerminal + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const controller = new AbortController() + + const response = await dispatcher.dispatch( + makeRequest({ + terminal: 'terminal-1', + text: 'echo x', + enter: true, + client: { id: 'orca-cli', type: 'desktop' } + }), + { signal: controller.signal } + ) + + expect(response.ok).toBe(true) + expect(sendTerminal.mock.calls[0][2].signal).toBe(controller.signal) + }) }) diff --git a/src/shared/agent-prompt-injection.test.ts b/src/shared/agent-prompt-injection.test.ts index 54b4d6cb544..159f5d19270 100644 --- a/src/shared/agent-prompt-injection.test.ts +++ b/src/shared/agent-prompt-injection.test.ts @@ -5,6 +5,7 @@ import { buildAgentPromptPasteBytes, buildAgentPromptSubmitBytes, getAgentPromptSubmitDelayMs, + getTerminalPasteIngestMs, iterateAgentPromptPasteChunks, sanitizeAgentPromptText } from './agent-prompt-injection' @@ -24,10 +25,60 @@ describe('agent prompt injection bytes', () => { expect(buildAgentPromptSubmitBytes()).toBe('\r') }) - it('gives Windows ConPTY more time to render before submit', () => { - expect(getAgentPromptSubmitDelayMs('win32')).toBe(1_500) - expect(getAgentPromptSubmitDelayMs('darwin')).toBe(500) - expect(getAgentPromptSubmitDelayMs('linux')).toBe(500) + it('costs a common-sized prompt far less than the old flat Windows delay', () => { + // Measured ingest: 2 KB 14-25 ms, 8 KB 60-89 ms. The old constant charged 1_500 ms for both. + expect(getAgentPromptSubmitDelayMs('win32', 2_000)).toBeLessThan(700) + expect(getAgentPromptSubmitDelayMs('win32', 8_000)).toBeLessThan(700) + expect(getAgentPromptSubmitDelayMs('darwin', 8_000)).toBeLessThan(700) + }) + + it.each([ + // The slower of the two measured Win11 hosts at each size. Dipping under any of these + // reopens the mid-paste Enter bug, so pin all of them, not just the top end. + [2_000, 25], + [8_000, 89], + [40_000, 440], + [80_000, 858], + [160_000, 1_662], + [320_000, 3_342] + ])('outlasts the slowest measured ConPTY ingest of %i bytes', (bytes, measuredMs) => { + expect(getTerminalPasteIngestMs('win32', bytes)).toBeGreaterThan(measuredMs) + expect(getAgentPromptSubmitDelayMs('win32', bytes)).toBeGreaterThan(measuredMs) + }) + + it('keeps a real margin over the slowest measured slope', () => { + // 1.5x of 0.0104 ms/byte, so a host ~50% slower than either measured one is still covered. + expect(getTerminalPasteIngestMs('win32', 320_000)).toBeGreaterThan(3_342 * 1.4) + }) + + it('outgrows the old 1_500 ms constant before ConPTY ingest does', () => { + // Ingest crossed 1_500 ms at ~145 KB; the delay must already exceed it there. + expect(getAgentPromptSubmitDelayMs('win32', 145_000)).toBeGreaterThan(1_500) + }) + + it('scales without a cap all the way to the terminal input ceiling', () => { + const ceilingBytes = 16 * 1024 * 1024 + expect(getAgentPromptSubmitDelayMs('win32', ceilingBytes)).toBeGreaterThan(250_000) + // Even the fast platforms outrun a flat 500 ms at the ceiling. + expect(getAgentPromptSubmitDelayMs('linux', ceilingBytes)).toBeGreaterThan(4_000) + }) + + it('charges non-Windows hosts nothing measurable for a real prompt', () => { + expect(getTerminalPasteIngestMs('darwin', 8_000)).toBeLessThanOrEqual(2) + expect(getTerminalPasteIngestMs('linux', 0)).toBe(0) + expect(getTerminalPasteIngestMs('linux', Number.NaN)).toBe(0) + expect(getTerminalPasteIngestMs('win32', -5)).toBe(0) + }) + + it('grows monotonically with payload size on every platform', () => { + for (const platform of ['win32', 'darwin', 'linux'] as const) { + expect(getAgentPromptSubmitDelayMs(platform, 400_000)).toBeGreaterThan( + getAgentPromptSubmitDelayMs(platform, 40_000) + ) + } + expect(getTerminalPasteIngestMs('win32', 320_000)).toBeGreaterThan( + getTerminalPasteIngestMs('darwin', 320_000) + ) }) it('sanitizes embedded escape bytes before framing', () => { diff --git a/src/shared/agent-prompt-injection.ts b/src/shared/agent-prompt-injection.ts index 3db9d1cd39d..a52738a6f90 100644 --- a/src/shared/agent-prompt-injection.ts +++ b/src/shared/agent-prompt-injection.ts @@ -4,17 +4,49 @@ export const AGENT_PROMPT_BRACKETED_PASTE_START = '\x1b[200~' export const AGENT_PROMPT_BRACKETED_PASTE_END = '\x1b[201~' export const AGENT_PROMPT_SUBMIT = '\r' -const DEFAULT_AGENT_PROMPT_SUBMIT_DELAY_MS = 500 -const WINDOWS_AGENT_PROMPT_SUBMIT_DELAY_MS = 1_500 +// Why: Windows ConPTY ingests pasted input linearly (first byte written -> child observes +// ESC[201~), and the cost is input ingest, not rendering -- the child repaints in ~0 ms on +// both platforms. Two real Win11 hosts, bundled ConPTY DLL, 16 KiB chunks: +// bytes host A host B +// 2,000 14 ms 25 ms +// 8,000 60 ms 89 ms +// 40,000 347 ms 440 ms +// 160,000 1662 ms 1499 ms +// 320,000 3342 ms 2969 ms +// Slopes: 0.0104 ms/byte (A) and 0.0092 ms/byte (B), i.e. ~40% host-to-host spread in both +// directions. 64 B/ms is 1.5x the slower of the two slopes, so neither host -- nor a +// meaningfully slower one -- can still be ingesting when the wait ends. +const WINDOWS_CONPTY_INGEST_BYTES_PER_MS = 64 +// Why: the same walk on macOS drains 320 KB in 26 ms (~12.3 KB/ms), but at those magnitudes +// the samples are noise-dominated (80 KB measured faster than 40 KB), so hold a 3x margin. +// It costs 0 ms at real prompt sizes and 4.1 s at the 16 MB terminal-input ceiling. +const DEFAULT_PASTE_INGEST_BYTES_PER_MS = 4_096 +// Why: ingest only buys the child the *bytes*; it still has to attach the completed paste +// before Enter counts. Unchanged from the previous cross-platform constant -- nothing +// measured here justifies moving it, and it also absorbs the 15-25 ms fixed intercept +// both ConPTY hosts show below the linear term. +const AGENT_PROMPT_SUBMIT_SETTLE_MS = 500 -// Why: ConPTY renders long bracketed pastes more slowly; an early Enter leaves the task in the agent input buffer. -export function getAgentPromptSubmitDelayMs(platform: NodeJS.Platform): number { - return platform === 'win32' - ? WINDOWS_AGENT_PROMPT_SUBMIT_DELAY_MS - : DEFAULT_AGENT_PROMPT_SUBMIT_DELAY_MS +/** Lower bound on when a paste of `byteLength` can have reached the child, given the + * ingest rate of the host that owns the pty transport (not the OS the command runs under). */ +export function getTerminalPasteIngestMs(platform: NodeJS.Platform, byteLength: number): number { + if (!Number.isFinite(byteLength) || byteLength <= 0) { + return 0 + } + return Math.ceil( + byteLength / + (platform === 'win32' + ? WINDOWS_CONPTY_INGEST_BYTES_PER_MS + : DEFAULT_PASTE_INGEST_BYTES_PER_MS) + ) } -export const AGENT_PROMPT_SUBMIT_DELAY_MS = getAgentPromptSubmitDelayMs(process.platform) +/** Open-loop wait before Enter for agents with no settlement signal: the paste cannot have + * landed before it is ingested, and the child needs a settle window after that. Never + * capped -- a cap silently reintroduces the mid-paste Enter it exists to prevent. */ +export function getAgentPromptSubmitDelayMs(platform: NodeJS.Platform, byteLength: number): number { + return AGENT_PROMPT_SUBMIT_SETTLE_MS + getTerminalPasteIngestMs(platform, byteLength) +} const ESCAPE = '\x1b' const INERT_ESCAPE = '' From 68d5b9206e9bc79a4522d6466debe2b9fa356b8f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:44:40 -0700 Subject: [PATCH 029/152] fix(agent-prompt): stop reporting delivered prompts as stalled (#16095) (#16590) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(agent-prompt): stop reporting delivered prompts as stalled (#16095) Enter is written before verification runs, so `agent_prompt_stalled` can only ever mean "turn start not observed" — never "prompt not delivered". Three of the verifier's blind spots made that misreading routine, and the coordinator then treated it as non-delivery and pasted the whole preamble a second time into a worker already running it. - Accept a hook-reported `working` recorded after the baseline. Hook rows reach the runtime through getAgentStatusSnapshot with no window involved, unlike the synthetic-title route that feeds workingSequence (suppressed for codex, absent for kimi, and gated on window visibility for everyone else). - Accept pane output after Enter when the agent was already working: a `->working` edge is unreachable there, so the old predicate could never be satisfied by a follow-up prompt. An idle agent still owes a real turn start, so a swallowed Enter stays detectable. - Give codex/kimi panes a longer effect window; their only turn-start proof is an out-of-process hook round-trip, not a TUI repaint. - Coordinator dispatch no longer fails (and re-dispatches) a task whose prompt stalled; the dispatch stays active with its capability intact so the worker's own report settles it. * fix(orchestration): let a worker's own report correct an unobserved prompt (#16095) Follow-up to f9f973c on two review findings. Anchor the hook signal on a turn, not a refresh: `receivedAt`/`updatedAt` bump on every same-state hook ping, so an in-progress turn could have passed for a new one and silently accepted every prompt to a working agent. `stateStartedAt` is the documented per-turn identity (pinned across same-state pings), so the verifier now reads that. Close the worker-start path: a `dispatch_input` stall settled the dispatch as failed *and* revoked the capability, so a worker that ran the preamble to completion had its result rejected. Revocation is now skipped for that cause, and a worker report can re-settle a dispatch whose `last_failure` is `agent_prompt_stalled` — retaining the capability alone was not enough, because settlement also gates on dispatch/task status. * fix(orchestration): let a failed worker report correct a stalled-prompt record (#16095) The duplicate short-circuit ran before the unobserved-prompt branch, and for outcome 'failed' both expected statuses are exactly the state failWorkerStart leaves behind. A worker that reported a real failure was answered duplicate:true, so its cause and result body were dropped and the record kept 'agent_prompt_stalled'. Evaluate settledByUnobservedPrompt first so one failure report can re-settle that dispatch; a repeat report is still a duplicate. Also derive the previous dispatch/worker states once instead of two parallel ternaries, reuse getPtyAgent in createAgentPromptRenderGate, cite the real 30s relay request budget the hook window is sized against, and make the coordinator test settle an actual worker report rather than calling completeDispatch under a name that promised otherwise. * test(orchestration): carry the new dispatch-depth fields into this PR's fixtures main added required creator/maxDepth on createStartingWorkerDispatch and nestedWorkerMaxDepth on dispatchTaskToWorker while this branch was open. The two fixtures added here predate them, so the merge typechecked clean on each side and failed once combined. Mechanical; no behaviour asserted here changes. --- .../agent-prompt-submission-runtime.test.ts | 118 +++++++++++++-- ...ent-prompt-submission-verification.test.ts | 110 ++++++++++++++ .../agent-prompt-submission-verification.ts | 76 +++++++++- src/main/runtime/orca-runtime.ts | 32 ++++- ...dinator-dispatch-unobserved-prompt.test.ts | 134 ++++++++++++++++++ .../coordinator-task-dispatch.ts | 15 +- .../worker-report-settlement.ts | 43 ++++-- .../worker-dispatch-outcome.ts | 11 +- ...start-unobserved-prompt-settlement.test.ts | 102 +++++++++++++ ...orker-start-outcome-classification.test.ts | 32 +++++ ...ation-worker-start-prompt-contract.test.ts | 4 +- .../orchestration-worker-start-receipt.ts | 7 +- 12 files changed, 648 insertions(+), 36 deletions(-) create mode 100644 src/main/runtime/orchestration/coordinator-dispatch-unobserved-prompt.test.ts create mode 100644 src/main/runtime/orchestration/worker-start-unobserved-prompt-settlement.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-worker-start-outcome-classification.test.ts diff --git a/src/main/runtime/agent-prompt-submission-runtime.test.ts b/src/main/runtime/agent-prompt-submission-runtime.test.ts index 69eb0a01079..3b8fe67bea7 100644 --- a/src/main/runtime/agent-prompt-submission-runtime.test.ts +++ b/src/main/runtime/agent-prompt-submission-runtime.test.ts @@ -403,15 +403,7 @@ describe('agent prompt submission runtime', () => { it('does not treat an unchanged newer working status as submission evidence', async () => { vi.useFakeTimers() vi.setSystemTime(1_000) - const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { - if (data === '\r') { - runtime.onPtyData( - 'pty-prompt', - '\x1b]9999;{"state":"working","agentType":"aider"}\x07', - Date.now() - ) - } - }) + const { runtime, handle, writes } = await createPromptRuntime(() => undefined) runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) vi.setSystemTime(2_000) runtime.onPtyData( @@ -428,6 +420,114 @@ describe('agent prompt submission runtime', () => { expect(writes.filter((data) => data === '\r')).toHaveLength(1) }) + // Why (#16095): a still-working agent can never produce a `→working` edge, so the old predicate + // was unsatisfiable for every follow-up prompt; pane output after Enter is the evidence left. + it('accepts pane output after Enter while the agent is already working', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { + if (data === '\r') { + runtime.onPtyData('pty-prompt', 'queued for the current turn', Date.now()) + } + }) + runtime.onPtyData( + 'pty-prompt', + '\x1b]9999;{"state":"working","agentType":"aider"}\x07', + Date.now() + ) + + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + await vi.runAllTimersAsync() + + await expect(submission).resolves.toMatchObject({ accepted: true }) + expect(writes.filter((data) => data === '\r')).toHaveLength(1) + }) + + // Why: hook rows reach the runtime through this provider, which has no window and no OSC title — + // the same path a headless `orca serve` host and a minimized desktop window take. + async function createHookOnlyPromptRuntime(hook: { + state: 'done' | 'working' + stateStartedAt: number + }): Promise<{ runtime: OrcaRuntimeService; handle: string; writes: string[] }> { + let handle = '' + const writes: string[] = [] + const runtime = new OrcaRuntimeService(makeStore() as never, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'prompt-pane', + terminalHandle: handle, + state: hook.state, + prompt: '', + agentType: 'kimi', + connectionId: null, + // Why: every hook ping refreshes receivedAt, including same-state tool pings. + receivedAt: Date.now(), + stateStartedAt: hook.stateStartedAt + } + ] + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }), + write: (_ptyId, data) => { + writes.push(data) + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + handle = ( + await runtime.createTerminal(`path:${AGENT_PROMPT_TEST_WORKTREE_PATH}`, { + launchAgent: 'kimi' + }) + ).handle + return { runtime, handle, writes } + } + + it('accepts a hook working status with no window and no title coverage', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const hook = { state: 'done' as 'done' | 'working', stateStartedAt: 1_000 } + const { runtime, handle, writes } = await createHookOnlyPromptRuntime(hook) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }), + write: (_ptyId, data) => { + writes.push(data) + if (data === '\r') { + vi.setSystemTime(3_000) + hook.state = 'working' + hook.stateStartedAt = 3_000 + } + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + await vi.runAllTimersAsync() + + await expect(submission).resolves.toMatchObject({ accepted: true }) + expect(writes.filter((data) => data === '\r')).toHaveLength(1) + }) + + // Why: same-state pings keep refreshing receivedAt on a turn that started before the prompt; + // only the pinned stateStartedAt separates that from a turn this prompt started. + it('does not accept a hook row refreshed without a new working turn', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const { runtime, handle, writes } = await createHookOnlyPromptRuntime({ + state: 'working', + stateStartedAt: 1_000 + }) + + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') + await vi.runAllTimersAsync() + + await rejected + expect(writes.filter((data) => data === '\r')).toHaveLength(1) + }) + it('does not write Enter after the PTY generation changes during settlement', async () => { vi.useFakeTimers() const { runtime, handle, writes } = await createPromptRuntime(() => undefined) diff --git a/src/main/runtime/agent-prompt-submission-verification.test.ts b/src/main/runtime/agent-prompt-submission-verification.test.ts index a0f3e1a5213..4a802df8d33 100644 --- a/src/main/runtime/agent-prompt-submission-verification.test.ts +++ b/src/main/runtime/agent-prompt-submission-verification.test.ts @@ -1,7 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { AGENT_PROMPT_EFFECT_TIMEOUT_MS, + AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS, type AgentPromptActivity, + isAgentPromptStalledError, + resolveAgentPromptEffectTimeoutMs, verifyAgentPromptSubmission } from './agent-prompt-submission-verification' @@ -10,6 +13,8 @@ function activity(overrides: Partial = {}): AgentPromptActi generation: 1, permissionSequence: 2, workingSequence: 4, + explicitWorkingStartedAt: null, + outputSequence: 7, status: 'idle', ...overrides } @@ -127,6 +132,111 @@ describe('agent prompt submission verification', () => { await rejected }) + it('accepts a hook working status recorded after the baseline', async () => { + vi.useFakeTimers() + let current = activity() + const verification = verifyAgentPromptSubmission({ + baseline: current, + readActivity: () => current + }) + + // No workingSequence edge: the window-gated synthetic title never ran (hidden window/headless). + current = activity({ explicitWorkingStartedAt: 2_000, status: 'working' }) + await vi.advanceTimersByTimeAsync(50) + + await expect(verification).resolves.toBeUndefined() + }) + + it('does not accept a hook working status that predates the baseline', async () => { + vi.useFakeTimers() + const current = activity({ explicitWorkingStartedAt: 2_000, status: 'working' }) + const verification = verifyAgentPromptSubmission({ + baseline: current, + readActivity: () => current + }) + const rejected = expect(verification).rejects.toThrow('agent_prompt_stalled') + + await vi.advanceTimersByTimeAsync(AGENT_PROMPT_EFFECT_TIMEOUT_MS) + + await rejected + }) + + // Why: same-state hook pings refresh the row without starting a turn, so only the pinned + // stateStartedAt may satisfy the check — a refreshed row must stay unproven. + it('does not accept a refreshed hook row whose working turn did not restart', async () => { + vi.useFakeTimers() + let current = activity({ explicitWorkingStartedAt: 2_000 }) + const verification = verifyAgentPromptSubmission({ + baseline: current, + readActivity: () => current + }) + const rejected = expect(verification).rejects.toThrow('agent_prompt_stalled') + + current = activity({ explicitWorkingStartedAt: 2_000, outputSequence: 40 }) + await vi.advanceTimersByTimeAsync(AGENT_PROMPT_EFFECT_TIMEOUT_MS) + + await rejected + }) + + it('accepts pane output after Enter when the agent was already working', async () => { + vi.useFakeTimers() + let current = activity({ status: 'working' }) + const verification = verifyAgentPromptSubmission({ + baseline: current, + readActivity: () => current + }) + + current = activity({ status: 'working', outputSequence: 8 }) + await vi.advanceTimersByTimeAsync(50) + + await expect(verification).resolves.toBeUndefined() + }) + + it('does not accept pane output when the agent was idle at submit', async () => { + vi.useFakeTimers() + let current = activity() + const verification = verifyAgentPromptSubmission({ + baseline: current, + readActivity: () => current + }) + const rejected = expect(verification).rejects.toThrow('agent_prompt_stalled') + + current = activity({ outputSequence: 9 }) + await vi.advanceTimersByTimeAsync(AGENT_PROMPT_EFFECT_TIMEOUT_MS) + + await rejected + }) + + it('holds the longer hook window open past the default timeout', async () => { + vi.useFakeTimers() + let current = activity() + const verification = verifyAgentPromptSubmission({ + baseline: current, + readActivity: () => current, + timeoutMs: AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS + }) + + await vi.advanceTimersByTimeAsync(AGENT_PROMPT_EFFECT_TIMEOUT_MS + 1_000) + current = activity({ explicitWorkingStartedAt: 9_000, status: 'working' }) + await vi.advanceTimersByTimeAsync(50) + + await expect(verification).resolves.toBeUndefined() + }) + + it('gives hook-observed agents the longer effect window', () => { + expect(resolveAgentPromptEffectTimeoutMs('codex')).toBe(AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS) + expect(resolveAgentPromptEffectTimeoutMs('kimi')).toBe(AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS) + expect(resolveAgentPromptEffectTimeoutMs('claude')).toBe(AGENT_PROMPT_EFFECT_TIMEOUT_MS) + expect(resolveAgentPromptEffectTimeoutMs(null)).toBe(AGENT_PROMPT_EFFECT_TIMEOUT_MS) + }) + + it('recognizes a stalled verdict from a message or a relayed error code', () => { + expect(isAgentPromptStalledError(new Error('agent_prompt_stalled'))).toBe(true) + expect(isAgentPromptStalledError({ code: 'agent_prompt_stalled' })).toBe(true) + expect(isAgentPromptStalledError(new Error('terminal_not_writable'))).toBe(false) + expect(isAgentPromptStalledError(null)).toBe(false) + }) + it('rejects a replaced terminal generation', async () => { const baseline = activity() diff --git a/src/main/runtime/agent-prompt-submission-verification.ts b/src/main/runtime/agent-prompt-submission-verification.ts index 41b5f5b1732..e8373bbaf1f 100644 --- a/src/main/runtime/agent-prompt-submission-verification.ts +++ b/src/main/runtime/agent-prompt-submission-verification.ts @@ -1,31 +1,68 @@ +import type { TuiAgent } from '../../shared/tui-agent' + export const AGENT_PROMPT_EFFECT_TIMEOUT_MS = 5_000 +// Why: these panes prove a turn start only through the out-of-process hook — kimi has no synthetic +// title profile and codex suppresses the hook-driven working frame (synthesizeWorkingTitle: false), +// so the first proof lags Enter by agent startup, not by one TUI repaint. Capped so the worst case +// (8s render gate + this wait + chunked paste) still fits RELAY_TO_CLIENT_REQUEST_TIMEOUT_MS +// (30s, src/relay/dispatcher.ts), the budget a paired client's submission runs under. +export const AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS = 15_000 const AGENT_PROMPT_EFFECT_POLL_MS = 50 +const HOOK_OBSERVED_TURN_START_AGENTS = new Set(['codex', 'kimi']) + +/** The prompt bytes are written before verification, so this only ever means "not observed". */ +export const AGENT_PROMPT_STALLED_ERROR = 'agent_prompt_stalled' + export type AgentPromptActivity = Readonly<{ generation: number permissionSequence: number workingSequence: number + /** When the hook's current `working` turn began; reaches the runtime with no window and no + * title coverage. Pinned across same-state pings, so a refresh alone cannot move it. */ + explicitWorkingStartedAt: number | null + /** PTY bytes seen on this pane; delivery evidence when a turn-start edge cannot be observed. */ + outputSequence: number status: 'working' | 'permission' | 'idle' | null }> type AgentPromptVerificationOptions = { baseline: AgentPromptActivity readActivity: () => AgentPromptActivity + timeoutMs?: number signal?: AbortSignal } +export function resolveAgentPromptEffectTimeoutMs(agent: TuiAgent | null | undefined): number { + return agent && HOOK_OBSERVED_TURN_START_AGENTS.has(agent) + ? AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS + : AGENT_PROMPT_EFFECT_TIMEOUT_MS +} + +export function isAgentPromptStalledError(error: unknown): boolean { + if (error instanceof Error && error.message === AGENT_PROMPT_STALLED_ERROR) { + return true + } + // Why: a relayed submission surfaces the same verdict as an RPC error code, not a message. + return ( + typeof error === 'object' && + error !== null && + (error as { code?: unknown }).code === AGENT_PROMPT_STALLED_ERROR + ) +} + export async function verifyAgentPromptSubmission( options: AgentPromptVerificationOptions ): Promise { throwIfAgentPromptAborted(options.signal) assertPromptNotBlocked(options.baseline, options.baseline) - const deadline = Date.now() + AGENT_PROMPT_EFFECT_TIMEOUT_MS + const deadline = Date.now() + (options.timeoutMs ?? AGENT_PROMPT_EFFECT_TIMEOUT_MS) while (Date.now() < deadline) { const current = options.readActivity() assertSamePromptGeneration(options.baseline, current) assertPromptNotBlocked(options.baseline, current) - if (agentPromptLifecycleChanged(options.baseline, current)) { + if (agentPromptEffectObserved(options.baseline, current)) { return } await waitForAgentPromptPoll(options.signal) @@ -34,17 +71,44 @@ export async function verifyAgentPromptSubmission( const current = options.readActivity() assertSamePromptGeneration(options.baseline, current) assertPromptNotBlocked(options.baseline, current) - if (agentPromptLifecycleChanged(options.baseline, current)) { + if (agentPromptEffectObserved(options.baseline, current)) { return } - throw new Error('agent_prompt_stalled') + throw new Error(AGENT_PROMPT_STALLED_ERROR) } -function agentPromptLifecycleChanged( +function agentPromptEffectObserved( baseline: AgentPromptActivity, current: AgentPromptActivity ): boolean { - return current.workingSequence > baseline.workingSequence + return ( + current.workingSequence > baseline.workingSequence || + observedHookWorkingAfterBaseline(baseline, current) || + observedDeliveryEvidence(baseline, current) + ) +} + +// Why: hook status reaches the runtime directly, so it survives a hidden window and headless serve — +// the synthetic-title route that feeds workingSequence does not (#16095). Only a turn that started +// after the baseline counts, so a same-state ping on the turn already running is not evidence. +function observedHookWorkingAfterBaseline( + baseline: AgentPromptActivity, + current: AgentPromptActivity +): boolean { + return ( + current.explicitWorkingStartedAt !== null && + current.explicitWorkingStartedAt > (baseline.explicitWorkingStartedAt ?? 0) + ) +} + +// Why: a `→working` edge is unreachable for an agent that is already working, so the honest proof +// that the prompt landed is the pane emitting bytes after Enter. An idle agent still owes a real +// turn start, which keeps a swallowed Enter detectable. +function observedDeliveryEvidence( + baseline: AgentPromptActivity, + current: AgentPromptActivity +): boolean { + return baseline.status === 'working' && current.outputSequence > baseline.outputSequence } function assertSamePromptGeneration( diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 71c71b7291d..54c6b711173 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -106,6 +106,7 @@ import { } from '../../shared/agent-prompt-injection' import { type AgentPromptActivity, + resolveAgentPromptEffectTimeoutMs, verifyAgentPromptSubmission } from './agent-prompt-submission-verification' import { @@ -19780,16 +19781,20 @@ export class OrcaRuntimeService { private getFreshExplicitAgentStatusForHandle(handle: string): { status: NonNullable updatedAt: number + /** When this state was entered. Pinned across same-state pings, so it identifies the turn. */ + stateStartedAt: number } | null { const paneKey = this.getPaneKeyForTerminalHandle(handle) const now = Date.now() let bestStatus: NonNullable | null = null let bestUpdatedAt = -1 + let bestStateStartedAt = -1 const consider = ( state: AgentStatusEntry['state'] | undefined, updatedAt: number | null | undefined, - restoredUnconfirmed = false + restoredUnconfirmed = false, + stateStartedAt?: number | null ): void => { if (!state || restoredUnconfirmed) { return @@ -19803,22 +19808,25 @@ export class OrcaRuntimeService { if (updatedAt > bestUpdatedAt || (updatedAt === bestUpdatedAt && status === 'permission')) { bestStatus = status bestUpdatedAt = updatedAt + bestStateStartedAt = typeof stateStartedAt === 'number' ? stateStartedAt : updatedAt } } if (paneKey) { const retained = this.latestAgentStatusByPaneKey.get(paneKey) - consider(retained?.payload.state, retained?.updatedAt) + consider(retained?.payload.state, retained?.updatedAt, false, retained?.stateStartedAt) } for (const entry of this.getAgentStatusSnapshotFn?.() ?? []) { if (entry.terminalHandle !== handle && (!paneKey || entry.paneKey !== paneKey)) { continue } - consider(entry.state, entry.receivedAt, entry.restoredUnconfirmed) + consider(entry.state, entry.receivedAt, entry.restoredUnconfirmed, entry.stateStartedAt) } - return bestStatus ? { status: bestStatus, updatedAt: bestUpdatedAt } : null + return bestStatus + ? { status: bestStatus, updatedAt: bestUpdatedAt, stateStartedAt: bestStateStartedAt } + : null } private async writeTerminalAction( @@ -20025,6 +20033,7 @@ export class OrcaRuntimeService { await verifyAgentPromptSubmission({ baseline, readActivity: () => this.getAgentPromptActivity(handle, ptyId), + timeoutMs: resolveAgentPromptEffectTimeoutMs(this.getPtyAgent(ptyId)), signal: options.signal }) return 1 @@ -20081,10 +20090,21 @@ export class OrcaRuntimeService { generation: this.getPtyLifecycleGeneration(ptyId), permissionSequence: this.agentPromptPermissionSequenceByPtyId.get(ptyId) ?? 0, workingSequence: lifecycle?.workingSequence ?? 0, + // Why: hook status is the only turn-start signal agents without title coverage have, and it + // reaches here without the window-gated synthetic title frame (#16095). Anchored on + // stateStartedAt, not updatedAt — same-state tool/prompt pings refresh updatedAt and would + // otherwise pass off an in-progress turn as a new one. + explicitWorkingStartedAt: explicit?.status === 'working' ? explicit.stateStartedAt : null, + outputSequence: this.getPtyOutputSequence(ptyId), status } } + private getPtyAgent(ptyId: string): TuiAgent | null { + const pty = this.ptysById.get(ptyId) + return pty?.launchAgent ?? pty?.foregroundAgent ?? null + } + private assertAgentPromptPermissionSafe( baseline: AgentPromptActivity, current: AgentPromptActivity @@ -20114,9 +20134,7 @@ export class OrcaRuntimeService { wait: () => Promise dispose: () => void } | null { - const pty = this.ptysById.get(ptyId) - const agent = pty?.launchAgent ?? pty?.foregroundAgent - if (!isTerminalSendSettlementAgent(agent)) { + if (!isTerminalSendSettlementAgent(this.getPtyAgent(ptyId))) { return null } let armed = false diff --git a/src/main/runtime/orchestration/coordinator-dispatch-unobserved-prompt.test.ts b/src/main/runtime/orchestration/coordinator-dispatch-unobserved-prompt.test.ts new file mode 100644 index 00000000000..5f99e283de3 --- /dev/null +++ b/src/main/runtime/orchestration/coordinator-dispatch-unobserved-prompt.test.ts @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { OrchestrationDb } from './db' +import type { CoordinatorRuntime } from './coordinator-runtime-contract' +import { dispatchTaskToWorker } from './coordinator-task-dispatch' + +const WORKER_PANE_KEY = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +let db: OrchestrationDb + +function createRuntime(promptError: Error | null): CoordinatorRuntime & { prompts: string[] } { + const prompts: string[] = [] + return { + prompts, + async sendTerminalAgentPrompt(_handle: string, prompt: string) { + prompts.push(prompt) + if (promptError) { + throw promptError + } + return { accepted: true } + }, + async listTerminals() { + return { terminals: [] } + }, + async createTerminal() { + return { handle: 'term_a', worktreeId: 'wt1' } + }, + async waitForTerminal(handle: string) { + return { handle, condition: 'exit' } + }, + async probeWorktreeDrift() { + return null + }, + getTerminalPaneKey() { + return WORKER_PANE_KEY + }, + getOrchestrationDispatchAuthority() { + return { + paneKey: WORKER_PANE_KEY, + processIncarnation: 'incarnation-1', + launchTokenHash: null + } + } + } +} + +async function dispatch( + runtime: CoordinatorRuntime, + taskId: string, + logs: string[] +): Promise { + return dispatchTaskToWorker({ + db, + runtime, + task: db.getTask(taskId)!, + targetHandle: 'term_a', + nestedWorkerMaxDepth: Number.MAX_SAFE_INTEGER, + baseDrift: null, + coordinatorHandle: 'coord', + worktree: undefined, + onLog: (message) => logs.push(message), + onCircuitBroken: () => undefined + }) +} + +describe('coordinator dispatch with an unobserved prompt', () => { + afterEach(() => db?.close()) + + it('never re-pastes a preamble whose turn start was not observed', async () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'do the work' }) + const runtime = createRuntime(new Error('agent_prompt_stalled')) + const logs: string[] = [] + + const result = await dispatch(runtime, task.id, logs) + + expect(result).toBe('dispatched-unobserved') + expect(runtime.prompts).toHaveLength(1) + // The task stays dispatched, so the next coordinator tick cannot pick it up again. + expect(db.getTask(task.id)?.status).toBe('dispatched') + const ctx = db.getDispatchContext(task.id) + expect(ctx).toMatchObject({ + status: 'dispatched', + failure_count: 0, + capability_revoked_at: null + }) + expect(db.listTasks({ status: 'ready' })).toEqual([]) + expect(logs.join('\n')).toContain('turn start was not observed') + }) + + it('lets a late worker report settle a dispatch whose prompt was unobserved', async () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'do the work' }) + await dispatch(createRuntime(new Error('agent_prompt_stalled')), task.id, []) + const dispatchId = db.getDispatchContext(task.id)!.id + const minted = db.mintDispatchCapability({ + dispatchId, + paneKey: WORKER_PANE_KEY, + processIncarnation: 'incarnation-1' + }) + + expect( + db.verifyDispatchCapability({ + dispatchId, + capability: minted, + paneKey: WORKER_PANE_KEY, + processIncarnation: 'incarnation-1' + }) + ).toEqual({ valid: true }) + expect( + db.settleWorkerReport({ + taskId: task.id, + dispatchId, + outcome: 'succeeded', + result: 'done the work' + }) + ).toEqual({ action: 'settled', outcome: 'succeeded', duplicate: false }) + expect(db.getTask(task.id)).toMatchObject({ status: 'completed', result: 'done the work' }) + expect(db.getDispatchContextById(dispatchId)?.status).toBe('completed') + }) + + it('still fails the dispatch when the prompt was never delivered', async () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'do the work' }) + const runtime = createRuntime(new Error('terminal_not_writable')) + + await expect(dispatch(runtime, task.id, [])).rejects.toThrow('terminal_not_writable') + + expect(db.getTask(task.id)?.status).toBe('ready') + expect(db.getDispatchContext(task.id)).toMatchObject({ + status: 'failed', + failure_count: 1, + last_failure: 'terminal_not_writable' + }) + }) +}) diff --git a/src/main/runtime/orchestration/coordinator-task-dispatch.ts b/src/main/runtime/orchestration/coordinator-task-dispatch.ts index 8dae105726a..685552cc2e9 100644 --- a/src/main/runtime/orchestration/coordinator-task-dispatch.ts +++ b/src/main/runtime/orchestration/coordinator-task-dispatch.ts @@ -7,8 +7,10 @@ import { DISPATCH_STALE_THRESHOLD, parseAllowStaleBaseFromSpec } from './coordinator-stale-base-flag' +import { isAgentPromptStalledError } from '../agent-prompt-submission-verification' -export type TaskDispatchResult = 'dispatched' | 'stale-base-refused' +/** `dispatched-unobserved`: the preamble landed but the worker's turn start was never observed. */ +export type TaskDispatchResult = 'dispatched' | 'dispatched-unobserved' | 'stale-base-refused' // Why: 10 min = documented heartbeat cadence (5 min) × 2, so one missed heartbeat is the earliest a dispatch can look stale. const HUNG_THRESHOLD_MS = 10 * 60 * 1000 @@ -136,6 +138,17 @@ export async function dispatchTaskToWorker(params: { try { await runtime.sendTerminalAgentPrompt(targetHandle, preamble + gateContext) } catch (err) { + // Why (#16095): Enter is written before submission is verified, so a stall is only ever an + // unobserved turn start — never proof the preamble is missing. Failing here would reset the + // task to 'ready' and paste the whole preamble a second time into a worker already running it, + // and would revoke the capability its worker_done needs. + if (isAgentPromptStalledError(err)) { + onLog( + `Dispatched task ${task.id} to ${targetHandle}; turn start was not observed. ` + + `The preamble is already in the pane, so the dispatch stays active instead of being resent.` + ) + return 'dispatched-unobserved' + } const updated = db.failDispatch(dispatch.id, err instanceof Error ? err.message : String(err)) if (updated?.status === 'circuit_broken') { params.onCircuitBroken(task.id) diff --git a/src/main/runtime/orchestration/db/dispatch-context/worker-report-settlement.ts b/src/main/runtime/orchestration/db/dispatch-context/worker-report-settlement.ts index 734c25b1264..9d6b643c070 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/worker-report-settlement.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/worker-report-settlement.ts @@ -1,5 +1,6 @@ import type { WorkerReportOutcome, WorkerReportSettlement } from '../../types' import type { OrchestrationDb } from '../orchestration-db' +import { AGENT_PROMPT_STALLED_ERROR } from '../../../agent-prompt-submission-verification' import { settleActiveDispatchesForTask } from './dispatch-completion' import { getActiveDispatchForTask } from './task-dispatch-reconciliation' @@ -54,10 +55,26 @@ export function settleWorkerReportInTransaction( const expectedDispatchStatus = params.outcome === 'succeeded' ? 'completed' : 'failed' const expectedTaskStatus = params.outcome === 'succeeded' ? 'completed' : 'failed' - if (dispatch.status === expectedDispatchStatus && task.status === expectedTaskStatus) { + // Why (#16095): worker-start records a stalled prompt as failed, but the preamble was written + // before verification ran — the worker may have been executing it the whole time. Its own report + // is first-hand evidence and must be able to correct that record instead of being thrown away. + // Checked before the duplicate short-circuit: a `failed` report lands on the very statuses that + // short-circuit reads as already settled, dropping the worker's real cause and result body. + const settledByUnobservedPrompt = + dispatch.status === 'failed' && + dispatch.last_failure === AGENT_PROMPT_STALLED_ERROR && + task.status === 'failed' + if ( + !settledByUnobservedPrompt && + dispatch.status === expectedDispatchStatus && + task.status === expectedTaskStatus + ) { return { action: 'settled', outcome: params.outcome, duplicate: true } } - if (dispatch.status !== 'dispatched' || task.status !== 'dispatched') { + const previous = settledByUnobservedPrompt + ? { status: 'failed', workerState: 'failed' } + : { status: 'dispatched', workerState: 'ready' } + if (dispatch.status !== previous.status || task.status !== previous.status) { return { action: 'rejected', code: 'inactive_dispatch', @@ -105,16 +122,22 @@ export function settleWorkerReportInTransaction( SET status = ?, completed_at = datetime('now'), last_failure = CASE WHEN ? = 'failed' THEN ? ELSE last_failure END, capability_revoked_at = COALESCE(capability_revoked_at, datetime('now')) - WHERE id = ? AND status = 'dispatched'` + WHERE id = ? AND status = ?` + ) + .run( + expectedDispatchStatus, + expectedDispatchStatus, + params.result, + params.dispatchId, + previous.status ) - .run(expectedDispatchStatus, expectedDispatchStatus, params.result, params.dispatchId) const taskUpdate = this.db .prepare( `UPDATE tasks SET status = ?, result = ?, completed_at = datetime('now') - WHERE id = ? AND status = 'dispatched'` + WHERE id = ? AND status = ?` ) - .run(expectedTaskStatus, params.result, params.taskId) + .run(expectedTaskStatus, params.result, params.taskId, previous.status) if (dispatchUpdate.changes !== 1 || taskUpdate.changes !== 1) { this.db.exec('ROLLBACK TO settle_worker_report') this.db.exec('RELEASE settle_worker_report') @@ -128,9 +151,13 @@ export function settleWorkerReportInTransaction( .prepare( `UPDATE worker_dispatches SET state = ?, stage = 'settled', updated_at = datetime('now') - WHERE dispatch_id = ? AND state = 'ready'` + WHERE dispatch_id = ? AND state = ?` + ) + .run( + params.outcome === 'succeeded' ? 'succeeded' : 'failed', + params.dispatchId, + previous.workerState ) - .run(params.outcome === 'succeeded' ? 'succeeded' : 'failed', params.dispatchId) settleActiveDispatchesForTask( this, params.taskId, diff --git a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-outcome.ts b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-outcome.ts index d9a8b23a39b..3ff796c097d 100644 --- a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-outcome.ts +++ b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-outcome.ts @@ -37,7 +37,11 @@ export function failWorkerStart( this: OrchestrationDb, dispatchId: string, stage: string, - reason: string + reason: string, + // Why (#16095): revocation exists to stop a worker acting on a dispatch that never landed. A + // prompt whose turn start went unobserved provably landed, so its worker keeps the authority its + // own report needs. + options: { retainCapability?: boolean } = {} ): WorkerDispatchRow { this.db.exec('BEGIN IMMEDIATE') try { @@ -50,10 +54,11 @@ export function failWorkerStart( .prepare( `UPDATE dispatch_contexts SET status = 'failed', last_failure = ?, completed_at = datetime('now'), - capability_revoked_at = COALESCE(capability_revoked_at, datetime('now')) + capability_revoked_at = CASE WHEN ? = 1 THEN capability_revoked_at + ELSE COALESCE(capability_revoked_at, datetime('now')) END WHERE id = ?` ) - .run(reason, dispatchId) + .run(reason, options.retainCapability ? 1 : 0, dispatchId) this.db .prepare( `UPDATE worker_dispatches diff --git a/src/main/runtime/orchestration/worker-start-unobserved-prompt-settlement.test.ts b/src/main/runtime/orchestration/worker-start-unobserved-prompt-settlement.test.ts new file mode 100644 index 00000000000..35cfb94b74c --- /dev/null +++ b/src/main/runtime/orchestration/worker-start-unobserved-prompt-settlement.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { OrchestrationDb } from './db' + +const WORKER_PANE_KEY = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const INCARNATION = 'runtime_test:term_worker:1' +let db: OrchestrationDb + +function startWorker(spec: string): { taskId: string; dispatchId: string; capability: string } { + const task = db.createTask({ spec }) + const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) + const capability = db.mintDispatchCapability({ + dispatchId: started.dispatch.id, + paneKey: WORKER_PANE_KEY, + processIncarnation: INCARNATION + }) + return { taskId: task.id, dispatchId: started.dispatch.id, capability } +} + +function verify(dispatchId: string, capability: string): { valid: boolean; reason?: string } { + return db.verifyDispatchCapability({ + dispatchId, + capability, + paneKey: WORKER_PANE_KEY, + processIncarnation: INCARNATION + }) +} + +describe('worker start settled by an unobserved prompt', () => { + afterEach(() => db?.close()) + + it('keeps the capability and lets the worker report correct the record', () => { + db = new OrchestrationDb(':memory:') + const { taskId, dispatchId, capability } = startWorker('run to completion') + + db.failWorkerStart(dispatchId, 'dispatch_input', 'agent_prompt_stalled', { + retainCapability: true + }) + + expect(db.getDispatchContextById(dispatchId)).toMatchObject({ + status: 'failed', + last_failure: 'agent_prompt_stalled', + capability_revoked_at: null + }) + expect(verify(dispatchId, capability)).toEqual({ valid: true }) + + expect( + db.settleWorkerReport({ + taskId, + dispatchId, + outcome: 'succeeded', + result: 'done the work' + }) + ).toEqual({ action: 'settled', outcome: 'succeeded', duplicate: false }) + expect(db.getTask(taskId)).toMatchObject({ status: 'completed', result: 'done the work' }) + expect(db.getDispatchContextById(dispatchId)?.status).toBe('completed') + expect(db.getWorkerDispatch(dispatchId)).toMatchObject({ state: 'succeeded', stage: 'settled' }) + }) + + it('revokes and stays settled when the start failed for any other cause', () => { + db = new OrchestrationDb(':memory:') + const { taskId, dispatchId, capability } = startWorker('never became ready') + + db.failWorkerStart(dispatchId, 'agent_readiness', 'Agent did not become ready (idle).') + + expect(db.getDispatchContextById(dispatchId)?.capability_revoked_at).toEqual(expect.any(String)) + expect(verify(dispatchId, capability).valid).toBe(false) + expect( + db.settleWorkerReport({ taskId, dispatchId, outcome: 'succeeded', result: 'done' }) + ).toMatchObject({ action: 'rejected', code: 'inactive_dispatch' }) + expect(db.getTask(taskId)?.status).toBe('failed') + }) + + it('lets a failure report replace the unobserved-prompt cause with the real one', () => { + db = new OrchestrationDb(':memory:') + const { taskId, dispatchId } = startWorker('reports its own failure') + + db.failWorkerStart(dispatchId, 'dispatch_input', 'agent_prompt_stalled', { + retainCapability: true + }) + + expect( + db.settleWorkerReport({ taskId, dispatchId, outcome: 'failed', result: 'build broke on X' }) + ).toEqual({ action: 'settled', outcome: 'failed', duplicate: false }) + expect(db.getTask(taskId)).toMatchObject({ status: 'failed', result: 'build broke on X' }) + expect(db.getDispatchContextById(dispatchId)).toMatchObject({ + status: 'failed', + last_failure: 'build broke on X' + }) + expect(db.getWorkerDispatch(dispatchId)).toMatchObject({ state: 'failed', stage: 'settled' }) + + // The stalled cause is gone, so a repeat report has nothing left to correct. + expect( + db.settleWorkerReport({ taskId, dispatchId, outcome: 'failed', result: 'again' }) + ).toEqual({ action: 'settled', outcome: 'failed', duplicate: true }) + expect(db.getTask(taskId)?.result).toBe('build broke on X') + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-outcome-classification.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-outcome-classification.test.ts new file mode 100644 index 00000000000..112df925f3e --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-outcome-classification.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { isUnknownWorkerStartOutcome } from './orchestration-worker-topology' + +describe('worker start outcome classification', () => { + it('treats an explicit operation_unknown code as unknown at any stage', () => { + const error = Object.assign(new Error('relay dropped'), { code: 'operation_unknown' }) + + expect(isUnknownWorkerStartOutcome(error, 'dispatch_input')).toBe(true) + expect(isUnknownWorkerStartOutcome(error, 'worktree_create')).toBe(true) + }) + + it('treats a lost connection during worktree create as unknown', () => { + expect(isUnknownWorkerStartOutcome(new Error('connection reset'), 'worktree_create')).toBe(true) + expect(isUnknownWorkerStartOutcome(new Error('request timed out'), 'worktree_create')).toBe( + true + ) + }) + + it('keeps a definite failure definite', () => { + expect(isUnknownWorkerStartOutcome(new Error('connection reset'), 'dispatch_input')).toBe(false) + expect(isUnknownWorkerStartOutcome(new Error('worktree exists'), 'worktree_create')).toBe(false) + }) + + // Why: a stalled prompt still reports a definite failure to the caller — the correction path is + // the worker's own report, which keeps its capability and can re-settle the dispatch (see + // worker-start-unobserved-prompt-settlement.test.ts), not an outcome_unknown receipt. + it('does not class a stalled dispatch prompt as unknown', () => { + expect(isUnknownWorkerStartOutcome(new Error('agent_prompt_stalled'), 'dispatch_input')).toBe( + false + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts index 89455f9cc0a..826eb57ea55 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts @@ -254,10 +254,12 @@ describe('orchestration worker-start prompt contract', () => { expect(harness.writes.filter((data) => data === '\r')).toHaveLength(1) const persisted = reopenPromptContractDb(harness) expect(persisted.getTask(harness.taskId)?.status).toBe('failed') + // Why (#16095): the receipt still reports the failure, but Enter was written before it was + // verified — so the capability survives and the worker's own report can correct the record. expect(persisted.getDispatchContextById(dispatchId)).toMatchObject({ status: 'failed', last_failure: 'agent_prompt_stalled', - capability_revoked_at: expect.any(String) + capability_revoked_at: null }) expect(persisted.getWorkerDispatch(dispatchId)).toMatchObject({ state: 'failed', diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts index e58f9fff01e..cde2ea9a22f 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts @@ -1,4 +1,5 @@ import type { OrchestrationDb } from '../../orchestration/db' +import { isAgentPromptStalledError } from '../../agent-prompt-submission-verification' import { isUnknownWorkerStartOutcome, type WorkerSetupReceipt @@ -19,7 +20,11 @@ export function failWorkerStartWithReceipt(args: { const unknown = isUnknownWorkerStartOutcome(args.error, args.failedStage) const worker = unknown ? args.db.markWorkerStartUnknown(args.dispatchId, args.failedStage, reason) - : args.db.failWorkerStart(args.dispatchId, args.failedStage, reason) + : args.db.failWorkerStart(args.dispatchId, args.failedStage, reason, { + // Why (#16095): the preamble is written before submission is verified, so a stalled + // verdict never means the worker lacks its task — keep the authority its report needs. + retainCapability: isAgentPromptStalledError(args.error) + }) return { runId: args.runId, taskId: args.taskId, From 26721bd6328103e09b79faba1d40bb124db13536 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:44:55 -0700 Subject: [PATCH 030/152] fix(codex): stop blocking the main thread on trust grants (#16441) (#16594) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(codex): stop blocking the main thread on trust grants (#16441) Codex hook trust was granted by blocking the Electron main thread on `spawnSync` of a bundled ELECTRON_RUN_AS_NODE entry for the whole app-server deadline: 15s native, 35s WSL, ~45s on the real-home path (rebase inspect + repair + grant). Cold start and every Codex pane launch showed "Not Responding"; the reported event-loop gap was 15,049 ms. The subprocess only ever existed to donate an event loop to a deliberately blocked parent — `runCodexHookTrustGrantSession` was already the real async implementation. Make the callers async and the fork is unnecessary, so the bridge, the forked entry and its envelope are deleted along with their build/knip/tsconfig registrations. The CLI `agent hooks prepare-codex` handler is already async, so it awaits the in-process session and saves a process spawn per managed-home shell. `resolveCodexTrustGrantHost` is async too; the WSL identity probe moves from `execFileSync` to `runProcess`, dropping that file from the child-process import allowlist. Status reads keep a synchronous native-only stamp path. Two invariants that held only because the lane blocked: - Overlapping capability probes were impossible by construction. `GitCapabilityCache`'s dedupe engine is extracted to a shared `CapabilityProbeCache` and `CodexAppServerCapabilityCache` now inherits it, so concurrent launches against a cold host share one app-server session instead of one each. - Two grants on one `config.toml` could not interleave capture and restore. A reentrant per-file lane now serializes the whole install sequence (managed, WSL runtime, real-home ensure, legacy sweep) and the grant and rebase inside it. Cold-start work moves off the critical path: retained-home reconciliation (N sequential sessions) is fire-and-forget behind the daemon provider, and the startup real-home ensure chains into managed hook reconciliation instead of blocking app init. Every preserved semantic is unchanged: never throws, the ORCA_DISABLE_CODEX_TRUST_RPC kill switch, ledger hits, backfill-pending and cooldown fallbacks, config rollback on every failure path, pre-grant self-computed trust removal, the verify-failure taxonomy, diagnostics and telemetry. * fix(codex): widen the trust-config lane to every config.toml writer Review follow-ups on #16441's async trust grant: - `markCodexProjectTrusted` now runs inside the runtime+system config.toml lanes, so a project-trust write can no longer land inside a hook grant's capture->restore window and be silently reverted. Its callers await it. - `install`/`refreshRuntimeUserHooks`/`remove` hold the system config.toml lane as well as the runtime one — they promote approvals into ~/.codex/config.toml and mirror it back. Lock order is runtime-before-system everywhere. - The real-home ensure chain resumes after a rejection instead of returning the same rejected promise to every later pane launch, and resolving the real home is now inside the module's never-throws boundary. - `buildSpawnEnv` awaits inside a cancelable pending-spawn registration, so shutdown during the (now long) env build stops the PTY from launching. `prepareLocalPtySpawn` generalizes into `awaitCancelableLocalPtySpawn`. - CapabilityProbeCache drops the test-only `nowMs` passthrough; its probe backstop comment now describes what it actually guards. - Preflight is a plain async function; the trust dispatch in orca-runtime collapses into one `markWorkspaceTrustedForAgent`. * test(codex): exercise the trust-config lane under real concurrency The async grant makes two pane launches overlap for the first time. These drive the real modules end to end on real files: a rollback swallowing a sibling's grant, a markCodexProjectTrusted write landing inside a capture -> restore window, shared capability-probe dedupe on a cold host, the host-scoped transient cooldown, and reentrancy from inside an installer. Each was verified to fail against a deliberately broken implementation (lane removed, dedupe disabled, cooldown made global, reentrancy pass- through disabled). * test(codex): stop hook-service suites spawning the developer's real codex The forked grant bundle never existed under vitest, so the RPC lane was unreachable in tests on main. Running it in-process makes these suites spawn a real `codex app-server` when one is installed: 38 spawns and two failures in hook-service-runtime-trust-repair on a machine with codex, green in CI where there is none. Stand in for the missing binary so both environments exercise the same fallback lane. * docs(codex): scope the trust-RPC kill switch comment to what it actually gates The comment read as though the flag forces the fallback lane everywhere. It gates the managed grant only: the real-home rebase still runs its own inspect/repair app-server sessions when Orca's insertion shifts a user's hook positions, and never reads the flag. Verified by exercise, not by reading — with the flag set, both inspect-user-hook-trust and repair-user-hook-trust still ran. Pre-existing: main has no check there either, it just blocked the main thread while doing it. Widening the flag to cover the rebase is a follow-up; this only stops the comment promising something the constant does not do. --- .../build-plugins/plain-node-entry-guard.ts | 3 +- config/knip.json | 1 - config/tsconfig.cli.json | 4 +- electron.vite.config.ts | 6 - src/cli/handlers/agent-hooks.ts | 2 +- .../managed-agent-hook-controls.ts | 38 +- .../managed-agent-hook-registry.ts | 12 +- .../managed-hook-stdin-lifecycle.test.ts | 15 +- .../windows-hook-post-interpreter.test.ts | 13 +- src/main/agent-trust-presets.test.ts | 45 +- src/main/agent-trust-presets.ts | 17 +- ...home-service-per-account-migration.test.ts | 2 +- .../codex-app-server-capability-cache.test.ts | 201 ++++++--- .../codex-app-server-capability-cache.ts | 72 +-- .../codex/codex-app-server-client.test.ts | 107 ----- src/main/codex/codex-app-server-client.ts | 4 +- .../codex/codex-app-server-grant-bridge.ts | 144 ------ .../codex/codex-app-server-grant-entry.ts | 79 ---- .../codex/codex-app-server-grant-envelope.ts | 35 -- src/main/codex/codex-hook-trust-grant.test.ts | 256 +++++++---- src/main/codex/codex-hook-trust-grant.ts | 359 +++++++-------- .../codex/codex-managed-trust-grant-plan.ts | 90 ++++ .../codex-real-home-hook-install.test.ts | 240 +++++----- .../codex/codex-real-home-hook-install.ts | 159 ++----- src/main/codex/codex-real-home-hooks-json.ts | 115 +++++ ...dex-trust-config-concurrent-launch.test.ts | 410 ++++++++++++++++++ .../codex-trust-config-mutation-queue.test.ts | 111 +++++ .../codex-trust-config-mutation-queue.ts | 46 ++ src/main/codex/codex-trust-grant-host.test.ts | 65 ++- src/main/codex/codex-trust-grant-host.ts | 44 +- ...x-trust-grant-main-thread-boundary.test.ts | 60 +++ src/main/codex/codex-trust-grant-telemetry.ts | 7 +- .../codex-user-hook-trust-rebase.test.ts | 44 +- .../codex/codex-user-hook-trust-rebase.ts | 38 +- .../codex/hook-service-legacy-cleanup.test.ts | 28 +- .../hook-service-managed-install.test.ts | 84 +++- .../hook-service-runtime-trust-repair.test.ts | 32 +- src/main/codex/hook-service-test-harness.ts | 26 ++ .../codex/hook-service-trust-grant.test.ts | 62 +-- .../hook-service-user-hook-mirroring.test.ts | 52 +-- .../codex/hook-service-wsl-runtime.test.ts | 58 +-- src/main/codex/hook-service.ts | 135 ++++-- src/main/codex/hook-trust-promotion.test.ts | 86 ++-- .../managed-home-shell-preflight.test.ts | 16 +- .../codex/managed-home-shell-preflight.ts | 14 +- .../codex/retained-codex-hook-state.test.ts | 8 +- src/main/codex/retained-codex-hook-state.ts | 23 +- src/main/index.ts | 88 ++-- src/main/ipc/pty/host-env/codex-home.ts | 10 +- src/main/ipc/pty/host-env/codex-resume.ts | 8 +- src/main/ipc/pty/host-env/types.ts | 5 +- src/main/ipc/pty/ipc/spawn-env-codex.ts | 29 +- src/main/ipc/pty/ipc/spawn-types.ts | 4 +- src/main/ipc/pty/provider/local-configure.ts | 6 +- src/main/ipc/pty/runtime/controller-deps.ts | 4 +- src/main/ipc/pty/runtime/spawn-preflight.ts | 29 +- .../local-pty-provider-spawn-session.test.ts | 24 + src/main/providers/local-pty-provider.ts | 39 +- src/main/runtime/orca-runtime.ts | 65 +-- .../commit-message-agent-environment.ts | 6 +- src/shared/capability-probe-cache.ts | 128 ++++++ .../child-process-import-allowlist.txt | 2 - src/shared/git-capability-cache.ts | 107 +---- 63 files changed, 2464 insertions(+), 1558 deletions(-) delete mode 100644 src/main/codex/codex-app-server-grant-bridge.ts delete mode 100644 src/main/codex/codex-app-server-grant-entry.ts delete mode 100644 src/main/codex/codex-app-server-grant-envelope.ts create mode 100644 src/main/codex/codex-managed-trust-grant-plan.ts create mode 100644 src/main/codex/codex-real-home-hooks-json.ts create mode 100644 src/main/codex/codex-trust-config-concurrent-launch.test.ts create mode 100644 src/main/codex/codex-trust-config-mutation-queue.test.ts create mode 100644 src/main/codex/codex-trust-config-mutation-queue.ts create mode 100644 src/main/codex/codex-trust-grant-main-thread-boundary.test.ts create mode 100644 src/shared/capability-probe-cache.ts diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts index 441536e7edb..c87e2548a53 100644 --- a/config/build-plugins/plain-node-entry-guard.ts +++ b/config/build-plugins/plain-node-entry-guard.ts @@ -24,8 +24,7 @@ const PLAIN_NODE_ENTRY_NAMES = [ 'parcel-watcher-process-entry', 'computer-sidecar', 'wsl-transcript-fs-process-entry', - 'agent-hooks/managed-agent-hook-controls', - 'codex/codex-app-server-grant-entry' + 'agent-hooks/managed-agent-hook-controls' ] as const // Entries executed as worker threads of the main process. Electron's module is diff --git a/config/knip.json b/config/knip.json index 565fb68bc0a..9af0b8d1a74 100644 --- a/config/knip.json +++ b/config/knip.json @@ -14,7 +14,6 @@ "src/main/ports/port-scan-command-worker-entry.ts", "src/main/ipc/parcel-watcher-process-entry.ts", "src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts", - "src/main/codex/codex-app-server-grant-entry.ts", "src/main/agent-hooks/managed-agent-hook-controls.ts", "src/main/claude-accounts/keychain.ts", "src/renderer/src/main.tsx", diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index e5cf60dd5a7..eb278257159 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -30,8 +30,6 @@ "../src/main/codex/codex-app-server-capability-cache.ts", "../src/main/codex/codex-app-server-capability-signal.ts", "../src/main/codex/codex-app-server-client.ts", - "../src/main/codex/codex-app-server-grant-bridge.ts", - "../src/main/codex/codex-app-server-grant-envelope.ts", "../src/main/codex/codex-app-server-session.ts", "../src/main/codex/codex-config-mirror.ts", "../src/main/codex/codex-config-path-reference-rewrite.ts", @@ -40,6 +38,7 @@ "../src/main/codex/codex-config-settings-upsert.ts", "../src/main/codex/codex-home-paths.ts", "../src/main/codex/codex-managed-home-resource-copy-marker.ts", + "../src/main/codex/codex-managed-trust-grant-plan.ts", "../src/main/codex/codex-path-observation.ts", "../src/main/codex/codex-hook-identity.ts", "../src/main/codex/codex-hook-trust-grant.ts", @@ -48,6 +47,7 @@ "../src/main/codex/codex-state-db.ts", "../src/main/codex/codex-trust-identity.ts", "../src/main/codex/codex-trust-config-rollback.ts", + "../src/main/codex/codex-trust-config-mutation-queue.ts", "../src/main/codex/codex-trust-grant-telemetry.ts", "../src/main/codex/codex-trust-grant-host.ts", "../src/main/codex/codex-trust-grant-ledger.ts", diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 982677811c5..f81dbb93041 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -239,12 +239,6 @@ export const electronViteConfig: UserConfig = { 'main-thread-hang-watchdog-entry': resolve( 'src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts' ), - // Why: run under ELECTRON_RUN_AS_NODE while the caller blocks on - // spawnSync — codex app-server trust grants need a live event loop - // but must finish before a Codex pane launch proceeds. - 'codex/codex-app-server-grant-entry': resolve( - 'src/main/codex/codex-app-server-grant-entry.ts' - ), // Why: electron-vite cleans out/main in dev. The dev CLI imports // this path for `orca agent hooks ...`, so it must survive rebuilds. 'agent-hooks/managed-agent-hook-controls': resolve( diff --git a/src/cli/handlers/agent-hooks.ts b/src/cli/handlers/agent-hooks.ts index 7e21c4973a2..57419518d4b 100644 --- a/src/cli/handlers/agent-hooks.ts +++ b/src/cli/handlers/agent-hooks.ts @@ -208,7 +208,7 @@ async function setAgentHooksEnabled( export const AGENT_HOOK_HANDLERS: Record = { 'agent hooks prepare-codex': async ({ client }) => { const settings = await readHookSettings(client) - prepareManagedCodexHomeBeforeShellLaunch({ + await prepareManagedCodexHomeBeforeShellLaunch({ userDataPath: getDefaultUserDataPath(), hooksEnabled: settings.agentStatusHooksEnabled && !settings.disabledTuiAgents.includes('codex') diff --git a/src/main/agent-hooks/managed-agent-hook-controls.ts b/src/main/agent-hooks/managed-agent-hook-controls.ts index 043cc1bb1ea..64d426ae072 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.ts @@ -86,14 +86,14 @@ function selectedInstallers(options: InstallOptions): readonly ManagedAgentHookI return MANAGED_AGENT_HOOK_INSTALLERS.filter(([agent]) => allowed.has(agent)) } -function runInstaller( +async function runInstaller( entry: ManagedAgentHookInstaller, onInstallError: InstallOptions['onInstallError'], userInitiated?: boolean -): AgentHookInstallStatus { +): Promise { const [agent, install] = entry try { - return install({ userInitiated }) + return await install({ userInitiated }) } catch (error) { console.error(`[agent-hooks] Failed to install ${agent} managed hooks:`, error) try { @@ -177,22 +177,27 @@ export async function installManagedAgentHooks( ) continue } - results.push(runInstaller(entry, options.onInstallError, options.userInitiated)) + results.push(await runInstaller(entry, options.onInstallError, options.userInitiated)) } return results } -export function removeManagedAgentHooks(options: RemoveOptions = {}): AgentHookInstallStatus[] { +export async function removeManagedAgentHooks( + options: RemoveOptions = {} +): Promise { const allowed = options.agents ? new Set(options.agents) : null - return MANAGED_AGENT_HOOK_REMOVERS.filter( - ([agent]) => allowed === null || allowed.has(agent) - ).map(([agent, remove]) => { - try { - return remove() - } catch (error) { - return errorStatus(agent, error) + const results: AgentHookInstallStatus[] = [] + for (const [agent, remove] of MANAGED_AGENT_HOOK_REMOVERS) { + if (allowed !== null && !allowed.has(agent)) { + continue } - }) + try { + results.push(await remove()) + } catch (error) { + results.push(errorStatus(agent, error)) + } + } + return results } export async function removeManagedAgentHooksAsync( @@ -228,7 +233,7 @@ export async function applyAgentStatusHooksEnabled( options: InstallOptions = {} ): Promise { if (!enabled) { - return removeManagedAgentHooks() + return await removeManagedAgentHooks() } const disabled = normalizeDisabledTuiAgents(settings?.disabledTuiAgents).filter( isManagedAgentHookTarget @@ -241,7 +246,10 @@ export async function applyAgentStatusHooksEnabled( return installed } const removed = new Map( - removeManagedAgentHooks({ agents: disabledToRemove }).map((status) => [status.agent, status]) + (await removeManagedAgentHooks({ agents: disabledToRemove })).map((status) => [ + status.agent, + status + ]) ) return installed.map((status) => removed.get(status.agent) ?? status) } diff --git a/src/main/agent-hooks/managed-agent-hook-registry.ts b/src/main/agent-hooks/managed-agent-hook-registry.ts index c94d367bd79..5c462f1794a 100644 --- a/src/main/agent-hooks/managed-agent-hook-registry.ts +++ b/src/main/agent-hooks/managed-agent-hook-registry.ts @@ -15,13 +15,21 @@ import { hermesHookService } from '../hermes/hook-service' import { kimiHookService } from '../kimi/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' +// Why (#16441): Codex's installer awaits a codex app-server trust-grant session +// instead of blocking the main thread on spawnSync. Widening the tuple keeps the +// other thirteen agent services synchronous — the shared loop already awaits. export type ManagedAgentHookInstallOptions = { userInitiated?: boolean } export type ManagedAgentHookInstaller = readonly [ HookInstallAgent, - (options?: ManagedAgentHookInstallOptions) => AgentHookInstallStatus + ( + options?: ManagedAgentHookInstallOptions + ) => AgentHookInstallStatus | Promise ] export type ManagedAgentHookScriptRefresher = readonly [HookInstallAgent, () => Promise] -export type ManagedAgentHookRemover = readonly [HookInstallAgent, () => AgentHookInstallStatus] +export type ManagedAgentHookRemover = readonly [ + HookInstallAgent, + () => AgentHookInstallStatus | Promise +] export type ManagedAgentHookAsyncRemover = readonly [ HookInstallAgent, () => Promise diff --git a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts index c52d6656013..40237141d24 100644 --- a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts +++ b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts @@ -209,11 +209,14 @@ async function generatePosixScripts(): Promise> { return scripts } -function withPlatform(platform: NodeJS.Platform, run: () => T): T { +// Why: the Codex installer awaits an app-server trust-grant session, so the +// override has to stay pinned across the await instead of being restored by a +// synchronous `finally` while the install is still running. +async function withPlatform(platform: NodeJS.Platform, run: () => T | Promise): Promise { const original = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: platform }) try { - return run() + return await run() } finally { if (original) { Object.defineProperty(process, 'platform', original) @@ -222,7 +225,7 @@ function withPlatform(platform: NodeJS.Platform, run: () => T): T { } describe('Windows managed hook stdin structure', () => { - it('exits immediately when Orca env is missing and keeps drain for other failures', () => { + it('exits immediately when Orca env is missing and keeps drain for other failures', async () => { const home = mkdtempSync(join(tmpdir(), 'orca-hook-stdin-windows-')) homedirMock.mockReturnValue(home) const previousGrokHome = process.env.GROK_HOME @@ -230,9 +233,9 @@ describe('Windows managed hook stdin structure', () => { delete process.env.GROK_HOME delete process.env.KIMI_CODE_HOME try { - withPlatform('win32', () => { + await withPlatform('win32', async () => { for (const entry of LOCAL_INSTALLERS) { - expect(entry.install().state, `${entry.agent} install status`).toBe('installed') + expect((await entry.install()).state, `${entry.agent} install status`).toBe('installed') } }) const hooksDir = join(home, '.orca', 'agent-hooks') @@ -317,7 +320,7 @@ describe('Windows managed hook stdin structure', () => { try { const gitBash = findGitBash() for (const entry of LOCAL_INSTALLERS) { - expect(entry.install().state, `${entry.agent} install status`).toBe('installed') + expect((await entry.install()).state, `${entry.agent} install status`).toBe('installed') } const hooksDir = join(home, '.orca', 'agent-hooks') const mainScripts = readdirSync(hooksDir).filter( diff --git a/src/main/agent-hooks/windows-hook-post-interpreter.test.ts b/src/main/agent-hooks/windows-hook-post-interpreter.test.ts index 0e79c125c2e..09377f3adf5 100644 --- a/src/main/agent-hooks/windows-hook-post-interpreter.test.ts +++ b/src/main/agent-hooks/windows-hook-post-interpreter.test.ts @@ -56,11 +56,14 @@ const BATCH_SCRIPT_INSTALLERS = [ { agent: 'grok', install: () => new GrokHookService().install() } ] as const -function withPlatform(platform: NodeJS.Platform, run: () => T): T { +// Why: the Codex installer awaits an app-server trust-grant session, so the +// override has to stay pinned across the await instead of being restored by a +// synchronous `finally` while the install is still running. +async function withPlatform(platform: NodeJS.Platform, run: () => T | Promise): Promise { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: platform }) try { - return run() + return await run() } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -93,10 +96,10 @@ describe('Windows managed hook post interpreter', () => { home = '' }) - it('posts through curl.exe from every managed batch script, spawning no interpreter', () => { - const scripts = withPlatform('win32', () => { + it('posts through curl.exe from every managed batch script, spawning no interpreter', async () => { + const scripts = await withPlatform('win32', async () => { for (const entry of BATCH_SCRIPT_INSTALLERS) { - expect(entry.install().state, `${entry.agent} install status`).toBe('installed') + expect((await entry.install()).state, `${entry.agent} install status`).toBe('installed') } const hooksDir = join(home, '.orca', 'agent-hooks') return readdirSync(hooksDir) diff --git a/src/main/agent-trust-presets.test.ts b/src/main/agent-trust-presets.test.ts index 2a979c60e27..f5377be86c4 100644 --- a/src/main/agent-trust-presets.test.ts +++ b/src/main/agent-trust-presets.test.ts @@ -40,6 +40,8 @@ vi.mock('node:os', async () => { const { markCodexProjectTrusted, markCopilotFolderTrusted, markCursorWorkspaceTrusted } = await import('./agent-trust-presets') +const { runExclusivelyForCodexTrustConfig } = + await import('./codex/codex-trust-config-mutation-queue') beforeEach(() => { testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-trust-presets-')) @@ -137,7 +139,32 @@ describe('markCopilotFolderTrusted', () => { }) describe('markCodexProjectTrusted', () => { - it('trusts the main repository root for a linked worktree without reading commondir', () => { + // Why (#16441): a hook install/grant holds this file across an awaited + // app-server session; an unqueued write here lands inside its + // capture->restore window and is silently reverted. + it('queues behind an in-flight Codex trust-config mutation', async () => { + const workspace = mkdtempSync(join(tmpdir(), 'orca-codex-ws-')) + const configPath = join(testState.fakeHomeDir, '.codex', 'config.toml') + let releaseGrant!: () => void + const grantHoldingTheFile = new Promise((resolve) => { + releaseGrant = resolve + }) + try { + const held = runExclusivelyForCodexTrustConfig(configPath, () => grantHoldingTheFile) + const marked = markCodexProjectTrusted(workspace) + await Promise.resolve() + expect(existsSync(configPath)).toBe(false) + + releaseGrant() + await held + await marked + expect(readFileSync(configPath, 'utf-8')).toContain('trust_level = "trusted"') + } finally { + rmSync(workspace, { recursive: true, force: true }) + } + }) + + it('trusts the main repository root for a linked worktree without reading commondir', async () => { const fixtureRoot = mkdtempSync(join(tmpdir(), 'orca-codex-linked-ws-')) const repository = join(fixtureRoot, 'repo') const workspace = join(fixtureRoot, 'worktrees', 'feature') @@ -148,7 +175,7 @@ describe('markCodexProjectTrusted', () => { writeFileSync(join(workspace, '.git'), `gitdir: ${worktreeGitDir}\n`, 'utf-8') writeFileSync(join(worktreeGitDir, 'gitdir'), join(workspace, '.git'), 'utf-8') - markCodexProjectTrusted(workspace) + await markCodexProjectTrusted(workspace) const repositoryRoot = realpathSync.native(repository) const workspaceRoot = realpathSync.native(workspace) @@ -171,7 +198,7 @@ describe('markCodexProjectTrusted', () => { } }) - it('does not broaden trust through arbitrary or adversarial Git metadata', () => { + it('does not broaden trust through arbitrary or adversarial Git metadata', async () => { const fixtureRoot = mkdtempSync(join(tmpdir(), 'orca-codex-untrusted-gitdir-')) const workspace = join(fixtureRoot, 'workspace') const arbitraryGitDir = join(fixtureRoot, 'metadata', 'feature') @@ -183,12 +210,12 @@ describe('markCodexProjectTrusted', () => { writeFileSync(join(workspace, '.git'), `gitdir: ${arbitraryGitDir}\n`, 'utf-8') writeFileSync(join(arbitraryGitDir, 'commondir'), join(unrelatedRoot, '.git'), 'utf-8') - markCodexProjectTrusted(workspace) + await markCodexProjectTrusted(workspace) const structuredGitDir = join(unrelatedRoot, '.git', 'worktrees', 'feature') mkdirSync(structuredGitDir, { recursive: true }) writeFileSync(join(workspace, '.git'), `gitdir: ${structuredGitDir}\n`, 'utf-8') writeFileSync(join(structuredGitDir, 'gitdir'), join(unrelatedRoot, '.git'), 'utf-8') - markCodexProjectTrusted(workspace) + await markCodexProjectTrusted(workspace) const written = readFileSync(join(testState.fakeHomeDir, '.codex', 'config.toml'), 'utf-8') expect(written).toContain( @@ -202,11 +229,11 @@ describe('markCodexProjectTrusted', () => { } }) - it('writes ~/.codex/config.toml with the project marked trusted', () => { + it('writes ~/.codex/config.toml with the project marked trusted', async () => { const workspace = mkdtempSync(join(tmpdir(), 'orca-codex-ws-')) try { const realpath = realpathSync.native(workspace) - markCodexProjectTrusted(workspace) + await markCodexProjectTrusted(workspace) const configPath = join(testState.fakeHomeDir, '.codex', 'config.toml') const runtimeConfigPath = join( testState.userDataDir, @@ -227,7 +254,7 @@ describe('markCodexProjectTrusted', () => { } }) - it('preserves existing config keys and updates an existing project block', () => { + it('preserves existing config keys and updates an existing project block', async () => { const workspace = mkdtempSync(join(tmpdir(), 'orca-codex-ws-')) const realpath = realpathSync.native(workspace) try { @@ -260,7 +287,7 @@ describe('markCodexProjectTrusted', () => { 'utf-8' ) - markCodexProjectTrusted(workspace) + await markCodexProjectTrusted(workspace) const written = readFileSync(join(codexDir, 'config.toml'), 'utf-8') const runtimeWritten = readFileSync(join(runtimeCodexDir, 'config.toml'), 'utf-8') diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts index 3b441d6d1fe..16c728eba55 100644 --- a/src/main/agent-trust-presets.ts +++ b/src/main/agent-trust-presets.ts @@ -4,6 +4,7 @@ import { basename, dirname, join, resolve } from 'node:path' import { writeFileAtomically } from './codex-accounts/fs-utils' import { getOrcaManagedCodexHomePath } from './codex/codex-home-paths' import { upsertProjectTrustLevel } from './codex/config-toml-trust' +import { runExclusivelyForCodexTrustConfig } from './codex/codex-trust-config-mutation-queue' export type AgentTrustPreset = 'cursor' | 'copilot' | 'codex' @@ -108,13 +109,21 @@ export function markCopilotFolderTrusted(workspacePath: string): void { * Verified against codex-rs/tui/src/onboarding/trust_directory.rs and * codex-rs/core/src/config/config_tests.rs in the Codex CLI source. */ -export function markCodexProjectTrusted(workspacePath: string): void { +export function markCodexProjectTrusted(workspacePath: string): Promise { const absPath = resolveCodexProjectTrustRoot(workspacePath) - const configPath = join(homedir(), '.codex', 'config.toml') - upsertProjectTrustLevel(configPath, absPath, 'trusted') + const systemTomlPath = join(homedir(), '.codex', 'config.toml') // Why: Orca-launched Codex runs with an Orca-owned CODEX_HOME, so the trust // preset must also update the runtime config Codex will actually read. - upsertProjectTrustLevel(join(getOrcaManagedCodexHomePath(), 'config.toml'), absPath, 'trusted') + const runtimeTomlPath = join(getOrcaManagedCodexHomePath(), 'config.toml') + // Why (#16441): hook installs now await a codex app-server grant, so an + // unqueued write here can land inside their capture->restore window and be + // reverted. Same runtime-before-system lock order the installer takes. + return runExclusivelyForCodexTrustConfig(runtimeTomlPath, () => + runExclusivelyForCodexTrustConfig(systemTomlPath, async () => { + upsertProjectTrustLevel(systemTomlPath, absPath, 'trusted') + upsertProjectTrustLevel(runtimeTomlPath, absPath, 'trusted') + }) + ) } function resolveCodexProjectTrustRoot(workspacePath: string): string { diff --git a/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts b/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts index 20b2ef75839..53736a11beb 100644 --- a/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts +++ b/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts @@ -95,7 +95,7 @@ describe('CodexRuntimeHomeService per-account takeover composition', () => { const config = readFileSync(join(account.managedHomePath, 'config.toml'), 'utf8') expect(config).toContain('model = "fixture-model"') expect(config).not.toContain('[hooks.state') - expect(hookService.install(account.managedHomePath).state).toBe('installed') + expect((await hookService.install(account.managedHomePath)).state).toBe('installed') expect(readFileSync(join(account.managedHomePath, 'hooks.json'), 'utf8')).toContain( process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh' ) diff --git a/src/main/codex/codex-app-server-capability-cache.test.ts b/src/main/codex/codex-app-server-capability-cache.test.ts index c6dea6e0031..676c3f8191b 100644 --- a/src/main/codex/codex-app-server-capability-cache.test.ts +++ b/src/main/codex/codex-app-server-capability-cache.test.ts @@ -21,30 +21,40 @@ describe('CodexAppServerCapabilityCache', () => { ) }) - it('falls back on the first unsupported probe and skips the probe on later calls', () => { + it('falls back on the first unsupported probe and skips the probe on later calls', async () => { const cache = new CodexAppServerCapabilityCache() - const firstPreferred = vi.fn(() => { - throw unsupportedError - }) - expect( - cache.runWithFallbackSync('native', firstPreferred, () => 'first-fallback', isUnsupported, 5) - ).toBe('first-fallback') + const firstPreferred = vi.fn(() => Promise.reject(unsupportedError)) + await expect( + cache.runWithFallback( + 'native', + firstPreferred, + () => Promise.resolve('first-fallback'), + isUnsupported + ) + ).resolves.toBe('first-fallback') expect(firstPreferred).toHaveBeenCalledTimes(1) - // Why: probes are synchronous on the main thread, so they can never - // overlap — back-to-back calls inside the retry window are the - // "concurrent probe" equivalent and must share the first probe's result. - const laterPreferred = vi.fn(() => 'unexpected-preferred') - expect( - cache.runWithFallbackSync('native', laterPreferred, () => 'cached-fallback', isUnsupported, 6) - ).toBe('cached-fallback') - expect( - cache.runWithFallbackSync('native', laterPreferred, () => 'cached-fallback', isUnsupported, 7) - ).toBe('cached-fallback') + const laterPreferred = vi.fn(() => Promise.resolve('unexpected-preferred')) + await expect( + cache.runWithFallback( + 'native', + laterPreferred, + () => Promise.resolve('cached-fallback'), + isUnsupported + ) + ).resolves.toBe('cached-fallback') + await expect( + cache.runWithFallback( + 'native', + laterPreferred, + () => Promise.resolve('cached-fallback'), + isUnsupported + ) + ).resolves.toBe('cached-fallback') expect(laterPreferred).not.toHaveBeenCalled() }) - it('isolates capability state per execution host', () => { + it('isolates capability state per execution host', async () => { const cache = new CodexAppServerCapabilityCache() cache.rememberUnsupported('wsl:Ubuntu', 1_000) @@ -52,63 +62,148 @@ describe('CodexAppServerCapabilityCache', () => { expect(cache.shouldTry('native', 1_001)).toBe(true) expect(cache.shouldTry('wsl:Debian', 1_001)).toBe(true) - const nativePreferred = vi.fn(() => 'native-result') - expect( - cache.runWithFallbackSync('native', nativePreferred, () => 'unexpected', isUnsupported, 1_001) - ).toBe('native-result') + const nativePreferred = vi.fn(() => Promise.resolve('native-result')) + await expect( + cache.runWithFallback( + 'native', + nativePreferred, + () => Promise.resolve('unexpected'), + isUnsupported + ) + ).resolves.toBe('native-result') expect(nativePreferred).toHaveBeenCalledTimes(1) }) - it('drops known support when a later call reports the capability unsupported', () => { + it('drops known support when a later call reports the capability unsupported', async () => { const cache = new CodexAppServerCapabilityCache() - expect( - cache.runWithFallbackSync( + await expect( + cache.runWithFallback( 'native', - () => 'supported', - () => 'unexpected', - isUnsupported, - 1 + () => Promise.resolve('supported'), + () => Promise.resolve('unexpected'), + isUnsupported ) - ).toBe('supported') + ).resolves.toBe('supported') expect(cache.isKnownSupported('native')).toBe(true) - expect( - cache.runWithFallbackSync( + await expect( + cache.runWithFallback( 'native', - () => { - throw unsupportedError - }, - () => 'fallback', - isUnsupported, - 2 + () => Promise.reject(unsupportedError), + () => Promise.resolve('fallback'), + isUnsupported ) - ).toBe('fallback') + ).resolves.toBe('fallback') expect(cache.isKnownSupported('native')).toBe(false) - const laterPreferred = vi.fn(() => 'unexpected-preferred') - expect( - cache.runWithFallbackSync('native', laterPreferred, () => 'cached-fallback', isUnsupported, 3) - ).toBe('cached-fallback') + const laterPreferred = vi.fn(() => Promise.resolve('unexpected-preferred')) + await expect( + cache.runWithFallback( + 'native', + laterPreferred, + () => Promise.resolve('cached-fallback'), + isUnsupported + ) + ).resolves.toBe('cached-fallback') expect(laterPreferred).not.toHaveBeenCalled() }) - it('rethrows transient errors without marking the host unsupported', () => { + it('rethrows transient errors without marking the host unsupported', async () => { const cache = new CodexAppServerCapabilityCache() const transient = new Error('spawn ETIMEDOUT') - expect(() => - cache.runWithFallbackSync( + await expect( + cache.runWithFallback( 'native', - () => { - throw transient - }, - () => 'unexpected-fallback', - isUnsupported, - 1 + () => Promise.reject(transient), + () => Promise.resolve('unexpected-fallback'), + isUnsupported ) - ).toThrow(transient) + ).rejects.toBe(transient) expect(cache.shouldTry('native', 2)).toBe(true) }) + // Why (#16441): grants no longer block the main thread, so two pane launches + // can reach a cold host at once. Without dedupe each one pays its own + // app-server session against a codex that has no such RPC surface. + it('dedupes concurrent probes on one host to a single app-server session', async () => { + const cache = new CodexAppServerCapabilityCache() + let releaseProbe!: (error: unknown) => void + const preferred = vi.fn( + () => + new Promise((_resolve, reject) => { + releaseProbe = reject + }) + ) + const first = cache.runWithFallback( + 'native', + preferred, + () => Promise.resolve('fallback'), + isUnsupported + ) + const second = cache.runWithFallback( + 'native', + preferred, + () => Promise.resolve('fallback'), + isUnsupported + ) + await Promise.resolve() + releaseProbe(unsupportedError) + + await expect(first).resolves.toBe('fallback') + await expect(second).resolves.toBe('fallback') + expect(preferred).toHaveBeenCalledTimes(1) + }) + + it('lets a waiter run its own work once the in-flight probe reports support', async () => { + const cache = new CodexAppServerCapabilityCache() + let releaseProbe!: (value: string) => void + const firstPreferred = vi.fn( + () => + new Promise((resolve) => { + releaseProbe = resolve + }) + ) + const secondPreferred = vi.fn(() => Promise.resolve('second')) + const first = cache.runWithFallback( + 'native', + firstPreferred, + () => Promise.resolve('fallback'), + isUnsupported + ) + const second = cache.runWithFallback( + 'native', + secondPreferred, + () => Promise.resolve('fallback'), + isUnsupported + ) + await Promise.resolve() + releaseProbe('first') + + await expect(first).resolves.toBe('first') + await expect(second).resolves.toBe('second') + expect(secondPreferred).toHaveBeenCalledTimes(1) + }) + + it('isolates in-flight probes per host so a cold WSL distro never waits on native', async () => { + const cache = new CodexAppServerCapabilityCache() + const nativePreferred = vi.fn(() => new Promise(() => {})) + void cache.runWithFallback( + 'native', + nativePreferred, + () => Promise.resolve('fallback'), + isUnsupported + ) + const wslPreferred = vi.fn(() => Promise.resolve('wsl-result')) + await expect( + cache.runWithFallback( + 'wsl:Ubuntu', + wslPreferred, + () => Promise.resolve('fallback'), + isUnsupported + ) + ).resolves.toBe('wsl-result') + }) + it('builds host keys that keep WSL distros apart', () => { expect(getCodexAppServerHostKey({ kind: 'native' })).toBe('native') expect(getCodexAppServerHostKey({ kind: 'wsl', distro: 'Ubuntu' })).toBe('wsl:Ubuntu') diff --git a/src/main/codex/codex-app-server-capability-cache.ts b/src/main/codex/codex-app-server-capability-cache.ts index 85816c9cf0c..ae6c175f043 100644 --- a/src/main/codex/codex-app-server-capability-cache.ts +++ b/src/main/codex/codex-app-server-capability-cache.ts @@ -1,3 +1,5 @@ +import { CapabilityProbeCache } from '../../shared/capability-probe-cache' + // Why: suppress a known-missing RPC surface without pinning it forever — an // in-place codex upgrade during a long Orca session self-heals after the // interval, mirroring GitCapabilityCache's rationale. @@ -14,70 +16,14 @@ export function getCodexAppServerHostKey( } /** - * Capability cache for the codex app-server trust-grant RPC pair, modeled on - * GitCapabilityCache but with a synchronous runner: the grant client blocks - * the main thread by design (launch prep), so probes cannot overlap — the - * unsupported mark alone is what keeps later installs off the dead probe. + * Capability cache for the codex app-server trust-grant RPC pair. The grant + * client runs off the main thread's critical path, so two pane launches can + * probe the same host at once; the shared probe dedupe is what keeps a cold + * host to one app-server session instead of one per concurrent launch. */ -export class CodexAppServerCapabilityCache { - private readonly retryAfterByHost = new Map() - private readonly supportedHosts = new Set() - - shouldTry(hostKey: CodexAppServerHostKey, nowMs = Date.now()): boolean { - const retryAfterMs = this.retryAfterByHost.get(hostKey) - if (retryAfterMs === undefined) { - return true - } - if (nowMs < retryAfterMs) { - return false - } - this.retryAfterByHost.delete(hostKey) - return true - } - - isKnownSupported(hostKey: CodexAppServerHostKey): boolean { - return this.supportedHosts.has(hostKey) - } - - rememberUnsupported(hostKey: CodexAppServerHostKey, nowMs = Date.now()): void { - this.supportedHosts.delete(hostKey) - this.retryAfterByHost.set(hostKey, nowMs + CODEX_APP_SERVER_CAPABILITY_RETRY_INTERVAL_MS) - } - - rememberSupported(hostKey: CodexAppServerHostKey): void { - this.retryAfterByHost.delete(hostKey) - this.supportedHosts.add(hostKey) - } - - runWithFallbackSync( - hostKey: CodexAppServerHostKey, - runPreferred: () => T, - runFallback: () => T, - isUnsupportedError: (error: unknown) => boolean, - nowMs = Date.now() - ): T { - if (!this.supportedHosts.has(hostKey) && !this.shouldTry(hostKey, nowMs)) { - return runFallback() - } - try { - const result = runPreferred() - this.rememberSupported(hostKey) - return result - } catch (error) { - // Why: only a positive absence signal (unknown method / missing - // subcommand) marks unsupported. Transient spawn failures, timeouts, - // and RPC errors fall back once without poisoning the capability. - if (!isUnsupportedError(error)) { - throw error - } - this.rememberUnsupported(hostKey, nowMs) - return runFallback() - } - } - - clear(): void { - this.retryAfterByHost.clear() - this.supportedHosts.clear() +export class CodexAppServerCapabilityCache extends CapabilityProbeCache { + constructor() { + super(CODEX_APP_SERVER_CAPABILITY_RETRY_INTERVAL_MS) } } diff --git a/src/main/codex/codex-app-server-client.test.ts b/src/main/codex/codex-app-server-client.test.ts index 121aebe7255..30d4941ad57 100644 --- a/src/main/codex/codex-app-server-client.test.ts +++ b/src/main/codex/codex-app-server-client.test.ts @@ -13,10 +13,6 @@ import { type CodexHookTrustGrantRequest } from './codex-app-server-client' import { killCodexAppServerProcessTree, runCodexAppServerSession } from './codex-app-server-session' -import { - resolveCodexGrantEntryPath, - runCodexHookTrustGrantSessionSync -} from './codex-app-server-grant-bridge' // Stub codex app-server speaking the same JSONL protocol: initialize → // initialized → hooks/list → config/batchWrite → hooks/list. Scenario-driven @@ -470,106 +466,3 @@ describe('runCodexHookTrustGrantSession', () => { expect(isCodexAppServerUnsupportedError(error)).toBe(false) }) }) - -describe('runCodexHookTrustGrantSessionSync', () => { - function writeEntryFixture(source: string): string { - const root = mkdtempSync(join(tmpdir(), 'orca-codex-entry-')) - tempRoots.push(root) - const entryPath = join(root, 'grant-entry.cjs') - writeFileSync(entryPath, source) - return entryPath - } - - const baseRequest: CodexHookTrustGrantRequest = { - invocation: { command: 'codex', cliPath: null, args: ['app-server'], timeoutMs: 1_000 }, - hooksListCwd: '/tmp', - expectedTrustKeys: ['k'], - managedCommand: MANAGED_COMMAND - } - - it('returns the entry envelope result and passes the request over stdin', () => { - const entryPath = writeEntryFixture(` - let input = '' - process.stdin.setEncoding('utf8') - process.stdin.on('data', (chunk) => { input += chunk }) - process.stdin.on('end', () => { - const request = JSON.parse(input) - process.stdout.write(JSON.stringify({ - ok: true, - result: { - outcome: 'granted', - wroteTrust: true, - entries: [{ key: request.expectedTrustKeys[0], normalizedKey: request.expectedTrustKeys[0], trustedHash: 'sha256:x' }] - } - }) + '\\n') - }) - `) - const result = runCodexHookTrustGrantSessionSync(baseRequest, { entryPath }) - expect(result).toMatchObject({ outcome: 'granted', wroteTrust: true }) - }) - - it('rethrows unsupported envelopes as the unsupported error class', () => { - const entryPath = writeEntryFixture(` - process.stdin.resume() - process.stdin.on('end', () => { - process.stdout.write(JSON.stringify({ ok: false, errorName: 'CodexAppServerUnsupportedError', message: 'no app-server', unsupported: true }) + '\\n') - }) - `) - expect(() => runCodexHookTrustGrantSessionSync(baseRequest, { entryPath })).toThrow( - CodexAppServerUnsupportedError - ) - }) - - it('fails with a clear error when the entry produces no result', () => { - const entryPath = writeEntryFixture( - `process.stdin.resume(); process.stdin.on('end', () => process.exit(7))` - ) - expect(() => runCodexHookTrustGrantSessionSync(baseRequest, { entryPath })).toThrow( - /produced no result \(exit 7\)/ - ) - }) - - it('classifies the spawnSync deadline as a typed timeout', () => { - const entryPath = writeEntryFixture(`setInterval(() => {}, 1000)`) - const request = { - ...baseRequest, - invocation: { ...baseRequest.invocation, timeoutMs: 20 } - } - expect(() => - runCodexHookTrustGrantSessionSync(request, { entryPath, timeoutMarginMs: 20 }) - ).toThrow(CodexAppServerTimeoutError) - }) -}) - -describe('resolveCodexGrantEntryPath', () => { - const entryName = 'codex-app-server-grant-entry.js' - - it('finds the sibling entry from emitted main and chunk directories', () => { - const mainDir = join('/opt', 'orca', 'out', 'main') - expect( - resolveCodexGrantEntryPath( - (candidate) => candidate === join(mainDir, 'codex', entryName), - mainDir - ) - ).toBe(join(mainDir, 'codex', entryName)) - - const chunkDir = join(mainDir, 'chunks') - expect( - resolveCodexGrantEntryPath( - (candidate) => candidate === join(mainDir, 'codex', entryName), - chunkDir - ) - ).toBe(join(mainDir, 'codex', entryName)) - }) - - it('redirects app.asar to unpacked without double-unpacking an existing path', () => { - const resourcesDir = join('/Applications', 'Orca.app', 'Contents', 'Resources') - const expected = join(resourcesDir, 'app.asar.unpacked', 'out', 'main', 'codex', entryName) - for (const archiveDir of ['app.asar', 'app.asar.unpacked']) { - const moduleDir = join(resourcesDir, archiveDir, 'out', 'main', 'chunks') - expect(resolveCodexGrantEntryPath((candidate) => candidate === expected, moduleDir)).toBe( - expected - ) - } - }) -}) diff --git a/src/main/codex/codex-app-server-client.ts b/src/main/codex/codex-app-server-client.ts index cdc2c4d20ff..8c95562e66c 100644 --- a/src/main/codex/codex-app-server-client.ts +++ b/src/main/codex/codex-app-server-client.ts @@ -41,8 +41,8 @@ export type CodexGrantedHookTrust = { trustedHash: string } -/** Closed verify-failure taxonomy — crosses the grant-bridge JSON envelope, so - * telemetry never has to parse the free-form `reason` diagnostics string. */ +/** Closed verify-failure taxonomy, so telemetry never has to parse the + * free-form `reason` diagnostics string. */ export type CodexTrustGrantSessionVerifyClass = | 'list-mismatch' | 'post-grant-untrusted' diff --git a/src/main/codex/codex-app-server-grant-bridge.ts b/src/main/codex/codex-app-server-grant-bridge.ts deleted file mode 100644 index c85c4a1c479..00000000000 --- a/src/main/codex/codex-app-server-grant-bridge.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { spawnSync } from 'node:child_process' -import { existsSync } from 'node:fs' -import { join } from 'node:path' -import { - CodexAppServerTimeoutError, - CodexAppServerUnsupportedError, - type CodexHookTrustGrantRequest, - type CodexHookTrustGrantSessionResult -} from './codex-app-server-client' -import type { - CodexAppServerEntryRequest, - CodexAppServerEntryResult, - GrantEntryEnvelope -} from './codex-app-server-grant-envelope' -import type { - CodexUserHookTrustRebaseRequest, - CodexUserHookTrustRebaseResult -} from './codex-user-hook-trust-rebase-client' - -// Why: hook install/refresh is synchronous launch prep — a Codex pane must -// not start before its trust is settled — but a stdio JSON-RPC session needs -// a live event loop. This bridge blocks the caller on spawnSync of a bundled -// ELECTRON_RUN_AS_NODE entry (same pattern as the daemon and parcel-watcher -// entries) that runs the session and reports one JSON envelope on stdout. - -const GRANT_ENTRY_FILE_NAME = 'codex-app-server-grant-entry.js' -// Why: spawnSync must outlive the session deadline so the entry's own timeout -// (and its result envelope) win the race; the margin only reaps a hung entry. -const GRANT_ENTRY_TIMEOUT_MARGIN_MS = 5_000 -const GRANT_ENTRY_MAX_BUFFER_BYTES = 16 * 1024 * 1024 - -export function resolveCodexGrantEntryPath( - pathExists: (candidate: string) => boolean = existsSync, - moduleDir = __dirname -): string | null { - // Why: resolved from __dirname (not electron's app paths) so this module - // stays loadable in plain-node CLI entries — the build guard rejects any - // electron require reachable from them. The emitted bridge chunk sits in - // out/main or out/main/chunks, so the entry is one or two levels up. - // ELECTRON_RUN_AS_NODE bypasses asar integration, so packaged builds must - // run the copy under app.asar.unpacked (out/main/codex/** is asarUnpacked). - const toUnpackedDir = (dir: string): string => - dir.replace(/([\\/])app\.asar(?=([\\/]|$))/, '$1app.asar.unpacked') - const baseDirs = [moduleDir, join(moduleDir, '..')].map(toUnpackedDir) - for (const baseDir of baseDirs) { - const candidate = join(baseDir, 'codex', GRANT_ENTRY_FILE_NAME) - if (pathExists(candidate)) { - return candidate - } - } - return null -} - -export type RunGrantSessionSyncOptions = { - entryPath?: string - nodeCommand?: string - /** Test-only override; production keeps enough margin for child cleanup. */ - timeoutMarginMs?: number -} - -/** - * Blocking wrapper for the grant session. Hook install/refresh is synchronous - * launch prep (pane launch must not proceed until trust is settled), and a - * stdio JSON-RPC session needs a live event loop — so the session runs in a - * short-lived ELECTRON_RUN_AS_NODE child (same pattern as the daemon and - * parcel-watcher entries) while the caller blocks on spawnSync. spawnSync - * always reaps the entry; a killed entry closes the codex child's stdin, - * which makes codex app-server exit on EOF. - */ -export function runCodexHookTrustGrantSessionSync( - request: CodexHookTrustGrantRequest, - options: RunGrantSessionSyncOptions = {} -): CodexHookTrustGrantSessionResult { - return runCodexAppServerEntrySync(request, options) as CodexHookTrustGrantSessionResult -} - -export function runCodexUserHookTrustRebaseSessionSync( - request: CodexUserHookTrustRebaseRequest, - options: RunGrantSessionSyncOptions = {} -): CodexUserHookTrustRebaseResult { - return runCodexAppServerEntrySync(request, options) as CodexUserHookTrustRebaseResult -} - -function runCodexAppServerEntrySync( - request: CodexAppServerEntryRequest, - options: RunGrantSessionSyncOptions -): CodexAppServerEntryResult { - const entryPath = options.entryPath ?? resolveCodexGrantEntryPath() - if (!entryPath) { - throw new Error('codex trust-grant entry bundle not found') - } - const spawned = spawnSync(options.nodeCommand ?? process.execPath, [entryPath], { - input: JSON.stringify(request), - encoding: 'utf8', - timeout: - request.invocation.timeoutMs + (options.timeoutMarginMs ?? GRANT_ENTRY_TIMEOUT_MARGIN_MS), - killSignal: 'SIGKILL', - maxBuffer: GRANT_ENTRY_MAX_BUFFER_BYTES, - windowsHide: true, - env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } - }) - if ((spawned.error as NodeJS.ErrnoException | undefined)?.code === 'ETIMEDOUT') { - // Why: spawnSync reports its own deadline through error.code before the - // signal field; preserve the typed timeout so cooldown diagnostics work. - throw new CodexAppServerTimeoutError( - `codex trust-grant entry exceeded ${request.invocation.timeoutMs}ms session deadline` - ) - } - if (spawned.error) { - throw spawned.error - } - if (spawned.signal) { - throw new CodexAppServerTimeoutError( - `codex trust-grant entry killed by ${spawned.signal} after ${request.invocation.timeoutMs}ms deadline` - ) - } - const lines = (spawned.stdout ?? '').split('\n').filter((line) => line.trim().length > 0) - const lastLine = lines.at(-1) - let envelope: GrantEntryEnvelope | null = null - if (lastLine) { - try { - envelope = JSON.parse(lastLine) as GrantEntryEnvelope - } catch { - envelope = null - } - } - if (!envelope) { - throw new Error( - `codex trust-grant entry produced no result (exit ${spawned.status ?? 'unknown'})${ - spawned.stderr ? `: ${spawned.stderr.trim().slice(0, 400)}` : '' - }` - ) - } - if (!envelope.ok) { - if (envelope.unsupported) { - throw new CodexAppServerUnsupportedError(envelope.message) - } - if (envelope.errorName === 'CodexAppServerTimeoutError') { - throw new CodexAppServerTimeoutError(envelope.message) - } - throw new Error(envelope.message) - } - return envelope.result -} diff --git a/src/main/codex/codex-app-server-grant-entry.ts b/src/main/codex/codex-app-server-grant-entry.ts deleted file mode 100644 index 8e16892d279..00000000000 --- a/src/main/codex/codex-app-server-grant-entry.ts +++ /dev/null @@ -1,79 +0,0 @@ -// Forked (ELECTRON_RUN_AS_NODE) child that runs one codex app-server -// trust-grant session. The parent blocks on spawnSync because hook -// install/refresh must finish before a Codex pane launch proceeds, while the -// JSONL RPC session itself needs a live event loop. Reads the request JSON -// from stdin, writes a single result-envelope JSON line to stdout, and never -// imports electron (see PLAIN_NODE_ENTRY_NAMES in the build guard). -import { - buildGrantEntryEnvelope, - type CodexAppServerEntryRequest -} from './codex-app-server-grant-envelope' -import { writeSync } from 'node:fs' -import { runCodexHookTrustGrantSession } from './codex-app-server-client' -import { runCodexUserHookTrustRebaseSession } from './codex-user-hook-trust-rebase-client' - -const HARD_EXIT_MARGIN_MS = 2_000 - -async function readStdin(): Promise { - const chunks: Buffer[] = [] - for await (const chunk of process.stdin) { - chunks.push(chunk as Buffer) - } - return Buffer.concat(chunks).toString('utf8') -} - -async function main(): Promise { - const raw = await readStdin() - let request: CodexAppServerEntryRequest - try { - request = JSON.parse(raw) as CodexAppServerEntryRequest - } catch (error) { - process.stdout.write( - `${JSON.stringify({ - ok: false, - errorName: 'Error', - message: `invalid trust-grant request JSON: ${error instanceof Error ? error.message : String(error)}` - })}\n` - ) - return - } - // Why: backstop for a session whose own deadline failed to fire (clock - // suspend mid-session); exiting closes the codex child's stdio so it - // exits on EOF instead of orphaning. - const hardExit = setTimeout(() => { - // Why: process.exit() does not flush asynchronous stdout pipes; write the - // timeout envelope synchronously so the parent can classify the fallback. - writeSync( - process.stdout.fd, - `${JSON.stringify({ - ok: false, - errorName: 'CodexAppServerTimeoutError', - message: `trust-grant entry hard deadline (${request.invocation.timeoutMs + HARD_EXIT_MARGIN_MS}ms) elapsed` - })}\n` - ) - process.exit(3) - }, request.invocation.timeoutMs + HARD_EXIT_MARGIN_MS) - const run = - 'operation' in request - ? runCodexUserHookTrustRebaseSession(request) - : runCodexHookTrustGrantSession(request) - const envelope = await buildGrantEntryEnvelope(run) - clearTimeout(hardExit) - process.stdout.write(`${JSON.stringify(envelope)}\n`) -} - -void main().then( - () => { - process.exitCode = 0 - }, - (error: unknown) => { - process.stdout.write( - `${JSON.stringify({ - ok: false, - errorName: error instanceof Error ? error.name : 'Error', - message: error instanceof Error ? error.message : String(error) - })}\n` - ) - process.exitCode = 0 - } -) diff --git a/src/main/codex/codex-app-server-grant-envelope.ts b/src/main/codex/codex-app-server-grant-envelope.ts deleted file mode 100644 index d5b133c2975..00000000000 --- a/src/main/codex/codex-app-server-grant-envelope.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { - isCodexAppServerUnsupportedError, - type CodexHookTrustGrantRequest, - type CodexHookTrustGrantSessionResult -} from './codex-app-server-client' -import type { - CodexUserHookTrustRebaseRequest, - CodexUserHookTrustRebaseResult -} from './codex-user-hook-trust-rebase-client' - -export type CodexAppServerEntryRequest = - | CodexHookTrustGrantRequest - | CodexUserHookTrustRebaseRequest - -export type CodexAppServerEntryResult = - | CodexHookTrustGrantSessionResult - | CodexUserHookTrustRebaseResult - -export type GrantEntryEnvelope = - | { ok: true; result: CodexAppServerEntryResult } - | { ok: false; errorName: string; message: string; unsupported?: boolean } - -export function buildGrantEntryEnvelope( - run: Promise -): Promise { - return run.then( - (result) => ({ ok: true as const, result }), - (error: unknown) => ({ - ok: false as const, - errorName: error instanceof Error ? error.name : 'Error', - message: error instanceof Error ? error.message : String(error), - ...(isCodexAppServerUnsupportedError(error) ? { unsupported: true as const } : {}) - }) - ) -} diff --git a/src/main/codex/codex-hook-trust-grant.test.ts b/src/main/codex/codex-hook-trust-grant.test.ts index e1ca7c41076..5292be120c1 100644 --- a/src/main/codex/codex-hook-trust-grant.test.ts +++ b/src/main/codex/codex-hook-trust-grant.test.ts @@ -45,7 +45,7 @@ beforeEach(() => { afterEach(() => { vi.useRealTimers() - _internals.setGrantSessionRunnerSync(null) + _internals.setGrantSessionRunner(null) setCodexTrustGrantTelemetry(() => {}) codexAppServerCapabilityCache.clear() if (previousUserDataPath === undefined) { @@ -97,28 +97,30 @@ function grantedSessionResult(entries: CodexTrustEntry[], hashPrefix = 'sha256:c } describe('grantManagedCodexHookTrust', () => { - it('does not let a short trust RPC claim an incomplete session index', () => { + it('does not let a short trust RPC claim an incomplete session index', async () => { const sessions = join(runtimeHomeDir, 'sessions') mkdirSync(sessions, { recursive: true }) for (let index = 0; index < 100; index += 1) { writeFileSync(join(sessions, `${index}.jsonl`), '{}\n') } const runner = vi.fn() - _internals.setGrantSessionRunnerSync(runner) + _internals.setGrantSessionRunner(runner) - expect(grantManagedCodexHookTrust(buildPlan([managedEntry('stop')]))).toMatchObject({ + expect(await grantManagedCodexHookTrust(buildPlan([managedEntry('stop')]))).toMatchObject({ lane: 'fallback', reason: 'retry-cached' }) expect(runner).not.toHaveBeenCalled() }) - it('returns granted entries with codex-verbatim hashes and records the ledger', () => { + it('returns granted entries with codex-verbatim hashes and records the ledger', async () => { const entries = [managedEntry('session_start'), managedEntry('stop')] - const runner = vi.fn((_request: CodexHookTrustGrantRequest) => grantedSessionResult(entries)) - _internals.setGrantSessionRunnerSync(runner) + const runner = vi.fn(async (_request: CodexHookTrustGrantRequest) => + grantedSessionResult(entries) + ) + _internals.setGrantSessionRunner(runner) - const outcome = grantManagedCodexHookTrust(buildPlan(entries)) + const outcome = await grantManagedCodexHookTrust(buildPlan(entries)) expect(outcome.lane).toBe('rpc') if (outcome.lane !== 'rpc') { return @@ -139,20 +141,22 @@ describe('grantManagedCodexHookTrust', () => { expect(getCodexTrustGrantDiagnostics()).toMatchObject({ granted: 1, fellBack: 0 }) }) - it('builds a default-home grant invocation without an inherited CODEX_HOME', () => { + it('builds a default-home grant invocation without an inherited CODEX_HOME', async () => { const entries = [managedEntry('stop')] - const runner = vi.fn((_request: CodexHookTrustGrantRequest) => grantedSessionResult(entries)) - _internals.setGrantSessionRunnerSync(runner) + const runner = vi.fn(async (_request: CodexHookTrustGrantRequest) => + grantedSessionResult(entries) + ) + _internals.setGrantSessionRunner(runner) expect( - grantManagedCodexHookTrust({ ...buildPlan(entries), useDefaultCodexHome: true }) + await grantManagedCodexHookTrust({ ...buildPlan(entries), useDefaultCodexHome: true }) ).toMatchObject({ lane: 'rpc' }) const invocation = runner.mock.calls[0]![0]!.invocation expect(invocation.env?.CODEX_HOME).toBeUndefined() expect(invocation.envToDelete).toContain('CODEX_HOME') }) - it('removes equivalent Windows fallback keys before the RPC writes canonical trust', () => { + it('removes equivalent Windows fallback keys before the RPC writes canonical trust', async () => { const entry: CodexTrustEntry = { ...managedEntry('stop'), sourcePath: String.raw`C:\Users\Alice\.codex\hooks.json` @@ -162,23 +166,23 @@ describe('grantManagedCodexHookTrust', () => { expect(readHookTrustEntries(plan.tomlPath).get(computeTrustKey(entry))?.trustedHash).toBe( computeTrustedHash(entry) ) - const runner = vi.fn(() => { + const runner = vi.fn(async () => { expect(readHookTrustEntries(plan.tomlPath).has(computeTrustKey(entry))).toBe(false) return grantedSessionResult([entry]) }) - _internals.setGrantSessionRunnerSync(runner) + _internals.setGrantSessionRunner(runner) - expect(grantManagedCodexHookTrust(plan)).toMatchObject({ lane: 'rpc' }) + expect(await grantManagedCodexHookTrust(plan)).toMatchObject({ lane: 'rpc' }) expect(runner).toHaveBeenCalledTimes(1) }) - it('skips the RPC session while the ledger grant still holds, and re-grants on config drift', () => { + it('skips the RPC session while the ledger grant still holds, and re-grants on config drift', async () => { const entries = [managedEntry('session_start')] - const runner = vi.fn(() => grantedSessionResult(entries)) - _internals.setGrantSessionRunnerSync(runner) + const runner = vi.fn(async () => grantedSessionResult(entries)) + _internals.setGrantSessionRunner(runner) const plan = buildPlan(entries) - const first = grantManagedCodexHookTrust(plan) + const first = await grantManagedCodexHookTrust(plan) expect(first.lane).toBe('rpc') expect(runner).toHaveBeenCalledTimes(1) @@ -187,92 +191,95 @@ describe('grantManagedCodexHookTrust', () => { upsertHookTrustEntries(plan.tomlPath, [ { ...entries[0], trustedHash: 'sha256:codex-session_start' } ]) - const second = grantManagedCodexHookTrust(plan) + const second = await grantManagedCodexHookTrust(plan) expect(second.lane).toBe('rpc') expect(runner).toHaveBeenCalledTimes(1) expect(getCodexTrustGrantDiagnostics()).toMatchObject({ granted: 1, ledgerHits: 1 }) // Config drift (user wiped the trust entry) must re-run the session. upsertHookTrustEntries(plan.tomlPath, [{ ...entries[0], trustedHash: 'sha256:wiped' }]) - const third = grantManagedCodexHookTrust(plan) + const third = await grantManagedCodexHookTrust(plan) expect(third.lane).toBe('rpc') expect(runner).toHaveBeenCalledTimes(2) }) - it('re-grants when the managed hook identity changes', () => { + it('re-grants when the managed hook identity changes', async () => { const entries = [managedEntry('session_start')] - const runner = vi.fn(() => grantedSessionResult(entries)) - _internals.setGrantSessionRunnerSync(runner) + const runner = vi.fn(async () => grantedSessionResult(entries)) + _internals.setGrantSessionRunner(runner) const plan = buildPlan(entries) - grantManagedCodexHookTrust(plan) + await grantManagedCodexHookTrust(plan) upsertHookTrustEntries(plan.tomlPath, [ { ...entries[0], trustedHash: 'sha256:codex-session_start' } ]) const changedEntries = [{ ...entries[0], timeoutSec: 99 }] - const changedRunner = vi.fn(() => grantedSessionResult(changedEntries)) - _internals.setGrantSessionRunnerSync(changedRunner) - const outcome = grantManagedCodexHookTrust(buildPlan(changedEntries)) + const changedRunner = vi.fn(async () => grantedSessionResult(changedEntries)) + _internals.setGrantSessionRunner(changedRunner) + const outcome = await grantManagedCodexHookTrust(buildPlan(changedEntries)) expect(outcome.lane).toBe('rpc') expect(changedRunner).toHaveBeenCalledTimes(1) }) - it('marks the host unsupported only for the unsupported error class', () => { + it('marks the host unsupported only for the unsupported error class', async () => { const entries = [managedEntry('session_start')] - const runner = vi.fn((): CodexHookTrustGrantSessionResult => { + const runner = vi.fn((): Promise => { throw new CodexAppServerUnsupportedError('no such method') }) - _internals.setGrantSessionRunnerSync(runner) + _internals.setGrantSessionRunner(runner) const plan = buildPlan(entries) - expect(grantManagedCodexHookTrust(plan)).toMatchObject({ + expect(await grantManagedCodexHookTrust(plan)).toMatchObject({ lane: 'fallback', reason: 'unsupported' }) expect(runner).toHaveBeenCalledTimes(1) // Cached: the second install skips the probe entirely. - expect(grantManagedCodexHookTrust(plan)).toMatchObject({ + expect(await grantManagedCodexHookTrust(plan)).toMatchObject({ lane: 'fallback', reason: 'unsupported-cached' }) expect(runner).toHaveBeenCalledTimes(1) }) - it('backs off transient failures without poisoning the capability', () => { + it('backs off transient failures without poisoning the capability', async () => { vi.useFakeTimers() vi.setSystemTime(1_000) const entries = [managedEntry('session_start')] - const runner = vi.fn((): CodexHookTrustGrantSessionResult => { + const runner = vi.fn((): Promise => { throw new Error('spawn ETIMEDOUT') }) - _internals.setGrantSessionRunnerSync(runner) + _internals.setGrantSessionRunner(runner) const plan = buildPlan(entries) - expect(grantManagedCodexHookTrust(plan)).toMatchObject({ lane: 'fallback', reason: 'error' }) - expect(grantManagedCodexHookTrust(plan)).toMatchObject({ + expect(await grantManagedCodexHookTrust(plan)).toMatchObject({ + lane: 'fallback', + reason: 'error' + }) + expect(await grantManagedCodexHookTrust(plan)).toMatchObject({ lane: 'fallback', reason: 'retry-cached' }) expect(runner).toHaveBeenCalledTimes(1) expect(codexAppServerCapabilityCache.shouldTry('native')).toBe(true) - runner.mockImplementation(() => grantedSessionResult(entries)) + runner.mockImplementation(async () => grantedSessionResult(entries)) vi.setSystemTime(1_000 + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS) - expect(grantManagedCodexHookTrust(plan)).toMatchObject({ lane: 'rpc' }) + expect(await grantManagedCodexHookTrust(plan)).toMatchObject({ lane: 'rpc' }) expect(runner).toHaveBeenCalledTimes(2) }) - it('falls back on verify-failed without marking unsupported', () => { + it('falls back on verify-failed without marking unsupported', async () => { const entries = [managedEntry('session_start')] - const runner = vi.fn(() => ({ + const runner = vi.fn(async () => ({ outcome: 'verify-failed' as const, reason: 'missing entries', reasonClass: 'list-mismatch' as const })) - _internals.setGrantSessionRunnerSync(runner) + _internals.setGrantSessionRunner(runner) - expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ + expect(await grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ lane: 'fallback', reason: 'verify-failed' }) @@ -280,53 +287,56 @@ describe('grantManagedCodexHookTrust', () => { expect(getCodexTrustGrantDiagnostics()).toMatchObject({ verifyFailed: 1 }) }) - it('rejects duplicate granted keys instead of treating another key as covered', () => { + it('rejects duplicate granted keys instead of treating another key as covered', async () => { const entries = [managedEntry('session_start'), managedEntry('stop')] const duplicated = grantedSessionResult([entries[0]!, entries[0]!]) - _internals.setGrantSessionRunnerSync(() => duplicated) + _internals.setGrantSessionRunner(async () => duplicated) - expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ + expect(await grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ lane: 'fallback', reason: 'verify-failed' }) expect(readCodexTrustGrantLedgerHome(runtimeHomeDir)).toBeNull() }) - it('keeps grant and fallback outcomes stable when telemetry throws', () => { + it('keeps grant and fallback outcomes stable when telemetry throws', async () => { const entries = [managedEntry('session_start')] setCodexTrustGrantTelemetry(() => { throw new Error('telemetry unavailable') }) - _internals.setGrantSessionRunnerSync(() => grantedSessionResult(entries)) + _internals.setGrantSessionRunner(async () => grantedSessionResult(entries)) - expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ lane: 'rpc' }) + expect(await grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ lane: 'rpc' }) process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1' - expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ + expect(await grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ lane: 'fallback', reason: 'disabled' }) }) - it('restores exact config bytes before fallback after a mutating RPC error', () => { + it('restores exact config bytes before fallback after a mutating RPC error', async () => { const entries = [managedEntry('session_start')] const plan = buildPlan(entries) const original = '# user formatting\r\n[hooks]\r\n' mkdirSync(runtimeHomeDir, { recursive: true }) writeFileSync(plan.tomlPath, original) - _internals.setGrantSessionRunnerSync(() => { + _internals.setGrantSessionRunner(async () => { writeFileSync(plan.tomlPath, '[hooks.state."rpc-partial"]\ntrusted_hash = "changed"\n') throw new Error('post-write transport failure') }) - expect(grantManagedCodexHookTrust(plan)).toMatchObject({ lane: 'fallback', reason: 'error' }) + expect(await grantManagedCodexHookTrust(plan)).toMatchObject({ + lane: 'fallback', + reason: 'error' + }) expect(readFileSync(plan.tomlPath, 'utf8')).toBe(original) }) - it('removes an RPC-created config before fallback when none existed', () => { + it('removes an RPC-created config before fallback when none existed', async () => { const entries = [managedEntry('session_start')] const plan = buildPlan(entries) mkdirSync(runtimeHomeDir, { recursive: true }) - _internals.setGrantSessionRunnerSync(() => { + _internals.setGrantSessionRunner(async () => { writeFileSync(plan.tomlPath, '[hooks.state."rpc-partial"]\ntrusted_hash = "changed"\n') return { outcome: 'verify-failed', @@ -335,32 +345,116 @@ describe('grantManagedCodexHookTrust', () => { } }) - expect(grantManagedCodexHookTrust(plan)).toMatchObject({ + expect(await grantManagedCodexHookTrust(plan)).toMatchObject({ lane: 'fallback', reason: 'verify-failed' }) expect(existsSync(plan.tomlPath)).toBe(false) }) - it('honors the ops kill switch env flag', () => { + it('honors the ops kill switch env flag', async () => { process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1' const entries = [managedEntry('session_start')] - const runner = vi.fn(() => grantedSessionResult(entries)) - _internals.setGrantSessionRunnerSync(runner) + const runner = vi.fn(async () => grantedSessionResult(entries)) + _internals.setGrantSessionRunner(runner) - expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ + expect(await grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ lane: 'fallback', reason: 'disabled' }) expect(runner).not.toHaveBeenCalled() }) - it('builds a WSL invocation that runs codex inside the distro', () => { + // Why (#16441): the grant used to run through spawnSync, so two grants on one + // config.toml were impossible by construction. Now they must queue — an + // interleaved capture/restore pair resurrects trust the other run removed. + it('serializes concurrent grants that share one config.toml', async () => { const entries = [managedEntry('session_start')] - const runner = vi.fn((_request: CodexHookTrustGrantRequest) => grantedSessionResult(entries)) - _internals.setGrantSessionRunnerSync(runner) + const plan = buildPlan(entries) + let inFlight = 0 + let maxInFlight = 0 + const releases: (() => void)[] = [] + _internals.setGrantSessionRunner(async () => { + inFlight += 1 + maxInFlight = Math.max(maxInFlight, inFlight) + await new Promise((resolve) => releases.push(resolve)) + inFlight -= 1 + return grantedSessionResult(entries) + }) - const outcome = grantManagedCodexHookTrust({ + const first = grantManagedCodexHookTrust(plan) + const second = grantManagedCodexHookTrust(plan) + await vi.waitFor(() => expect(releases).toHaveLength(1)) + releases[0]!() + await first + await vi.waitFor(() => expect(releases).toHaveLength(2)) + releases[1]!() + await second + + expect(maxInFlight).toBe(1) + }) + + it('lets grants on different config.toml paths overlap', async () => { + const entries = [managedEntry('session_start')] + const otherHome = join(userDataDir, 'codex-accounts', 'other', 'home') + mkdirSync(otherHome, { recursive: true }) + // Why: the probe dedupe only holds the first session on an unproven host. + // A known-supported host must keep its intended launch concurrency. + codexAppServerCapabilityCache.rememberSupported('native') + let inFlight = 0 + let maxInFlight = 0 + const releases: (() => void)[] = [] + _internals.setGrantSessionRunner(async () => { + inFlight += 1 + maxInFlight = Math.max(maxInFlight, inFlight) + await new Promise((resolve) => releases.push(resolve)) + inFlight -= 1 + return grantedSessionResult(entries) + }) + + const first = grantManagedCodexHookTrust(buildPlan(entries)) + const second = grantManagedCodexHookTrust({ + ...buildPlan(entries), + runtimeHomePath: otherHome, + tomlPath: join(otherHome, 'config.toml') + }) + await vi.waitFor(() => expect(releases).toHaveLength(2)) + releases.forEach((release) => release()) + await Promise.all([first, second]) + + expect(maxInFlight).toBe(2) + }) + + it('dedupes the capability probe when concurrent grants hit an unsupported host', async () => { + const entries = [managedEntry('session_start')] + const otherHome = join(userDataDir, 'codex-accounts', 'other', 'home') + mkdirSync(otherHome, { recursive: true }) + const releases: ((error: unknown) => void)[] = [] + const runner = vi.fn(() => new Promise((_resolve, reject) => releases.push(reject))) + _internals.setGrantSessionRunner(runner) + + const first = grantManagedCodexHookTrust(buildPlan(entries)) + const second = grantManagedCodexHookTrust({ + ...buildPlan(entries), + runtimeHomePath: otherHome, + tomlPath: join(otherHome, 'config.toml') + }) + await vi.waitFor(() => expect(releases).toHaveLength(1)) + releases[0]!(new CodexAppServerUnsupportedError('no such method')) + + expect(await first).toMatchObject({ lane: 'fallback', reason: 'unsupported' }) + expect(await second).toMatchObject({ lane: 'fallback', reason: 'unsupported-cached' }) + expect(runner).toHaveBeenCalledTimes(1) + }) + + it('builds a WSL invocation that runs codex inside the distro', async () => { + const entries = [managedEntry('session_start')] + const runner = vi.fn(async (_request: CodexHookTrustGrantRequest) => + grantedSessionResult(entries) + ) + _internals.setGrantSessionRunner(runner) + + const outcome = await grantManagedCodexHookTrust({ ...buildPlan(entries), host: { kind: 'wsl', distro: 'Ubuntu', linuxRuntimeHome: '/home/alice/.codex-runtime' } }) @@ -384,32 +478,32 @@ describe('trust-grant telemetry detail', () => { return events } - it('attributes the plan lane on granted events', () => { + it('attributes the plan lane on granted events', async () => { const events = captureTelemetry() const entries = [managedEntry('session_start')] - _internals.setGrantSessionRunnerSync(() => grantedSessionResult(entries)) + _internals.setGrantSessionRunner(async () => grantedSessionResult(entries)) - expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ lane: 'rpc' }) + expect(await grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ lane: 'rpc' }) expect(events).toEqual([{ outcome: 'granted', hostKind: 'native', lane: 'real-home' }]) }) - it('reports the managed lane independently of host kind', () => { + it('reports the managed lane independently of host kind', async () => { const events = captureTelemetry() const entries = [managedEntry('session_start')] - _internals.setGrantSessionRunnerSync(() => grantedSessionResult(entries)) + _internals.setGrantSessionRunner(async () => grantedSessionResult(entries)) - grantManagedCodexHookTrust({ ...buildPlan(entries), telemetryLane: 'managed' }) + await grantManagedCodexHookTrust({ ...buildPlan(entries), telemetryLane: 'managed' }) expect(events).toEqual([{ outcome: 'granted', hostKind: 'native', lane: 'managed' }]) }) - it('classifies error fallbacks on the wire', () => { + it('classifies error fallbacks on the wire', async () => { const events = captureTelemetry() const entries = [managedEntry('session_start')] - _internals.setGrantSessionRunnerSync(() => { + _internals.setGrantSessionRunner(async () => { throw new Error('spawn codex ENOENT') }) - expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ + expect(await grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ lane: 'fallback', reason: 'error' }) @@ -424,16 +518,16 @@ describe('trust-grant telemetry detail', () => { ]) }) - it('carries the session verify class through the fallback event', () => { + it('carries the session verify class through the fallback event', async () => { const events = captureTelemetry() const entries = [managedEntry('session_start')] - _internals.setGrantSessionRunnerSync(() => ({ + _internals.setGrantSessionRunner(async () => ({ outcome: 'verify-failed' as const, reason: 'post-grant verify left 1 entries untrusted', reasonClass: 'post-grant-untrusted' as const })) - grantManagedCodexHookTrust(buildPlan(entries)) + await grantManagedCodexHookTrust(buildPlan(entries)) expect(events).toEqual([ { outcome: 'verify_failed', @@ -445,12 +539,12 @@ describe('trust-grant telemetry detail', () => { ]) }) - it('classifies module-detected verify failures', () => { + it('classifies module-detected verify failures', async () => { const events = captureTelemetry() const entries = [managedEntry('session_start'), managedEntry('stop')] - _internals.setGrantSessionRunnerSync(() => grantedSessionResult([entries[0]!, entries[0]!])) + _internals.setGrantSessionRunner(async () => grantedSessionResult([entries[0]!, entries[0]!])) - grantManagedCodexHookTrust(buildPlan(entries)) + await grantManagedCodexHookTrust(buildPlan(entries)) expect(events).toEqual([ { outcome: 'verify_failed', diff --git a/src/main/codex/codex-hook-trust-grant.ts b/src/main/codex/codex-hook-trust-grant.ts index 9142a590ab3..f886dbb6344 100644 --- a/src/main/codex/codex-hook-trust-grant.ts +++ b/src/main/codex/codex-hook-trust-grant.ts @@ -1,5 +1,6 @@ import { isCodexAppServerUnsupportedError, + runCodexHookTrustGrantSession, type CodexHookTrustGrantRequest, type CodexHookTrustGrantSessionResult } from './codex-app-server-client' @@ -10,55 +11,50 @@ import { type CodexTrustGrantTelemetryLane, type CodexTrustGrantVerifyClass } from './codex-trust-grant-telemetry' -import { runCodexHookTrustGrantSessionSync } from './codex-app-server-grant-bridge' import { codexAppServerCapabilityCache, - getCodexAppServerHostKey + getCodexAppServerHostKey, + type CodexAppServerHostKey } from './codex-app-server-capability-cache' import { writeCodexTrustGrantLedgerHome, type CodexTrustGrantBinaryStamp, type CodexTrustGrantLedgerEntry } from './codex-trust-grant-ledger' -import { - computeTrustKey, - computeTrustedHash, - normalizeHookTrustKeyForLookup, - readHookTrustEntries, - removeHookTrustEntries, - type CodexTrustEntry -} from './config-toml-trust' -import { getCodexHookTrustSignature } from './codex-hook-identity' +import type { CodexTrustEntry } from './config-toml-trust' import { captureCodexTrustConfig, restoreCodexTrustConfig } from './codex-trust-config-rollback' +import { runExclusivelyForCodexTrustConfig } from './codex-trust-config-mutation-queue' import { - readCodexTrustGrantLedgerHomeMatchingStamp, resolveCodexTrustGrantHost, - type CodexTrustGrantHost + type ResolvedCodexTrustGrantHost } from './codex-trust-grant-host' +import { + buildExpectedEntries, + findLedgerGrant, + removeSelfComputedTrustBeforeGrant, + type CodexManagedTrustGrantPlan, + type ExpectedManagedEntry +} from './codex-managed-trust-grant-plan' import { isCodexStateDbBackfillPending } from './codex-state-db' // Why: a transiently hung app-server must not block launch prep on every pane. // The legacy lane remains available while a short, host-scoped cooldown runs. export const CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS = 5 * 60_000 -/** Ops escape hatch (not a setting): forces the unchanged fallback lane. */ +/** + * Ops escape hatch (not a setting): forces the unchanged fallback lane for the + * *managed* grant only. + * + * Scope, because the name reads broader than it is: the real-home rebase + * (`mutateRealHomeHooksPreservingUserTrust`) still runs its own inspect/repair + * app-server sessions when Orca's insertion shifts a user's hook positions, and + * does not read this flag. That is unchanged from before the grant went async — + * those sessions simply used to block the main thread instead. Widening the flag + * to cover the rebase is a follow-up, not something this constant already does. + */ const DISABLE_ENV_FLAG = 'ORCA_DISABLE_CODEX_TRUST_RPC' -export type CodexManagedTrustGrantPlan = { - /** Host-visible runtime home path (UNC for WSL) — ledger key + config reads. */ - runtimeHomePath: string - /** Host-visible config.toml path holding the trust entries. */ - tomlPath: string - /** Exact command string written to the managed hooks.json entries. */ - managedCommand: string - /** Managed trust identities Orca just wrote (no trustedHash). */ - managedEntries: readonly CodexTrustEntry[] - host: CodexTrustGrantHost - telemetryLane: CodexTrustGrantTelemetryLane - /** Match a pane where CODEX_HOME is absent instead of an explicit managed home. */ - useDefaultCodexHome?: boolean -} - +export type { CodexManagedTrustGrantPlan } export type { CodexTrustGrantFallbackReason, CodexTrustGrantTelemetryLane } export type CodexManagedTrustGrantOutcome = @@ -77,11 +73,14 @@ const transientRetryAfterByHost = new Map() export const getCodexTrustGrantDiagnostics = (): CodexTrustGrantDiagnostics => ({ ...diagnostics }) -type GrantSessionRunnerSync = ( +type GrantSessionRunner = ( request: CodexHookTrustGrantRequest -) => CodexHookTrustGrantSessionResult +) => Promise -let runSessionSync: GrantSessionRunnerSync = runCodexHookTrustGrantSessionSync +// Why (#16441): the session runs in-process on the main thread's event loop. +// It used to be forked through spawnSync purely to donate an event loop to a +// deliberately-blocked parent, which froze the window for the whole deadline. +let runSession: GrantSessionRunner = runCodexHookTrustGrantSession function fallback( plan: CodexManagedTrustGrantPlan, @@ -109,60 +108,141 @@ function fallback( return { lane: 'fallback', reason } } -type ExpectedManagedEntry = { - entry: CodexTrustEntry - normalizedKey: string - signature: string +function startTransientCooldown(hostKey: CodexAppServerHostKey): void { + transientRetryAfterByHost.set(hostKey, Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS) } -function buildExpectedEntries(plan: CodexManagedTrustGrantPlan): ExpectedManagedEntry[] { - return plan.managedEntries.map((entry) => ({ - entry, - normalizedKey: normalizeHookTrustKeyForLookup(computeTrustKey(entry)), - signature: getCodexHookTrustSignature(entry) - })) +type GrantAttempt = { + plan: CodexManagedTrustGrantPlan + expected: ExpectedManagedEntry[] + hostKey: CodexAppServerHostKey + currentStamp: CodexTrustGrantBinaryStamp | null + configSnapshot: ReturnType + startedAtMs: number } -function removeSelfComputedTrustBeforeGrant(plan: CodexManagedTrustGrantPlan): void { - const trustStates = readHookTrustEntries(plan.tomlPath) - const ownedKeys = plan.managedEntries - .map((entry) => { - const key = computeTrustKey(entry) - return trustStates.get(key)?.trustedHash === computeTrustedHash(entry) ? key : null - }) - .filter((key): key is string => key !== null) - if (ownedKeys.length > 0) { - removeHookTrustEntries(plan.tomlPath, ownedKeys) +/** Post-session verification, ledger persistence and telemetry. Never throws for + * a verify failure — every rejection is a rolled-back fallback. */ +function completeGrant( + attempt: GrantAttempt, + result: CodexHookTrustGrantSessionResult +): CodexManagedTrustGrantOutcome { + const { plan, expected, hostKey, configSnapshot } = attempt + const rejectGrant = ( + detail: unknown, + verifyClass: CodexTrustGrantVerifyClass + ): CodexManagedTrustGrantOutcome => { + restoreCodexTrustConfig(plan.tomlPath, configSnapshot) + startTransientCooldown(hostKey) + return fallback(plan, 'verify-failed', detail, verifyClass) } + if (result.outcome === 'verify-failed') { + return rejectGrant(result.reason, result.reasonClass) + } + + const byNormalizedKey = new Map(expected.map((item) => [item.normalizedKey, item])) + const seenNormalizedKeys = new Set() + const grantedEntries: CodexTrustEntry[] = [] + const ledgerRecord: Record = {} + for (const granted of result.entries) { + const match = byNormalizedKey.get(granted.normalizedKey) + if (!match) { + return rejectGrant(`unexpected granted key ${granted.key}`, 'unexpected-key') + } + if (seenNormalizedKeys.has(granted.normalizedKey)) { + return rejectGrant(`duplicate granted key ${granted.key}`, 'duplicate-key') + } + seenNormalizedKeys.add(granted.normalizedKey) + grantedEntries.push({ ...match.entry, trustedHash: granted.trustedHash }) + ledgerRecord[granted.normalizedKey] = { + signature: match.signature, + trustedHash: granted.trustedHash + } + } + if (seenNormalizedKeys.size !== expected.length) { + return rejectGrant('granted entry set did not cover expected entries', 'coverage') + } + transientRetryAfterByHost.delete(hostKey) + try { + writeCodexTrustGrantLedgerHome(plan.runtimeHomePath, { + binary: attempt.currentStamp, + entries: ledgerRecord + }) + } catch (error) { + // Why: a ledger write failure only costs an extra session next launch. + console.warn('[codex-trust-grant] failed to persist grant ledger', error) + } + diagnostics.granted += 1 + console.log( + `[codex-trust-grant] granted ${grantedEntries.length} managed hook entries via codex app-server ` + + `(host=${plan.host.kind}, wrote=${result.wroteTrust}, ${Date.now() - attempt.startedAtMs}ms)` + ) + emitCodexTrustGrantTelemetry({ + outcome: 'granted', + hostKind: plan.host.kind, + lane: plan.telemetryLane + }) + return { lane: 'rpc', entries: grantedEntries } } -function findLedgerGrant( +async function runGrantAttempt( plan: CodexManagedTrustGrantPlan, expected: ExpectedManagedEntry[], - currentStamp: CodexTrustGrantBinaryStamp | null -): CodexTrustEntry[] | null { - const home = readCodexTrustGrantLedgerHomeMatchingStamp(plan.runtimeHomePath, currentStamp) - if (!home) { - return null + resolvedHost: ResolvedCodexTrustGrantHost, + hostKey: CodexAppServerHostKey +): Promise { + // Why: the RPC may rewrite config.toml before a later RPC fails. Restore its + // exact pre-session bytes before the legacy lane runs so every fallback has + // the same input and output as the pre-RPC implementation. + const attempt: GrantAttempt = { + plan, + expected, + hostKey, + currentStamp: resolvedHost.binaryStamp, + configSnapshot: captureCodexTrustConfig(plan.tomlPath), + startedAtMs: Date.now() } - let trustStates: ReturnType + let unsupportedError: unknown try { - trustStates = readHookTrustEntries(plan.tomlPath) - } catch { - return null + return await codexAppServerCapabilityCache.runWithFallback( + hostKey, + async () => { + removeSelfComputedTrustBeforeGrant(plan) + return completeGrant( + attempt, + await runSession( + resolvedHost.buildRequest({ + runtimeHomePath: plan.runtimeHomePath, + managedCommand: plan.managedCommand, + expectedTrustKeys: expected.map(({ normalizedKey }) => normalizedKey), + useDefaultCodexHome: plan.useDefaultCodexHome + }) + ) + ) + }, + async () => { + if (unsupportedError === undefined) { + // Why: a concurrent launch's probe proved the surface missing while + // this one waited behind it; nothing was mutated, so nothing to undo. + return fallback(plan, 'unsupported-cached') + } + restoreCodexTrustConfig(plan.tomlPath, attempt.configSnapshot) + transientRetryAfterByHost.delete(hostKey) + return fallback(plan, 'unsupported', unsupportedError) + }, + (error) => { + if (!isCodexAppServerUnsupportedError(error)) { + return false + } + unsupportedError = error + return true + } + ) + } catch (error) { + restoreCodexTrustConfig(plan.tomlPath, attempt.configSnapshot) + startTransientCooldown(hostKey) + return fallback(plan, 'error', error) } - const entries: CodexTrustEntry[] = [] - for (const { entry, normalizedKey, signature } of expected) { - const recorded = home.entries[normalizedKey] - if (!recorded || recorded.signature !== signature) { - return null - } - if (trustStates.get(normalizedKey)?.trustedHash !== recorded.trustedHash) { - return null - } - entries.push({ ...entry, trustedHash: recorded.trustedHash }) - } - return entries } /** @@ -173,9 +253,9 @@ function findLedgerGrant( * throws: any unexpected failure is a fallback, because hook install is * best-effort launch prep. */ -export function grantManagedCodexHookTrust( +export async function grantManagedCodexHookTrust( plan: CodexManagedTrustGrantPlan -): CodexManagedTrustGrantOutcome { +): Promise { try { if (process.env[DISABLE_ENV_FLAG] === '1') { return fallback(plan, 'disabled') @@ -184,9 +264,8 @@ export function grantManagedCodexHookTrust( return fallback(plan, 'no-managed-entries') } const expected = buildExpectedEntries(plan) - const resolvedHost = resolveCodexTrustGrantHost(plan.host) - const currentStamp = resolvedHost.binaryStamp - const ledgerEntries = findLedgerGrant(plan, expected, currentStamp) + const resolvedHost = await resolveCodexTrustGrantHost(plan.host) + const ledgerEntries = findLedgerGrant(plan, expected, resolvedHost.binaryStamp) if (ledgerEntries !== null) { diagnostics.ledgerHits += 1 return { lane: 'rpc', entries: ledgerEntries } @@ -207,131 +286,17 @@ export function grantManagedCodexHookTrust( } transientRetryAfterByHost.delete(hostKey) } - - const startedAtMs = Date.now() - // Why: the RPC may rewrite config.toml before a later RPC fails. Restore - // its exact pre-session bytes before the legacy lane runs so every fallback - // has the same input and output as the pre-RPC implementation. - const configSnapshot = captureCodexTrustConfig(plan.tomlPath) - let result: CodexHookTrustGrantSessionResult - try { - // Why: Windows fallback writes equivalent separator variants that Codex's - // canonical RPC key may not overwrite, leaving conflicting logical trust. - removeSelfComputedTrustBeforeGrant(plan) - result = runSessionSync( - resolvedHost.buildRequest({ - runtimeHomePath: plan.runtimeHomePath, - managedCommand: plan.managedCommand, - expectedTrustKeys: expected.map(({ normalizedKey }) => normalizedKey), - useDefaultCodexHome: plan.useDefaultCodexHome - }) - ) - } catch (error) { - restoreCodexTrustConfig(plan.tomlPath, configSnapshot) - if (isCodexAppServerUnsupportedError(error)) { - transientRetryAfterByHost.delete(hostKey) - codexAppServerCapabilityCache.rememberUnsupported(hostKey) - return fallback(plan, 'unsupported', error) - } - transientRetryAfterByHost.set( - hostKey, - Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS - ) - return fallback(plan, 'error', error) - } - // Why: the RPC surface answered, even if our entries were not verifiable — - // remember support so a later drift event retries the preferred lane. - codexAppServerCapabilityCache.rememberSupported(hostKey) - if (result.outcome === 'verify-failed') { - restoreCodexTrustConfig(plan.tomlPath, configSnapshot) - transientRetryAfterByHost.set( - hostKey, - Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS - ) - return fallback(plan, 'verify-failed', result.reason, result.reasonClass) - } - - const byNormalizedKey = new Map(expected.map((item) => [item.normalizedKey, item])) - const seenNormalizedKeys = new Set() - const grantedEntries: CodexTrustEntry[] = [] - const ledgerRecord: Record = {} - for (const granted of result.entries) { - const match = byNormalizedKey.get(granted.normalizedKey) - if (!match) { - restoreCodexTrustConfig(plan.tomlPath, configSnapshot) - transientRetryAfterByHost.set( - hostKey, - Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS - ) - return fallback( - plan, - 'verify-failed', - `unexpected granted key ${granted.key}`, - 'unexpected-key' - ) - } - if (seenNormalizedKeys.has(granted.normalizedKey)) { - restoreCodexTrustConfig(plan.tomlPath, configSnapshot) - transientRetryAfterByHost.set( - hostKey, - Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS - ) - return fallback( - plan, - 'verify-failed', - `duplicate granted key ${granted.key}`, - 'duplicate-key' - ) - } - seenNormalizedKeys.add(granted.normalizedKey) - grantedEntries.push({ ...match.entry, trustedHash: granted.trustedHash }) - ledgerRecord[granted.normalizedKey] = { - signature: match.signature, - trustedHash: granted.trustedHash - } - } - if (seenNormalizedKeys.size !== expected.length) { - restoreCodexTrustConfig(plan.tomlPath, configSnapshot) - transientRetryAfterByHost.set( - hostKey, - Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS - ) - return fallback( - plan, - 'verify-failed', - 'granted entry set did not cover expected entries', - 'coverage' - ) - } - transientRetryAfterByHost.delete(hostKey) - try { - writeCodexTrustGrantLedgerHome(plan.runtimeHomePath, { - binary: currentStamp, - entries: ledgerRecord - }) - } catch (error) { - // Why: a ledger write failure only costs an extra session next launch. - console.warn('[codex-trust-grant] failed to persist grant ledger', error) - } - diagnostics.granted += 1 - console.log( - `[codex-trust-grant] granted ${grantedEntries.length} managed hook entries via codex app-server ` + - `(host=${plan.host.kind}, wrote=${result.wroteTrust}, ${Date.now() - startedAtMs}ms)` + return await runExclusivelyForCodexTrustConfig(plan.tomlPath, () => + runGrantAttempt(plan, expected, resolvedHost, hostKey) ) - emitCodexTrustGrantTelemetry({ - outcome: 'granted', - hostKind: plan.host.kind, - lane: plan.telemetryLane - }) - return { lane: 'rpc', entries: grantedEntries } } catch (error) { return fallback(plan, 'error', error) } } export const _internals = { - setGrantSessionRunnerSync(runner: GrantSessionRunnerSync | null): void { - runSessionSync = runner ?? runCodexHookTrustGrantSessionSync + setGrantSessionRunner(runner: GrantSessionRunner | null): void { + runSession = runner ?? runCodexHookTrustGrantSession }, resetDiagnostics(): void { diagnostics.granted = 0 diff --git a/src/main/codex/codex-managed-trust-grant-plan.ts b/src/main/codex/codex-managed-trust-grant-plan.ts new file mode 100644 index 00000000000..e816b0cbff5 --- /dev/null +++ b/src/main/codex/codex-managed-trust-grant-plan.ts @@ -0,0 +1,90 @@ +import type { CodexTrustGrantTelemetryLane } from './codex-trust-grant-telemetry' +import { + readCodexTrustGrantLedgerHomeMatchingStamp, + type CodexTrustGrantHost +} from './codex-trust-grant-host' +import type { CodexTrustGrantBinaryStamp } from './codex-trust-grant-ledger' +import { getCodexHookTrustSignature } from './codex-hook-identity' +import { + computeTrustKey, + computeTrustedHash, + normalizeHookTrustKeyForLookup, + readHookTrustEntries, + removeHookTrustEntries, + type CodexTrustEntry +} from './config-toml-trust' + +export type CodexManagedTrustGrantPlan = { + /** Host-visible runtime home path (UNC for WSL) — ledger key + config reads. */ + runtimeHomePath: string + /** Host-visible config.toml path holding the trust entries. */ + tomlPath: string + /** Exact command string written to the managed hooks.json entries. */ + managedCommand: string + /** Managed trust identities Orca just wrote (no trustedHash). */ + managedEntries: readonly CodexTrustEntry[] + host: CodexTrustGrantHost + telemetryLane: CodexTrustGrantTelemetryLane + /** Match a pane where CODEX_HOME is absent instead of an explicit managed home. */ + useDefaultCodexHome?: boolean +} + +export type ExpectedManagedEntry = { + entry: CodexTrustEntry + normalizedKey: string + signature: string +} + +export function buildExpectedEntries(plan: CodexManagedTrustGrantPlan): ExpectedManagedEntry[] { + return plan.managedEntries.map((entry) => ({ + entry, + normalizedKey: normalizeHookTrustKeyForLookup(computeTrustKey(entry)), + signature: getCodexHookTrustSignature(entry) + })) +} + +/** Windows fallback writes equivalent separator variants that Codex's canonical + * RPC key may not overwrite, leaving conflicting logical trust behind. */ +export function removeSelfComputedTrustBeforeGrant(plan: CodexManagedTrustGrantPlan): void { + const trustStates = readHookTrustEntries(plan.tomlPath) + const ownedKeys = plan.managedEntries + .map((entry) => { + const key = computeTrustKey(entry) + return trustStates.get(key)?.trustedHash === computeTrustedHash(entry) ? key : null + }) + .filter((key): key is string => key !== null) + if (ownedKeys.length > 0) { + removeHookTrustEntries(plan.tomlPath, ownedKeys) + } +} + +/** Entries a prior grant already recorded for this exact binary and config + * state, or null when the RPC session has to run again. */ +export function findLedgerGrant( + plan: CodexManagedTrustGrantPlan, + expected: ExpectedManagedEntry[], + currentStamp: CodexTrustGrantBinaryStamp | null +): CodexTrustEntry[] | null { + const home = readCodexTrustGrantLedgerHomeMatchingStamp(plan.runtimeHomePath, currentStamp) + if (!home) { + return null + } + let trustStates: ReturnType + try { + trustStates = readHookTrustEntries(plan.tomlPath) + } catch { + return null + } + const entries: CodexTrustEntry[] = [] + for (const { entry, normalizedKey, signature } of expected) { + const recorded = home.entries[normalizedKey] + if (!recorded || recorded.signature !== signature) { + return null + } + if (trustStates.get(normalizedKey)?.trustedHash !== recorded.trustedHash) { + return null + } + entries.push({ ...entry, trustedHash: recorded.trustedHash }) + } + return entries +} diff --git a/src/main/codex/codex-real-home-hook-install.test.ts b/src/main/codex/codex-real-home-hook-install.test.ts index b108a0b5250..ed0a184163c 100644 --- a/src/main/codex/codex-real-home-hook-install.test.ts +++ b/src/main/codex/codex-real-home-hook-install.test.ts @@ -87,7 +87,7 @@ beforeEach(() => { }) afterEach(() => { - rebaseInternals.setSessionRunnerSync(null) + rebaseInternals.setSessionRunner(null) rebaseInternals.resetRetryState() rmSync(fakeHomeDir, { recursive: true, force: true }) rmSync(userDataDir, { recursive: true, force: true }) @@ -100,10 +100,30 @@ afterEach(() => { }) describe('ensureRealHomeCodexHookState (install)', () => { - it('creates hooks.json with the Orca entry in every managed event for a fresh home', () => { + // Why (#16441): the ensure chain is process-wide; a rejection that escapes it + // would return the same rejected promise to every later pane launch, with no + // retry and no cooldown recovery. + it('recovers from a home-resolution failure instead of poisoning later ensures', async () => { + grantSucceeds() + homedirMock.mockImplementationOnce(() => { + throw new Error('home unavailable') + }) + + await expect( + ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + ).resolves.toBe('unavailable') + await expect( + ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir }) + ).resolves.toBe('removed') + }) + + it('creates hooks.json with the Orca entry in every managed event for a fresh home', async () => { grantSucceeds() - const lane = ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + const lane = await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: userDataDir + }) expect(lane).toBe('installed') const material = getCodexManagedHookInstallMaterial() @@ -121,7 +141,7 @@ describe('ensureRealHomeCodexHookState (install)', () => { expect(plan.managedEntries.every((entry) => entry.groupIndex === 0)).toBe(true) }) - it('keeps a symlinked default home logical in the keys sent to Codex', () => { + it('keeps a symlinked default home logical in the keys sent to Codex', async () => { grantSucceeds() const logicalHome = join(fakeHomeDir, '.codex') const targetHome = join(fakeHomeDir, 'dotfiles-codex') @@ -129,9 +149,9 @@ describe('ensureRealHomeCodexHookState (install)', () => { mkdirSync(targetHome) symlinkSync(targetHome, logicalHome, process.platform === 'win32' ? 'junction' : 'dir') - expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe( - 'installed' - ) + expect( + await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + ).toBe('installed') const plan = grantMock.mock.calls[0]![0] as CodexManagedTrustGrantPlan expect( @@ -139,7 +159,7 @@ describe('ensureRealHomeCodexHookState (install)', () => { ).toBe(true) }) - it('keeps the managed lane for unknown top-level fields Codex cannot load', () => { + it('keeps the managed lane for unknown top-level fields Codex cannot load', async () => { grantSucceeds() const userConfig = { hooks: { @@ -151,7 +171,10 @@ describe('ensureRealHomeCodexHookState (install)', () => { const original = `${JSON.stringify(userConfig, null, 2)}\n` writeFileSync(getRealHooksJsonPath(), original, 'utf-8') - const lane = ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + const lane = await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: userDataDir + }) expect(lane).toBe('unavailable') expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe(original) @@ -161,7 +184,7 @@ describe('ensureRealHomeCodexHookState (install)', () => { ) }) - it('appends LAST and preserves user entries and trust positions', () => { + it('appends LAST and preserves user entries and trust positions', async () => { grantSucceeds() const userConfig = { hooks: { @@ -172,9 +195,9 @@ describe('ensureRealHomeCodexHookState (install)', () => { const original = `${JSON.stringify(userConfig, null, 2)}\n` writeFileSync(getRealHooksJsonPath(), original, 'utf-8') - expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe( - 'installed' - ) + expect( + await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + ).toBe('installed') const config = readRealHooksJson() expect(config.hooks?.Stop).toHaveLength(2) @@ -190,7 +213,7 @@ describe('ensureRealHomeCodexHookState (install)', () => { // Why: ordinary Windows CI tokens cannot create file symlinks without Developer Mode. it.skipIf(process.platform === 'win32')( 'updates a symlinked hooks.json target without replacing the symlink', - () => { + async () => { grantSucceeds() const dotfilesDir = join(fakeHomeDir, 'dotfiles') const targetPath = join(dotfilesDir, 'hooks.json') @@ -202,78 +225,87 @@ describe('ensureRealHomeCodexHookState (install)', () => { ) symlinkSync(targetPath, getRealHooksJsonPath()) - expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe( - 'installed' - ) + expect( + await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + ).toBe('installed') expect(lstatSync(getRealHooksJsonPath()).isSymbolicLink()).toBe(true) expect(JSON.parse(readFileSync(targetPath, 'utf-8')).hooks.Stop).toHaveLength(2) } ) - it('keeps the managed lane and original bytes when the pristine backup cannot be created', () => { + it('keeps the managed lane and original bytes when the pristine backup cannot be created', async () => { grantSucceeds() const original = `${JSON.stringify({ hooks: { Stop: [] } }, null, 2)}\n` writeFileSync(getRealHooksJsonPath(), original, 'utf-8') writeFileSync(join(userDataDir, 'codex-real-home-hooks'), 'blocks backup directory', 'utf-8') - expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe( - 'unavailable' - ) + expect( + await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + ).toBe('unavailable') expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe(original) expect(grantMock).not.toHaveBeenCalled() }) - it.skipIf(process.platform === 'win32')('preserves restrictive hooks.json permissions', () => { - grantSucceeds() - writeFileSync(getRealHooksJsonPath(), '{ "hooks": {} }\n', 'utf-8') - chmodSync(getRealHooksJsonPath(), 0o600) - - expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe( - 'installed' - ) - - expect(statSync(getRealHooksJsonPath()).mode & 0o777).toBe(0o600) - }) - it.skipIf(process.platform === 'win32')( - 'restores restrictive hooks.json permissions after grant fallback', - () => { - grantUnavailable() + 'preserves restrictive hooks.json permissions', + async () => { + grantSucceeds() writeFileSync(getRealHooksJsonPath(), '{ "hooks": {} }\n', 'utf-8') chmodSync(getRealHooksJsonPath(), 0o600) - expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe( - 'unavailable' - ) + expect( + await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + ).toBe('installed') expect(statSync(getRealHooksJsonPath()).mode & 0o777).toBe(0o600) } ) - it('rolls the file back byte-exactly when the grant lane is unavailable', () => { + it.skipIf(process.platform === 'win32')( + 'restores restrictive hooks.json permissions after grant fallback', + async () => { + grantUnavailable() + writeFileSync(getRealHooksJsonPath(), '{ "hooks": {} }\n', 'utf-8') + chmodSync(getRealHooksJsonPath(), 0o600) + + expect( + await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + ).toBe('unavailable') + + expect(statSync(getRealHooksJsonPath()).mode & 0o777).toBe(0o600) + } + ) + + it('rolls the file back byte-exactly when the grant lane is unavailable', async () => { grantUnavailable() const userRaw = `${JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'mine.sh' }] }] } }, null, 2)}\n` writeFileSync(getRealHooksJsonPath(), userRaw, 'utf-8') - const lane = ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + const lane = await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: userDataDir + }) expect(lane).toBe('unavailable') expect(getRealHomeCodexHookLane()).toBe('unavailable') expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe(userRaw) }) - it('removes a freshly created hooks.json when the grant lane is unavailable', () => { + it('removes a freshly created hooks.json when the grant lane is unavailable', async () => { grantUnavailable() - const lane = ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + const lane = await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: userDataDir + }) expect(lane).toBe('unavailable') expect(existsSync(getRealHooksJsonPath())).toBe(false) }) - it('surfaces rollback failures to the retry boundary', () => { + it('surfaces rollback failures to the retry boundary', async () => { const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) grantMock.mockImplementation(() => { rmSync(getRealHooksJsonPath()) @@ -281,9 +313,9 @@ describe('ensureRealHomeCodexHookState (install)', () => { return { lane: 'fallback', reason: 'unsupported' } }) - expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe( - 'unavailable' - ) + expect( + await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + ).toBe('unavailable') expect(warning).toHaveBeenCalledWith( '[codex-real-home-hooks] ensure failed; staying on managed lane:', @@ -291,43 +323,49 @@ describe('ensureRealHomeCodexHookState (install)', () => { ) }) - it('does no hook-file or grant work on repeated unsupported launches', () => { + it('does no hook-file or grant work on repeated unsupported launches', async () => { grantUnavailable() - expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe( - 'unavailable' - ) + expect( + await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + ).toBe('unavailable') expect(existsSync(getRealHooksJsonPath())).toBe(false) - expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe( - 'unavailable' - ) + expect( + await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + ).toBe('unavailable') expect(grantMock).toHaveBeenCalledTimes(1) expect(existsSync(getRealHooksJsonPath())).toBe(false) }) - it('leaves an unparseable hooks.json untouched and keeps the managed lane', () => { + it('leaves an unparseable hooks.json untouched and keeps the managed lane', async () => { writeFileSync(getRealHooksJsonPath(), '{not json', 'utf-8') - const lane = ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + const lane = await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: userDataDir + }) expect(lane).toBe('unavailable') expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe('{not json') expect(grantMock).not.toHaveBeenCalled() }) - it('is idempotent: a second ensure keeps a single appended entry per event', () => { + it('is idempotent: a second ensure keeps a single appended entry per event', async () => { grantSucceeds() - ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) const firstRaw = readFileSync(getRealHooksJsonPath(), 'utf-8') - const lane = ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + const lane = await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: userDataDir + }) expect(lane).toBe('installed') expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe(firstRaw) }) - it('keeps later user hook trust positions stable when reconciling an existing install', () => { + it('keeps later user hook trust positions stable when reconciling an existing install', async () => { grantSucceeds() const userBefore = { hooks: [{ type: 'command', command: 'before.sh' }] } writeFileSync( @@ -335,15 +373,15 @@ describe('ensureRealHomeCodexHookState (install)', () => { `${JSON.stringify({ hooks: { Stop: [userBefore] } }, null, 2)}\n`, 'utf-8' ) - ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) const installed = readRealHooksJson() const userAfter = { hooks: [{ type: 'command', command: 'after.sh' }] } installed.hooks!.Stop!.push(userAfter) writeFileSync(getRealHooksJsonPath(), `${JSON.stringify(installed, null, 2)}\n`, 'utf-8') - expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe( - 'installed' - ) + expect( + await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + ).toBe('installed') const reconciled = readRealHooksJson().hooks?.Stop expect(reconciled?.[0]).toEqual(userBefore) @@ -352,17 +390,17 @@ describe('ensureRealHomeCodexHookState (install)', () => { expect(plan.managedEntries.find((entry) => entry.eventLabel === 'stop')?.groupIndex).toBe(1) }) - it("keeps later user handler trust positions stable inside Orca's hook group", () => { + it("keeps later user handler trust positions stable inside Orca's hook group", async () => { grantSucceeds() - ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) const installed = readRealHooksJson() const userAfter = { type: 'command', command: 'after.sh' } installed.hooks!.Stop![0]!.hooks!.push(userAfter) writeFileSync(getRealHooksJsonPath(), `${JSON.stringify(installed, null, 2)}\n`, 'utf-8') - expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe( - 'installed' - ) + expect( + await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + ).toBe('installed') expect(readRealHooksJson().hooks?.Stop?.[0]?.hooks?.[1]).toEqual(userAfter) const plan = grantMock.mock.calls.at(-1)![0] as CodexManagedTrustGrantPlan @@ -372,37 +410,37 @@ describe('ensureRealHomeCodexHookState (install)', () => { }) describe('ensureRealHomeCodexHookState (opt-out sweep)', () => { - it('keeps the managed lane when hooks.json cannot be read', () => { + it('keeps the managed lane when hooks.json cannot be read', async () => { mkdirSync(getRealHooksJsonPath()) - expect(ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir })).toBe( - 'unavailable' - ) + expect( + await ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir }) + ).toBe('unavailable') }) - it('keeps the managed lane when hooks.json is malformed', () => { + it('keeps the managed lane when hooks.json is malformed', async () => { writeFileSync(getRealHooksJsonPath(), '{ not json', 'utf-8') - expect(ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir })).toBe( - 'unavailable' - ) + expect( + await ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir }) + ).toBe('unavailable') expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe('{ not json') }) - it('rebases trust when a user appended hooks after Orca installed', () => { + it('rebases trust when a user appended hooks after Orca installed', async () => { grantSucceeds() const before = { type: 'command', command: 'before.sh' } writeFileSync( getRealHooksJsonPath(), `${JSON.stringify({ hooks: { Stop: [{ hooks: [before] }] } }, null, 2)}\n` ) - ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) const installed = readRealHooksJson() const after = { type: 'command', command: 'after.sh' } installed.hooks!.Stop!.push({ hooks: [after] }) writeFileSync(getRealHooksJsonPath(), `${JSON.stringify(installed, null, 2)}\n`) const operations: string[] = [] - rebaseInternals.setSessionRunnerSync((request) => { + rebaseInternals.setSessionRunner(async (request) => { operations.push(request.operation) if (request.operation === 'inspect-user-hook-trust') { expect(readRealHooksJson().hooks?.Stop?.[2]?.hooks?.[0]?.command).toBe('after.sh') @@ -420,21 +458,21 @@ describe('ensureRealHomeCodexHookState (opt-out sweep)', () => { return { outcome: 'repaired', repaired: 1 } }) - expect(ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir })).toBe( - 'removed' - ) + expect( + await ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir }) + ).toBe('removed') expect(operations).toEqual(['inspect-user-hook-trust', 'repair-user-hook-trust']) expect(readRealHooksJson().hooks?.Stop).toEqual([{ hooks: [before] }, { hooks: [after] }]) }) - it('aborts without writing when hooks.json changes during the trust inspection', () => { + it('aborts without writing when hooks.json changes during the trust inspection', async () => { grantSucceeds() const before = { type: 'command', command: 'before.sh' } writeFileSync( getRealHooksJsonPath(), `${JSON.stringify({ hooks: { Stop: [{ hooks: [before] }] } }, null, 2)}\n` ) - ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) const installed = readRealHooksJson() const after = { type: 'command', command: 'after.sh' } installed.hooks!.Stop!.push({ hooks: [after] }) @@ -443,7 +481,7 @@ describe('ensureRealHomeCodexHookState (opt-out sweep)', () => { writeFileSync(getRealConfigTomlPath(), userTrustToml, 'utf-8') const concurrentSave = `${JSON.stringify({ hooks: { Stop: [{ hooks: [before] }] } }, null, 2)}\n` const operations: string[] = [] - rebaseInternals.setSessionRunnerSync((request) => { + rebaseInternals.setSessionRunner(async (request) => { operations.push(request.operation) // A user save (or a second Orca instance) lands while the RPC runs. writeFileSync(getRealHooksJsonPath(), concurrentSave, 'utf-8') @@ -458,16 +496,16 @@ describe('ensureRealHomeCodexHookState (opt-out sweep)', () => { } }) - expect(ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir })).toBe( - 'unavailable' - ) + expect( + await ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir }) + ).toBe('unavailable') expect(operations).toEqual(['inspect-user-hook-trust']) expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe(concurrentSave) expect(readFileSync(getRealConfigTomlPath(), 'utf-8')).toBe(userTrustToml) }) - it('removes only Orca entries and reports the removed lane', () => { + it('removes only Orca entries and reports the removed lane', async () => { grantSucceeds() const userStop = { matcher: 'deploy-*', @@ -478,10 +516,13 @@ describe('ensureRealHomeCodexHookState (opt-out sweep)', () => { `${JSON.stringify({ hooks: { Stop: [userStop] } }, null, 2)}\n`, 'utf-8' ) - ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) expect(readRealHooksJson().hooks?.Stop).toHaveLength(2) - const lane = ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir }) + const lane = await ensureRealHomeCodexHookState({ + hooksEnabled: false, + userDataPath: userDataDir + }) expect(lane).toBe('removed') const config = readRealHooksJson() @@ -495,14 +536,17 @@ describe('ensureRealHomeCodexHookState (opt-out sweep)', () => { } }) - it('no-ops the sweep when the real home has no hooks.json', () => { - const lane = ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir }) + it('no-ops the sweep when the real home has no hooks.json', async () => { + const lane = await ensureRealHomeCodexHookState({ + hooksEnabled: false, + userDataPath: userDataDir + }) expect(lane).toBe('removed') expect(existsSync(getRealHooksJsonPath())).toBe(false) }) - it('removes only hash-proven Orca trust from a mixed hook group', () => { + it('removes only hash-proven Orca trust from a mixed hook group', async () => { const material = getCodexManagedHookInstallMaterial() const userCommand = 'my-user-hook.sh' writeFileSync( @@ -544,9 +588,9 @@ describe('ensureRealHomeCodexHookState (opt-out sweep)', () => { ] writeFileSync(getRealConfigTomlPath(), upsertHookTrustEntriesInContent('', entries), 'utf-8') - expect(ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir })).toBe( - 'removed' - ) + expect( + await ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir }) + ).toBe('removed') expect(readRealHooksJson().hooks?.Stop).toEqual([ { hooks: [{ type: 'command', command: userCommand }] } diff --git a/src/main/codex/codex-real-home-hook-install.ts b/src/main/codex/codex-real-home-hook-install.ts index 678526e4e60..f5ab1b279c7 100644 --- a/src/main/codex/codex-real-home-hook-install.ts +++ b/src/main/codex/codex-real-home-hook-install.ts @@ -1,8 +1,5 @@ -import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync } from 'node:fs' -import { join } from 'node:path' -import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { statSync } from 'node:fs' import { - buildManagedCommandHook, createManagedCommandMatcher, MANAGED_HOOK_TIMEOUT_SECONDS, readHooksJsonWithRaw, @@ -13,6 +10,14 @@ import { type HooksConfig } from '../agent-hooks/installer-utils' import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path' +import { + assertHooksJsonGeneration, + backupRealHomeHooksJsonOnce, + getRealHomeConfigTomlPath, + getRealHomeHooksJsonPath, + reconcileManagedHookDefinition, + restoreRealHomeHooksJson +} from './codex-real-home-hooks-json' import { getCodexManagedScriptFileName } from './codex-hook-identity' import { CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS, @@ -25,6 +30,7 @@ import { getSystemCodexHomePath } from './codex-home-paths' import type { CodexTrustEntry } from './config-toml-trust' import { restoreCodexTrustConfig } from './codex-trust-config-rollback' import { mutateRealHomeHooksPreservingUserTrust } from './codex-user-hook-trust-rebase' +import { runExclusivelyForCodexTrustConfig } from './codex-trust-config-mutation-queue' /** * Real-home Codex hook lane for the system-default selection (flag ON). @@ -42,6 +48,7 @@ export type RealHomeCodexHookLane = 'pending' | 'installed' | 'unavailable' | 'r let currentLane: RealHomeCodexHookLane = 'pending' let installRetryAfterMs = 0 +let ensureInFlight: Promise = Promise.resolve(currentLane) export function getRealHomeCodexHookLane(): RealHomeCodexHookLane { return currentLane @@ -56,52 +63,43 @@ export function isRealHomeCodexHookLaneUsable(): boolean { return currentLane !== 'unavailable' } -function getRealHomeHooksJsonPath(): string { - return join(getSystemCodexHomePath(), 'hooks.json') -} - -function getRealHomeConfigTomlPath(): string { - return join(getSystemCodexHomePath(), 'config.toml') -} - -/** Orca-side state dir; nothing extra is ever written into the user's ~/.codex. */ -function getRealHomeHookStateDir(userDataPath: string): string { - return join(userDataPath, 'codex-real-home-hooks') -} - -function assertHooksJsonGeneration( - hooksJsonPath: string, - hooksWritePath: string, - expectedRaw: string | null -): void { - const currentRaw = existsSync(hooksJsonPath) ? readFileSync(hooksJsonPath, 'utf-8') : null - if (currentRaw !== expectedRaw || resolveHooksJsonWritePath(hooksJsonPath) !== hooksWritePath) { - // Why: the pre-mutation RPC can overlap a user's editor save. Abort rather - // than atomically replacing a newer file with the stale parsed snapshot. - throw new Error('Codex hooks.json changed while Orca prepared its trust repair') - } -} - /** * Ensures the real-home hook state matches the settings: installs and trusts - * the Orca status hook when enabled, sweeps it when opted out. Idempotent and - * synchronous (launch prep); repeat calls are cheap — an unchanged hooks.json - * write no-ops and a valid grant ledger skips the RPC session entirely. + * the Orca status hook when enabled, sweeps it when opted out. Idempotent; + * repeat calls are cheap — an unchanged hooks.json write no-ops and a valid + * grant ledger skips the RPC session entirely. * Never throws: any failure logs and leaves the host on the managed lane. */ export function ensureRealHomeCodexHookState(args: { hooksEnabled: boolean userDataPath: string -}): RealHomeCodexHookLane { +}): Promise { // Why: the grant client caches failed probes, but mutating and rolling back - // hooks.json before consulting it still adds synchronous work to every pane. + // hooks.json before consulting it still adds work to every pane launch. if (args.hooksEnabled && currentLane === 'unavailable' && Date.now() < installRetryAfterMs) { - return currentLane + return Promise.resolve(currentLane) } + // Why: this mutates the user's real ~/.codex and the module's lane state. + // Concurrent pane launches must not interleave two of them, and the shared + // config.toml lane keeps the rebase + grant pair atomic against the managed + // installer's legacy sweep of the same file. + const run = (): Promise => runRealHomeCodexHookEnsure(args) + // Why both handlers: a rejected predecessor must not poison every later + // ensure for the process' lifetime. + ensureInFlight = ensureInFlight.then(run, run) + return ensureInFlight +} + +async function runRealHomeCodexHookEnsure(args: { + hooksEnabled: boolean + userDataPath: string +}): Promise { try { - currentLane = args.hooksEnabled - ? installRealHomeCodexHook(args.userDataPath) - : sweepRealHomeCodexHook() + // Why inside the try: resolving the real home can throw too, and this + // function is the module's "never throws" boundary. + currentLane = await runExclusivelyForCodexTrustConfig(getRealHomeConfigTomlPath(), () => + args.hooksEnabled ? installRealHomeCodexHook(args.userDataPath) : sweepRealHomeCodexHook() + ) if (!args.hooksEnabled || currentLane === 'installed') { installRetryAfterMs = 0 } @@ -115,7 +113,7 @@ export function ensureRealHomeCodexHookState(args: { return currentLane } -function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane { +async function installRealHomeCodexHook(userDataPath: string): Promise { const material = getCodexManagedHookInstallMaterial() const hooksJsonPath = getRealHomeHooksJsonPath() const hooksWritePath = resolveHooksJsonWritePath(hooksJsonPath) @@ -175,7 +173,7 @@ function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane { backupRealHomeHooksJsonOnce(userDataPath, previousRaw) // Why: unknown top-level fields belong to the user (other managers' // metadata); unlike the managed-home writer, preserve them verbatim. - const trustConfigSnapshot = mutateRealHomeHooksPreservingUserTrust({ + const trustConfigSnapshot = await mutateRealHomeHooksPreservingUserTrust({ sourcePath: hooksJsonPath, runtimeHomePath: getSystemCodexHomePath(), tomlPath: getRealHomeConfigTomlPath(), @@ -190,7 +188,7 @@ function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane { restoreHooks: () => restoreRealHomeHooksJson(hooksWritePath, previousRaw, previousMode) }) - const grant = grantManagedCodexHookTrust({ + const grant = await grantManagedCodexHookTrust({ runtimeHomePath: getSystemCodexHomePath(), tomlPath: getRealHomeConfigTomlPath(), managedCommand: material.command, @@ -223,53 +221,13 @@ function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane { return 'unavailable' } -function reconcileManagedHookDefinition( - current: HookDefinition[], - isManagedCommand: (command: string | undefined) => boolean, - command: string -): { definitions: HookDefinition[]; groupIndex: number; handlerIndex: number } { - const directCommandKeys = ['command', 'bash', 'powershell'] as const - const hasManagedDirectCommand = current.some((definition) => - directCommandKeys.some((key) => isManagedCommand(definition[key])) - ) - const nestedLocations = current.flatMap((definition, groupIndex) => - Array.isArray(definition.hooks) - ? definition.hooks.flatMap((hook, handlerIndex) => - isManagedCommand(hook.command) ? [{ groupIndex, handlerIndex }] : [] - ) - : [] - ) - if (!hasManagedDirectCommand && nestedLocations.length === 1) { - const { groupIndex, handlerIndex } = nestedLocations[0]! - const definition = current[groupIndex]! - const hasDirectCommand = directCommandKeys.some((key) => typeof definition[key] === 'string') - if (definition.matcher === undefined && !hasDirectCommand) { - const definitions = [...current] - // Why: users can append groups or handlers after Orca's first install. - // Reusing the exact slot preserves all later positional trust keys. - const hooks = [...definition.hooks!] - hooks[handlerIndex] = buildManagedCommandHook(command) - definitions[groupIndex] = { ...definition, hooks } - return { definitions, groupIndex, handlerIndex } - } - } - - const cleaned = removeManagedCommands(current, isManagedCommand) - // Why: first install appends LAST so no existing user trust position shifts. - return { - definitions: [...cleaned, { hooks: [buildManagedCommandHook(command)] }], - groupIndex: cleaned.length, - handlerIndex: 0 - } -} - function getInstallRetryAfterMs(reason: CodexTrustGrantFallbackReason): number { return reason === 'unsupported' || reason === 'unsupported-cached' || reason === 'disabled' ? Number.POSITIVE_INFINITY : Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS } -function sweepRealHomeCodexHook(): RealHomeCodexHookLane { +async function sweepRealHomeCodexHook(): Promise { const hooksJsonPath = getRealHomeHooksJsonPath() // Why: single read — the pre-write generation guard must compare against // the exact bytes this sweep's parse came from. @@ -306,7 +264,7 @@ function sweepRealHomeCodexHook(): RealHomeCodexHookLane { if (removedAny) { const hooksWritePath = resolveHooksJsonWritePath(hooksJsonPath) const previousMode = statSync(hooksWritePath).mode - mutateRealHomeHooksPreservingUserTrust({ + await mutateRealHomeHooksPreservingUserTrust({ sourcePath: hooksJsonPath, runtimeHomePath: getSystemCodexHomePath(), tomlPath: getRealHomeConfigTomlPath(), @@ -345,41 +303,10 @@ function sweepRealHomeCodexHook(): RealHomeCodexHookLane { return 'removed' } -/** One-time pristine copy of the user's file, kept under Orca's userData. */ -function backupRealHomeHooksJsonOnce(userDataPath: string, previousRaw: string | null): void { - if (previousRaw === null) { - return - } - const backupDir = getRealHomeHookStateDir(userDataPath) - const backupPath = join(backupDir, 'hooks.json.pre-orca') - if (existsSync(backupPath)) { - return - } - // Why: this lane mutates the user's real Codex home. If the required - // pristine recovery copy cannot be created, keep the managed lane intact. - mkdirSync(backupDir, { recursive: true }) - writeFileAtomically(backupPath, previousRaw, { mode: 0o600 }) -} - -function restoreRealHomeHooksJson( - hooksJsonPath: string, - previousRaw: string | null, - previousMode?: number -): void { - if (previousRaw === null) { - if (existsSync(hooksJsonPath)) { - unlinkSync(hooksJsonPath) - } - return - } - // Why: rollback is part of the safety boundary. Use the shared atomic - // writer so Windows file-lock retries and failed-temp cleanup are covered. - writeFileAtomically(hooksJsonPath, previousRaw, { mode: previousMode }) -} - export const _internals = { setLaneForTesting(lane: RealHomeCodexHookLane): void { currentLane = lane installRetryAfterMs = 0 + ensureInFlight = Promise.resolve(lane) } } diff --git a/src/main/codex/codex-real-home-hooks-json.ts b/src/main/codex/codex-real-home-hooks-json.ts new file mode 100644 index 00000000000..0b9f63003bb --- /dev/null +++ b/src/main/codex/codex-real-home-hooks-json.ts @@ -0,0 +1,115 @@ +import { existsSync, mkdirSync, readFileSync, unlinkSync } from 'node:fs' +import { join } from 'node:path' +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { + buildManagedCommandHook, + removeManagedCommands, + type HookDefinition +} from '../agent-hooks/installer-utils' +import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path' +import { getSystemCodexHomePath } from './codex-home-paths' + +/** The user's real `~/.codex` hook files, plus the guards and rollback the + * real-home lane needs before it is allowed to mutate them. */ +export function getRealHomeHooksJsonPath(): string { + return join(getSystemCodexHomePath(), 'hooks.json') +} + +export function getRealHomeConfigTomlPath(): string { + return join(getSystemCodexHomePath(), 'config.toml') +} + +/** Orca-side state dir; nothing extra is ever written into the user's ~/.codex. */ +function getRealHomeHookStateDir(userDataPath: string): string { + return join(userDataPath, 'codex-real-home-hooks') +} + +export function assertHooksJsonGeneration( + hooksJsonPath: string, + hooksWritePath: string, + expectedRaw: string | null +): void { + const currentRaw = existsSync(hooksJsonPath) ? readFileSync(hooksJsonPath, 'utf-8') : null + if (currentRaw !== expectedRaw || resolveHooksJsonWritePath(hooksJsonPath) !== hooksWritePath) { + // Why: the pre-mutation RPC can overlap a user's editor save. Abort rather + // than atomically replacing a newer file with the stale parsed snapshot. + throw new Error('Codex hooks.json changed while Orca prepared its trust repair') + } +} + +/** One-time pristine copy of the user's file, kept under Orca's userData. */ +export function backupRealHomeHooksJsonOnce( + userDataPath: string, + previousRaw: string | null +): void { + if (previousRaw === null) { + return + } + const backupDir = getRealHomeHookStateDir(userDataPath) + const backupPath = join(backupDir, 'hooks.json.pre-orca') + if (existsSync(backupPath)) { + return + } + // Why: this lane mutates the user's real Codex home. If the required + // pristine recovery copy cannot be created, keep the managed lane intact. + mkdirSync(backupDir, { recursive: true }) + writeFileAtomically(backupPath, previousRaw, { mode: 0o600 }) +} + +export function restoreRealHomeHooksJson( + hooksJsonPath: string, + previousRaw: string | null, + previousMode?: number +): void { + if (previousRaw === null) { + if (existsSync(hooksJsonPath)) { + unlinkSync(hooksJsonPath) + } + return + } + // Why: rollback is part of the safety boundary. Use the shared atomic + // writer so Windows file-lock retries and failed-temp cleanup are covered. + writeFileAtomically(hooksJsonPath, previousRaw, { mode: previousMode }) +} + +/** Places Orca's managed hook in `definitions`, reusing its existing slot when + * one is unambiguous so no later user trust position shifts. */ +export function reconcileManagedHookDefinition( + current: HookDefinition[], + isManagedCommand: (command: string | undefined) => boolean, + command: string +): { definitions: HookDefinition[]; groupIndex: number; handlerIndex: number } { + const directCommandKeys = ['command', 'bash', 'powershell'] as const + const hasManagedDirectCommand = current.some((definition) => + directCommandKeys.some((key) => isManagedCommand(definition[key])) + ) + const nestedLocations = current.flatMap((definition, groupIndex) => + Array.isArray(definition.hooks) + ? definition.hooks.flatMap((hook, handlerIndex) => + isManagedCommand(hook.command) ? [{ groupIndex, handlerIndex }] : [] + ) + : [] + ) + if (!hasManagedDirectCommand && nestedLocations.length === 1) { + const { groupIndex, handlerIndex } = nestedLocations[0]! + const definition = current[groupIndex]! + const hasDirectCommand = directCommandKeys.some((key) => typeof definition[key] === 'string') + if (definition.matcher === undefined && !hasDirectCommand) { + const definitions = [...current] + // Why: users can append groups or handlers after Orca's first install. + // Reusing the exact slot preserves all later positional trust keys. + const hooks = [...definition.hooks!] + hooks[handlerIndex] = buildManagedCommandHook(command) + definitions[groupIndex] = { ...definition, hooks } + return { definitions, groupIndex, handlerIndex } + } + } + + const cleaned = removeManagedCommands(current, isManagedCommand) + // Why: first install appends LAST so no existing user trust position shifts. + return { + definitions: [...cleaned, { hooks: [buildManagedCommandHook(command)] }], + groupIndex: cleaned.length, + handlerIndex: 0 + } +} diff --git a/src/main/codex/codex-trust-config-concurrent-launch.test.ts b/src/main/codex/codex-trust-config-concurrent-launch.test.ts new file mode 100644 index 00000000000..d08d7a54969 --- /dev/null +++ b/src/main/codex/codex-trust-config-concurrent-launch.test.ts @@ -0,0 +1,410 @@ +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 { + CodexHookTrustGrantRequest, + CodexHookTrustGrantSessionResult +} from './codex-app-server-client' +import type { CodexManagedTrustGrantPlan } from './codex-hook-trust-grant' +import type { CodexTrustEntry } from './config-toml-trust' + +const testState = { + fakeHomeDir: '', + userDataDir: '', + previousUserDataPath: undefined as string | undefined +} + +vi.mock('node:os', async () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + const actual = await vi.importActual('node:os') + return { ...actual, homedir: () => testState.fakeHomeDir } +}) + +const { CodexAppServerUnsupportedError } = await import('./codex-app-server-client') +const { codexAppServerCapabilityCache } = await import('./codex-app-server-capability-cache') +const { _internals, grantManagedCodexHookTrust } = await import('./codex-hook-trust-grant') +const { markCodexProjectTrusted } = await import('../agent-trust-presets') +const { setCodexTrustGrantTelemetry } = await import('./codex-trust-grant-telemetry') +const { + computeTrustKey, + computeTrustedHash, + normalizeHookTrustKeyForLookup, + readHookTrustEntries, + upsertHookTrustEntries +} = await import('./config-toml-trust') + +let runtimeHomeDir: string + +beforeEach(() => { + testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-concurrent-home-')) + testState.userDataDir = mkdtempSync(join(tmpdir(), 'orca-concurrent-userdata-')) + testState.previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = testState.userDataDir + runtimeHomeDir = join(testState.userDataDir, 'codex-runtime-home', 'home') + mkdirSync(runtimeHomeDir, { recursive: true }) + writeFileSync(join(runtimeHomeDir, 'hooks.json'), '{"hooks":{}}\n', 'utf-8') + mkdirSync(join(testState.fakeHomeDir, '.codex'), { recursive: true }) + codexAppServerCapabilityCache.clear() + _internals.resetDiagnostics() +}) + +afterEach(() => { + _internals.setGrantSessionRunner(null) + setCodexTrustGrantTelemetry(() => {}) + codexAppServerCapabilityCache.clear() + if (testState.previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = testState.previousUserDataPath + } + delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC + rmSync(testState.fakeHomeDir, { recursive: true, force: true }) + rmSync(testState.userDataDir, { recursive: true, force: true }) +}) + +const MANAGED_COMMAND = "/bin/sh '/tmp/orca/codex-hook.sh'" + +function managedEntry(eventLabel: CodexTrustEntry['eventLabel']): CodexTrustEntry { + return { + sourcePath: join(runtimeHomeDir, 'hooks.json'), + eventLabel, + groupIndex: 0, + handlerIndex: 0, + command: MANAGED_COMMAND, + timeoutSec: 10 + } +} + +function buildPlan( + entries: CodexTrustEntry[], + overrides: Partial = {} +): CodexManagedTrustGrantPlan { + return { + runtimeHomePath: runtimeHomeDir, + tomlPath: join(runtimeHomeDir, 'config.toml'), + managedCommand: MANAGED_COMMAND, + managedEntries: entries, + host: { kind: 'native' }, + telemetryLane: 'real-home', + ...overrides + } +} + +const tick = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)) + +/** Stands in for codex app-server: really writes the trust entries into + * config.toml across an await, like the RPC does. */ +function writingSessionRunner(args: { + tomlPath: string + entries: CodexTrustEntry[] + hashPrefix: string + gate?: Promise + outcome?: 'granted' | 'verify-failed' +}) { + return async ( + _request: CodexHookTrustGrantRequest + ): Promise => { + const granted = args.entries.map((entry) => { + const key = computeTrustKey(entry) + return { + key, + normalizedKey: normalizeHookTrustKeyForLookup(key), + trustedHash: `${args.hashPrefix}${entry.eventLabel}` + } + }) + await tick() + upsertHookTrustEntries( + args.tomlPath, + args.entries.map((entry, index) => ({ ...entry, trustedHash: granted[index].trustedHash })) + ) + if (args.gate) { + await args.gate + } + if (args.outcome === 'verify-failed') { + return { + outcome: 'verify-failed', + reason: 'listed hash mismatch', + reasonClass: 'post-grant-mismatch' + } + } + return { outcome: 'granted', wroteTrust: true, entries: granted } + } +} + +describe('two Codex pane launches against one config.toml', () => { + it('does not let a failing launch roll back a concurrent launch that already succeeded', async () => { + // Why warm: on a cold host the shared capability probe incidentally + // serializes the two launches. Once the host is known-supported that + // dedupe is bypassed and the per-file lane is the only thing left. + codexAppServerCapabilityCache.rememberSupported('native') + const tomlPath = join(runtimeHomeDir, 'config.toml') + const entries = [managedEntry('session_start')] + let sessionsInFlight = 0 + let maxSessionsInFlight = 0 + let call = 0 + let releaseFirst!: () => void + const firstGate = new Promise((resolve) => { + releaseFirst = resolve + }) + + _internals.setGrantSessionRunner(async (request) => { + sessionsInFlight += 1 + maxSessionsInFlight = Math.max(maxSessionsInFlight, sessionsInFlight) + call += 1 + const isFirst = call === 1 + try { + return await writingSessionRunner({ + tomlPath, + entries, + hashPrefix: isFirst ? 'sha256:doomed-' : 'sha256:survivor-', + gate: isFirst ? firstGate : undefined, + outcome: isFirst ? 'verify-failed' : 'granted' + })(request) + } finally { + sessionsInFlight -= 1 + } + }) + + const doomed = grantManagedCodexHookTrust(buildPlan(entries)) + const survivor = grantManagedCodexHookTrust(buildPlan(entries)) + await tick() + await tick() + releaseFirst() + + expect(await doomed).toMatchObject({ lane: 'fallback', reason: 'verify-failed' }) + expect(await survivor).toMatchObject({ lane: 'rpc' }) + // The doomed run's rollback must not resurrect the pre-grant file over + // the entries the survivor legitimately wrote. + const trust = readHookTrustEntries(tomlPath) + const key = normalizeHookTrustKeyForLookup(computeTrustKey(entries[0])) + expect(trust.get(key)?.trustedHash).toBe('sha256:survivor-session_start') + expect(maxSessionsInFlight).toBe(1) + }) + + it('keeps a concurrent markCodexProjectTrusted write out of a grant rollback window', async () => { + codexAppServerCapabilityCache.rememberSupported('native') + const tomlPath = join(runtimeHomeDir, 'config.toml') + const entries = [managedEntry('session_start')] + const workspace = mkdtempSync(join(tmpdir(), 'orca-concurrent-ws-')) + let releaseSession!: () => void + const sessionGate = new Promise((resolve) => { + releaseSession = resolve + }) + + _internals.setGrantSessionRunner( + writingSessionRunner({ + tomlPath, + entries, + hashPrefix: 'sha256:doomed-', + gate: sessionGate, + outcome: 'verify-failed' + }) + ) + + try { + const grant = grantManagedCodexHookTrust(buildPlan(entries)) + // Let the grant capture config.toml and start its session. + await tick() + await tick() + const marked = markCodexProjectTrusted(workspace) + await tick() + // The lane must hold the preset write back until rollback has run. + expect(readFileSync(tomlPath, 'utf-8')).not.toContain('trust_level') + + releaseSession() + expect(await grant).toMatchObject({ lane: 'fallback', reason: 'verify-failed' }) + await marked + + expect(readFileSync(tomlPath, 'utf-8')).toContain('trust_level = "trusted"') + } finally { + rmSync(workspace, { recursive: true, force: true }) + } + }) +}) + +describe('concurrent capability probes against a cold host', () => { + it('shares one app-server session between two launches on different config files', async () => { + const secondHome = join(testState.userDataDir, 'second-runtime-home') + mkdirSync(secondHome, { recursive: true }) + writeFileSync(join(secondHome, 'hooks.json'), '{"hooks":{}}\n', 'utf-8') + const entries = [managedEntry('session_start')] + let sessions = 0 + let releaseProbe!: () => void + const probeGate = new Promise((resolve) => { + releaseProbe = resolve + }) + _internals.setGrantSessionRunner(async () => { + sessions += 1 + await probeGate + throw new CodexAppServerUnsupportedError('hooks/grantTrust: method not found') + }) + + const first = grantManagedCodexHookTrust(buildPlan(entries)) + const second = grantManagedCodexHookTrust( + buildPlan([{ ...entries[0], sourcePath: join(secondHome, 'hooks.json') }], { + runtimeHomePath: secondHome, + tomlPath: join(secondHome, 'config.toml') + }) + ) + await tick() + await tick() + expect(sessions).toBe(1) + releaseProbe() + + expect(await first).toMatchObject({ lane: 'fallback', reason: 'unsupported' }) + expect(await second).toMatchObject({ lane: 'fallback', reason: 'unsupported-cached' }) + expect(sessions).toBe(1) + }) + + it('leaves the waiter config.toml untouched when the shared probe reports unsupported', async () => { + const secondHome = join(testState.userDataDir, 'second-runtime-home') + mkdirSync(secondHome, { recursive: true }) + writeFileSync(join(secondHome, 'hooks.json'), '{"hooks":{}}\n', 'utf-8') + const waiterToml = join(secondHome, 'config.toml') + const waiterEntry = { + ...managedEntry('session_start'), + sourcePath: join(secondHome, 'hooks.json') + } + // Self-computed trust the fallback lane already wrote for this pane. + upsertHookTrustEntries(waiterToml, [ + { ...waiterEntry, trustedHash: computeTrustedHash(waiterEntry) } + ]) + const before = readFileSync(waiterToml, 'utf-8') + + let releaseProbe!: () => void + const probeGate = new Promise((resolve) => { + releaseProbe = resolve + }) + _internals.setGrantSessionRunner(async () => { + await probeGate + throw new CodexAppServerUnsupportedError('hooks/grantTrust: method not found') + }) + + const first = grantManagedCodexHookTrust(buildPlan([managedEntry('session_start')])) + const waiter = grantManagedCodexHookTrust( + buildPlan([waiterEntry], { runtimeHomePath: secondHome, tomlPath: waiterToml }) + ) + await tick() + releaseProbe() + await first + expect(await waiter).toMatchObject({ lane: 'fallback', reason: 'unsupported-cached' }) + expect(readFileSync(waiterToml, 'utf-8')).toBe(before) + }) +}) + +describe('host-scoped transient cooldown', () => { + // Why: the cooldown lives outside the per-file lane, so a failure on one + // pane's config.toml has to suppress every other pane on that host and + // nothing on a different one. + it('suppresses a second config.toml on the same host but not another host', async () => { + const secondHome = join(testState.userDataDir, 'second-runtime-home') + mkdirSync(secondHome, { recursive: true }) + writeFileSync(join(secondHome, 'hooks.json'), '{"hooks":{}}\n', 'utf-8') + const entries = [managedEntry('session_start')] + let calls = 0 + _internals.setGrantSessionRunner(() => { + calls += 1 + throw new Error('spawn ETIMEDOUT') + }) + + expect(await grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ + lane: 'fallback', + reason: 'error' + }) + expect( + await grantManagedCodexHookTrust( + buildPlan([{ ...entries[0], sourcePath: join(secondHome, 'hooks.json') }], { + runtimeHomePath: secondHome, + tomlPath: join(secondHome, 'config.toml') + }) + ) + ).toMatchObject({ lane: 'fallback', reason: 'retry-cached' }) + expect(calls).toBe(1) + + // A WSL distro runs its own codex binary; the native cooldown must not reach it. + expect( + await grantManagedCodexHookTrust( + buildPlan(entries, { + host: { kind: 'wsl', distro: 'Ubuntu', linuxRuntimeHome: '/home/u/.codex' } + }) + ) + ).toMatchObject({ lane: 'fallback', reason: 'error' }) + expect(calls).toBe(2) + }) + + // Why: the cooldown check runs before the lane, so a launch already admitted + // can succeed after a sibling failed. That proof of health must clear the + // sibling's cooldown instead of suppressing the host for five more minutes. + it('lets a concurrent success clear a cooldown a sibling failure just set', async () => { + codexAppServerCapabilityCache.rememberSupported('native') + const secondHome = join(testState.userDataDir, 'second-runtime-home') + mkdirSync(secondHome, { recursive: true }) + writeFileSync(join(secondHome, 'hooks.json'), '{"hooks":{}}\n', 'utf-8') + const entries = [managedEntry('session_start')] + const okEntry = { ...entries[0], sourcePath: join(secondHome, 'hooks.json') } + const okToml = join(secondHome, 'config.toml') + const okPlan = buildPlan([okEntry], { runtimeHomePath: secondHome, tomlPath: okToml }) + + let releaseFailure!: () => void + const failureGate = new Promise((resolve) => { + releaseFailure = resolve + }) + let sessions = 0 + _internals.setGrantSessionRunner(async (request) => { + sessions += 1 + if (request.hooksListCwd === runtimeHomeDir) { + await failureGate + throw new Error('spawn ETIMEDOUT') + } + return writingSessionRunner({ + tomlPath: okToml, + entries: [okEntry], + hashPrefix: 'sha256:ok-' + })(request) + }) + + const failing = grantManagedCodexHookTrust(buildPlan(entries)) + const succeeding = grantManagedCodexHookTrust(okPlan) + releaseFailure() + expect(await failing).toMatchObject({ lane: 'fallback', reason: 'error' }) + expect(await succeeding).toMatchObject({ lane: 'rpc' }) + + // A later launch on the same host must reach the RPC, not the cooldown. + // The ledger is shared across runtime homes, so clear it to force a session. + rmSync(join(testState.userDataDir, 'codex-runtime-home', 'trust-grant-ledger.json'), { + force: true + }) + expect(sessions).toBe(2) + expect(await grantManagedCodexHookTrust(okPlan)).toMatchObject({ lane: 'rpc' }) + expect(sessions).toBe(3) + }) +}) + +describe('reentrancy under concurrency', () => { + it('completes a grant nested inside an installer that already holds both lanes', async () => { + const { runExclusivelyForCodexTrustConfig } = + await import('./codex-trust-config-mutation-queue') + const entries = [managedEntry('session_start')] + const tomlPath = join(runtimeHomeDir, 'config.toml') + const systemToml = join(testState.fakeHomeDir, '.codex', 'config.toml') + _internals.setGrantSessionRunner( + writingSessionRunner({ tomlPath, entries, hashPrefix: 'sha256:nested-' }) + ) + const workspace = mkdtempSync(join(tmpdir(), 'orca-nested-ws-')) + try { + // Installer lock order: runtime then system, with a grant and a preset + // write nested inside both. + const outcome = await runExclusivelyForCodexTrustConfig(tomlPath, () => + runExclusivelyForCodexTrustConfig(systemToml, async () => { + await markCodexProjectTrusted(workspace) + return grantManagedCodexHookTrust(buildPlan(entries)) + }) + ) + expect(outcome).toMatchObject({ lane: 'rpc' }) + expect(readFileSync(tomlPath, 'utf-8')).toContain('trust_level = "trusted"') + } finally { + rmSync(workspace, { recursive: true, force: true }) + } + }, 5000) +}) diff --git a/src/main/codex/codex-trust-config-mutation-queue.test.ts b/src/main/codex/codex-trust-config-mutation-queue.test.ts new file mode 100644 index 00000000000..e2d775b4ed7 --- /dev/null +++ b/src/main/codex/codex-trust-config-mutation-queue.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import { runExclusivelyForCodexTrustConfig } from './codex-trust-config-mutation-queue' + +function deferred(): { promise: Promise; resolve: () => void; reject: (e: unknown) => void } { + let resolve!: () => void + let reject!: (e: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +describe('runExclusivelyForCodexTrustConfig', () => { + // Why: the grant lane runs inside the installer that already owns the file; + // a non-reentrant lane would queue it behind itself and never settle. + it('passes through a nested acquire of a lane the caller already holds', async () => { + const nested = await runExclusivelyForCodexTrustConfig('/a/config.toml', () => + runExclusivelyForCodexTrustConfig('/a/config.toml', () => Promise.resolve('inner')) + ) + expect(nested).toBe('inner') + }) + + it('still queues an unrelated lane acquired from inside another lane', async () => { + const gate = deferred() + let innerRan = false + const blocking = runExclusivelyForCodexTrustConfig('/b/config.toml', () => gate.promise) + const nested = runExclusivelyForCodexTrustConfig('/a/config.toml', () => + runExclusivelyForCodexTrustConfig('/b/config.toml', () => { + innerRan = true + return Promise.resolve() + }) + ) + await Promise.resolve() + expect(innerRan).toBe(false) + gate.resolve() + await blocking + await nested + expect(innerRan).toBe(true) + }) + + it('runs one mutation at a time per config.toml', async () => { + const order: string[] = [] + const first = deferred() + const second = deferred() + + const a = runExclusivelyForCodexTrustConfig('/home/.codex/config.toml', async () => { + order.push('a:start') + await first.promise + order.push('a:end') + return 'a' + }) + const b = runExclusivelyForCodexTrustConfig('/home/.codex/config.toml', async () => { + order.push('b:start') + await second.promise + order.push('b:end') + return 'b' + }) + + await Promise.resolve() + expect(order).toEqual(['a:start']) + first.resolve() + await a + second.resolve() + await b + expect(order).toEqual(['a:start', 'a:end', 'b:start', 'b:end']) + }) + + it('keeps distinct config.toml paths independent', async () => { + const gate = deferred() + let secondRan = false + const blocked = runExclusivelyForCodexTrustConfig('/a/config.toml', () => gate.promise) + await runExclusivelyForCodexTrustConfig('/b/config.toml', async () => { + secondRan = true + }) + expect(secondRan).toBe(true) + gate.resolve() + await blocked + }) + + it('keeps the queue alive after a rejected mutation', async () => { + const failing = runExclusivelyForCodexTrustConfig('/a/config.toml', () => + Promise.reject(new Error('grant blew up')) + ) + await expect(failing).rejects.toThrow('grant blew up') + await expect( + runExclusivelyForCodexTrustConfig('/a/config.toml', () => Promise.resolve('next')) + ).resolves.toBe('next') + }) + + // Why: normalized keys, so a Windows caller passing the other separator or + // case must still land in the same lane as the run it has to wait for. + it('serializes equivalent paths that differ only in normalization', async () => { + const gate = deferred() + let secondStarted = false + const blocked = runExclusivelyForCodexTrustConfig( + String.raw`C:\Users\Alice\.codex\config.toml`, + () => gate.promise + ) + const queued = runExclusivelyForCodexTrustConfig('C:/Users/Alice/.codex/config.toml', () => { + secondStarted = true + return Promise.resolve() + }) + await Promise.resolve() + expect(secondStarted).toBe(false) + gate.resolve() + await blocked + await queued + expect(secondStarted).toBe(true) + }) +}) diff --git a/src/main/codex/codex-trust-config-mutation-queue.ts b/src/main/codex/codex-trust-config-mutation-queue.ts new file mode 100644 index 00000000000..08aa3db4316 --- /dev/null +++ b/src/main/codex/codex-trust-config-mutation-queue.ts @@ -0,0 +1,46 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' + +const tailByTomlPath = new Map>() +// Why: the grant lane runs inside the installer that already owns the file. +// AsyncLocalStorage survives awaits, so the inner acquire can see the outer +// one and pass through instead of queueing behind itself forever. +const heldKeys = new AsyncLocalStorage>() + +/** + * Serializes everything that mutates one Codex `config.toml` — hook installs, + * trust grants, and user-hook rebases — as a single lane per file. + * + * Why (#16441): these used to block the main thread, so two of them could + * never be in flight at once. Now that they await, a second run could write + * the file between another run's capture and its restore-on-failure, undoing + * a mutation that run never made and resurrecting trust it deliberately + * removed. + */ +export function runExclusivelyForCodexTrustConfig( + tomlPath: string, + run: () => Promise +): Promise { + const key = normalizeRuntimePathForComparison(tomlPath) + const held = heldKeys.getStore() + if (held?.has(key)) { + return run() + } + const owned = new Set(held ?? []) + owned.add(key) + const enter = (): Promise => heldKeys.run(owned, run) + const previous = tailByTomlPath.get(key) ?? Promise.resolve() + // Why both handlers: a rejected predecessor must not cancel the queue. + const result = previous.then(enter, enter) + const tail = result.then( + () => undefined, + () => undefined + ) + tailByTomlPath.set(key, tail) + void tail.then(() => { + if (tailByTomlPath.get(key) === tail) { + tailByTomlPath.delete(key) + } + }) + return result +} diff --git a/src/main/codex/codex-trust-grant-host.test.ts b/src/main/codex/codex-trust-grant-host.test.ts index 5e23add099e..5ad415e9779 100644 --- a/src/main/codex/codex-trust-grant-host.test.ts +++ b/src/main/codex/codex-trust-grant-host.test.ts @@ -1,11 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { execFileSyncMock, resolveCodexCommandMock } = vi.hoisted(() => ({ - execFileSyncMock: vi.fn(), +const { runProcessMock, resolveCodexCommandMock } = vi.hoisted(() => ({ + runProcessMock: vi.fn(), resolveCodexCommandMock: vi.fn() })) -vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock })) +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexCommandMock @@ -14,23 +14,28 @@ vi.mock('../codex-cli/command', () => ({ import { resolveCodexTrustGrantHost } from './codex-trust-grant-host' beforeEach(() => { - execFileSyncMock.mockReset() + runProcessMock.mockReset() // Stand in for the guest shell: rc banner first, then the payload inside the // command's own fence. The identity script execs, so no closing fence is written. - execFileSyncMock.mockImplementation((_command: string, args: string[]) => { - const nonce = /__ORCA_WSL_CAPTURE_BEGIN_([^_]+)__/.exec(String(args.at(-1)))?.[1] ?? '' - return ( - 'To run a command as administrator (user "root"), use "sudo ".\n\n' + - `__ORCA_WSL_CAPTURE_BEGIN_${nonce}__/home/alice/.local/bin/codex\ncodex-cli 1.2.3\n` - ) + runProcessMock.mockImplementation((spec: { args: string[] }) => { + const nonce = /__ORCA_WSL_CAPTURE_BEGIN_([^_]+)__/.exec(String(spec.args.at(-1)))?.[1] ?? '' + return Promise.resolve({ + code: 0, + signal: null, + timedOut: false, + stderr: '', + stdout: + 'To run a command as administrator (user "root"), use "sudo ".\n\n' + + `__ORCA_WSL_CAPTURE_BEGIN_${nonce}__/home/alice/.local/bin/codex\ncodex-cli 1.2.3\n` + }) }) resolveCodexCommandMock.mockReset() resolveCodexCommandMock.mockReturnValue(process.execPath) }) describe('resolveCodexTrustGrantHost', () => { - it('resolves the native command once for both the binary stamp and request', () => { - const host = resolveCodexTrustGrantHost({ kind: 'native' }) + it('resolves the native command once for both the binary stamp and request', async () => { + const host = await resolveCodexTrustGrantHost({ kind: 'native' }) const input = { runtimeHomePath: '/tmp/codex-home', managedCommand: '/bin/sh codex-hook.sh', @@ -43,11 +48,11 @@ describe('resolveCodexTrustGrantHost', () => { // Why: PATH/version-manager scans are synchronous launch-path I/O. Reusing // the resolved command keeps one grant at one scan regardless of consumers. expect(resolveCodexCommandMock).toHaveBeenCalledTimes(1) - expect(execFileSyncMock).not.toHaveBeenCalled() + expect(runProcessMock).not.toHaveBeenCalled() }) - it('builds WSL requests without scanning the native PATH', () => { - const host = resolveCodexTrustGrantHost({ + it('builds WSL requests without scanning the native PATH', async () => { + const host = await resolveCodexTrustGrantHost({ kind: 'wsl', distro: 'Ubuntu', linuxRuntimeHome: '/home/alice/.codex-runtime' @@ -65,11 +70,33 @@ describe('resolveCodexTrustGrantHost', () => { version: 'codex-cli 1.2.3' }) expect(request.invocation.command).toBe('wsl.exe') - expect(execFileSyncMock).toHaveBeenCalledWith( - 'wsl.exe', - expect.arrayContaining(['-d', 'Ubuntu', '--exec', 'sh', '-c']), - expect.objectContaining({ encoding: 'utf-8', timeout: 5_000, windowsHide: true }) + // Why (#16441): the identity probe runs through the shared async runner — + // an execFileSync here froze the Electron main thread for its full timeout. + expect(runProcessMock).toHaveBeenCalledWith( + expect.objectContaining({ + program: 'wsl.exe', + args: expect.arrayContaining(['-d', 'Ubuntu', '--exec', 'sh', '-c']), + timeoutMs: 5_000 + }) ) expect(resolveCodexCommandMock).not.toHaveBeenCalled() }) + + it('drops the stamp when the guest probe fails instead of trusting partial stdout', async () => { + runProcessMock.mockResolvedValue({ + code: 127, + signal: null, + timedOut: false, + stdout: '', + stderr: 'codex not found' + }) + + const host = await resolveCodexTrustGrantHost({ + kind: 'wsl', + distro: 'Ubuntu', + linuxRuntimeHome: '/home/alice/.codex-runtime' + }) + + expect(host.binaryStamp).toBeNull() + }) }) diff --git a/src/main/codex/codex-trust-grant-host.ts b/src/main/codex/codex-trust-grant-host.ts index c74f8f19af9..9228fad43ce 100644 --- a/src/main/codex/codex-trust-grant-host.ts +++ b/src/main/codex/codex-trust-grant-host.ts @@ -1,4 +1,4 @@ -import { execFileSync } from 'node:child_process' +import { runProcess } from '../../shared/child-process/run-process' import { resolveCodexCommand } from '../codex-cli/command' import { getSpawnArgsForWindows } from '../win32-utils' import { @@ -36,10 +36,18 @@ export type ResolvedCodexTrustGrantHost = { buildRequest: (input: CodexTrustGrantRequestInput) => CodexHookTrustGrantRequest } -export function resolveCodexTrustGrantHost(host: CodexTrustGrantHost): ResolvedCodexTrustGrantHost { +/** + * Resolves the host that runs the codex binary for a grant session. + * + * Async because the WSL identity probe shells into the distro; #16441 measured + * a 15s main-thread stall when launch prep did this work synchronously. + */ +export async function resolveCodexTrustGrantHost( + host: CodexTrustGrantHost +): Promise { if (host.kind === 'wsl') { return { - binaryStamp: buildWslCodexBinaryStamp(host.distro), + binaryStamp: await buildWslCodexBinaryStamp(host.distro), buildRequest: (input) => ({ invocation: { command: 'wsl.exe', @@ -55,6 +63,10 @@ export function resolveCodexTrustGrantHost(host: CodexTrustGrantHost): ResolvedC } } + return resolveNativeCodexTrustGrantHost() +} + +export function resolveNativeCodexTrustGrantHost(): ResolvedCodexTrustGrantHost { // Why: command resolution scans PATH/version-manager directories. Resolve // once per grant and reuse it for both the binary stamp and invocation. const command = resolveCodexCommand() @@ -81,19 +93,24 @@ export function resolveCodexTrustGrantHost(host: CodexTrustGrantHost): ResolvedC } } -function buildWslCodexBinaryStamp(distro: string): CodexTrustGrantBinaryStamp | null { +async function buildWslCodexBinaryStamp( + distro: string +): Promise { try { // Why: WSL PATH resolution happens inside the distro's login shell. The // resolved path plus CLI version detects upgrades without assuming UNC access. const probe = buildWslCodexIdentityProbe(distro) - const stdout = execFileSync('wsl.exe', probe.args, { - encoding: 'utf-8', - timeout: WSL_CODEX_AVAILABILITY_TIMEOUT_MS, - windowsHide: true + const result = await runProcess({ + program: 'wsl.exe', + args: probe.args, + timeoutMs: WSL_CODEX_AVAILABILITY_TIMEOUT_MS }) + if (result.code !== 0 || result.timedOut) { + return null + } // Why: the split below is positional, so login-shell rc output ahead of the // payload would silently become the "path" and destabilize the stamp. - const output = probe.readStdout(stdout) + const output = probe.readStdout(result.stdout) if (output === null) { return null } @@ -114,9 +131,10 @@ export function readCodexTrustGrantLedgerHomeMatchingStamp( return home && binaryStampsMatch(home.binary, currentStamp) ? home : null } -export function readCurrentCodexTrustGrantLedgerHome( - runtimeHomePath: string, - host: CodexTrustGrantHost +/** Native-only: the WSL stamp needs a subprocess, and status reads must stay + * synchronous for the hook-status readers that never target a distro. */ +export function readCurrentNativeCodexTrustGrantLedgerHome( + runtimeHomePath: string ): CodexTrustGrantLedgerHome | null { try { const home = readCodexTrustGrantLedgerHome(runtimeHomePath) @@ -125,7 +143,7 @@ export function readCurrentCodexTrustGrantLedgerHome( // and version-manager scan when there is no recorded stamp to validate. return null } - return binaryStampsMatch(home.binary, resolveCodexTrustGrantHost(host).binaryStamp) + return binaryStampsMatch(home.binary, resolveNativeCodexTrustGrantHost().binaryStamp) ? home : null } catch { diff --git a/src/main/codex/codex-trust-grant-main-thread-boundary.test.ts b/src/main/codex/codex-trust-grant-main-thread-boundary.test.ts new file mode 100644 index 00000000000..4b88d95991c --- /dev/null +++ b/src/main/codex/codex-trust-grant-main-thread-boundary.test.ts @@ -0,0 +1,60 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * Ratchet for stablyai/orca#16441. + * + * Codex hook trust used to be granted by blocking the Electron main thread on + * `spawnSync` of a bundled ELECTRON_RUN_AS_NODE entry, for the whole + * app-server deadline: 15s native, 35s WSL, ~45s on the three-session real-home + * path. The window showed "Not Responding" during cold start and pane launch. + * + * The subprocess only ever existed to donate an event loop to a deliberately + * blocked parent, so this guards the shape of the fix rather than one call + * site: nothing on the trust-grant lane may start a child process + * synchronously, and the forked entry must stay gone. + */ +const CODEX_DIR = __dirname + +const SYNC_SPAWN_PATTERN = /\b(?:spawnSync|execSync|execFileSync|runProcessSync)\s*[(<]/ + +/** Drop comments so the prose explaining the old idiom is not an offender. */ +function codeText(contents: string): string { + return contents + .split('\n') + .filter((line) => !/^\s*(?:\/\/|\/\*|\*)/.test(line)) + .join('\n') +} + +function listCodexSourceFiles(): string[] { + return readdirSync(CODEX_DIR).filter((name) => name.endsWith('.ts') && !name.endsWith('.test.ts')) +} + +describe('codex trust grant main-thread boundary', () => { + it('starts no child process synchronously anywhere in the codex module', () => { + const offenders = listCodexSourceFiles().filter((name) => + SYNC_SPAWN_PATTERN.test(codeText(readFileSync(join(CODEX_DIR, name), 'utf8'))) + ) + expect(offenders).toEqual([]) + }) + + it('keeps the forked grant entry and its blocking bridge deleted', () => { + for (const name of [ + 'codex-app-server-grant-bridge.ts', + 'codex-app-server-grant-entry.ts', + 'codex-app-server-grant-envelope.ts' + ]) { + expect(existsSync(join(CODEX_DIR, name))).toBe(false) + } + }) + + it('keeps the trust-grant lane on async entry points', () => { + const grant = readFileSync(join(CODEX_DIR, 'codex-hook-trust-grant.ts'), 'utf8') + expect(grant).toContain('export async function grantManagedCodexHookTrust(') + const host = readFileSync(join(CODEX_DIR, 'codex-trust-grant-host.ts'), 'utf8') + expect(host).toContain('export async function resolveCodexTrustGrantHost(') + const realHome = readFileSync(join(CODEX_DIR, 'codex-real-home-hook-install.ts'), 'utf8') + expect(realHome).toContain('}): Promise {') + }) +}) diff --git a/src/main/codex/codex-trust-grant-telemetry.ts b/src/main/codex/codex-trust-grant-telemetry.ts index 25265219ba8..aa40c17cba3 100644 --- a/src/main/codex/codex-trust-grant-telemetry.ts +++ b/src/main/codex/codex-trust-grant-telemetry.ts @@ -16,10 +16,9 @@ export type CodexTrustGrantFallbackReason = | 'retry-cached' | 'error' -/** Closed classification of `reason: 'error'` fallbacks. Errors cross the - * grant-bridge envelope as message text (only timeout/unsupported keep their - * name), so classes are matched on the bounded message shapes each layer - * produces — never forwarded raw. */ +/** Closed classification of `reason: 'error'` fallbacks. Only timeout and + * unsupported carry a stable error name, so the rest are matched on the + * bounded message shapes each layer produces — never forwarded raw. */ export type CodexTrustGrantErrorClass = | 'binary-missing' | 'timeout' diff --git a/src/main/codex/codex-user-hook-trust-rebase.test.ts b/src/main/codex/codex-user-hook-trust-rebase.test.ts index 89f25176d50..c3e0f760f69 100644 --- a/src/main/codex/codex-user-hook-trust-rebase.test.ts +++ b/src/main/codex/codex-user-hook-trust-rebase.test.ts @@ -27,7 +27,7 @@ beforeEach(() => { }) afterEach(() => { - _internals.setSessionRunnerSync(null) + _internals.setSessionRunner(null) _internals.resetRetryState() codexAppServerCapabilityCache.clear() rmSync(root, { recursive: true, force: true }) @@ -38,18 +38,18 @@ function command(command: string): HookCommandConfig { } describe('real-home user hook trust rebasing', () => { - it('writes directly without reading config or spawning Codex when user positions stay stable', () => { + it('writes directly without reading config or spawning Codex when user positions stay stable', async () => { const user = command('user-hook') const orca = command('orca-hook') const before = { Stop: [{ hooks: [user] }] } const after = { Stop: [{ hooks: [user] }, { hooks: [orca] }] } let wroteHooks = false - _internals.setSessionRunnerSync(() => { + _internals.setSessionRunner(() => { throw new Error('stable positions must not open an app-server session') }) expect( - mutateRealHomeHooksPreservingUserTrust({ + await mutateRealHomeHooksPreservingUserTrust({ sourcePath: hooksPath, runtimeHomePath: root, tomlPath: configPath, @@ -67,7 +67,7 @@ describe('real-home user hook trust rebasing', () => { expect(existsSync(configPath)).toBe(false) }) - it('finds multiple shifted user hooks, including a handler from a mixed group', () => { + it('finds multiple shifted user hooks, including a handler from a mixed group', async () => { const orca = command('orca-hook') const first = command('first-user') const second = command('second-user') @@ -98,7 +98,7 @@ describe('real-home user hook trust rebasing', () => { ]) }) - it('carries only previously trusted states into the repair request', () => { + it('carries only previously trusted states into the repair request', async () => { const orca = command('orca-hook') const trusted = command('trusted-user') const untrusted = command('untrusted-user') @@ -107,7 +107,7 @@ describe('real-home user hook trust rebasing', () => { writeFileSync(hooksPath, `${JSON.stringify({ hooks: before }, null, 2)}\n`) writeFileSync(configPath, '# original config\n') const requests: CodexUserHookTrustRebaseRequest[] = [] - _internals.setSessionRunnerSync((request) => { + _internals.setSessionRunner(async (request) => { requests.push(request) if (request.operation === 'inspect-user-hook-trust') { return { @@ -123,7 +123,7 @@ describe('real-home user hook trust rebasing', () => { return { outcome: 'repaired', repaired: 1 } }) - mutateRealHomeHooksPreservingUserTrust({ + await mutateRealHomeHooksPreservingUserTrust({ sourcePath: hooksPath, runtimeHomePath: root, tomlPath: configPath, @@ -147,14 +147,14 @@ describe('real-home user hook trust rebasing', () => { } }) - it('marks the host unsupported and skips further codex sessions', () => { + it('marks the host unsupported and skips further codex sessions', async () => { const orca = command('orca-hook') const user = command('user-hook') const before = { Stop: [{ hooks: [orca] }, { hooks: [user] }] } const after = { Stop: [{ hooks: [user] }] } writeFileSync(configPath, '# config\n') let sessions = 0 - _internals.setSessionRunnerSync(() => { + _internals.setSessionRunner(async () => { sessions += 1 throw new CodexAppServerUnsupportedError('unrecognized subcommand app-server') }) @@ -172,20 +172,22 @@ describe('real-home user hook trust rebasing', () => { } } - expect(() => mutateRealHomeHooksPreservingUserTrust(args)).toThrow('unrecognized subcommand') - expect(() => mutateRealHomeHooksPreservingUserTrust(args)).toThrow('marked unsupported') + await expect(mutateRealHomeHooksPreservingUserTrust(args)).rejects.toThrow( + 'unrecognized subcommand' + ) + await expect(mutateRealHomeHooksPreservingUserTrust(args)).rejects.toThrow('marked unsupported') expect(sessions).toBe(1) expect(codexAppServerCapabilityCache.shouldTry('native')).toBe(false) }) - it('cools down after a transient session failure instead of retrying every launch prep', () => { + it('cools down after a transient session failure instead of retrying every launch prep', async () => { const orca = command('orca-hook') const user = command('user-hook') const before = { Stop: [{ hooks: [orca] }, { hooks: [user] }] } const after = { Stop: [{ hooks: [user] }] } writeFileSync(configPath, '# config\n') let sessions = 0 - _internals.setSessionRunnerSync(() => { + _internals.setSessionRunner(async () => { sessions += 1 throw new Error('pre-mutation hooks/list reported 0 of 1 moved user hooks') }) @@ -203,14 +205,16 @@ describe('real-home user hook trust rebasing', () => { } } - expect(() => mutateRealHomeHooksPreservingUserTrust(args)).toThrow('0 of 1 moved user hooks') - expect(() => mutateRealHomeHooksPreservingUserTrust(args)).toThrow('cooling down') + await expect(mutateRealHomeHooksPreservingUserTrust(args)).rejects.toThrow( + '0 of 1 moved user hooks' + ) + await expect(mutateRealHomeHooksPreservingUserTrust(args)).rejects.toThrow('cooling down') expect(sessions).toBe(1) // Why: a transient failure must not poison the shared capability signal. expect(codexAppServerCapabilityCache.shouldTry('native')).toBe(true) }) - it('restores both files byte-exactly when post-mutation repair fails', () => { + it('restores both files byte-exactly when post-mutation repair fails', async () => { const orca = command('orca-hook') const user = command('user-hook') const before = { Stop: [{ hooks: [orca] }, { hooks: [user] }] } @@ -220,7 +224,7 @@ describe('real-home user hook trust rebasing', () => { const originalConfig = '# user formatting\r\nmodel = "x"\r\n' writeFileSync(hooksPath, originalHooks) writeFileSync(configPath, originalConfig) - _internals.setSessionRunnerSync((request) => { + _internals.setSessionRunner(async (request) => { if (request.operation === 'inspect-user-hook-trust') { return { outcome: 'inspected', @@ -236,7 +240,7 @@ describe('real-home user hook trust rebasing', () => { throw new Error('repair transport failed') }) - expect(() => + await expect( mutateRealHomeHooksPreservingUserTrust({ sourcePath: hooksPath, runtimeHomePath: root, @@ -246,7 +250,7 @@ describe('real-home user hook trust rebasing', () => { writeHooks: () => writeFileSync(hooksPath, `${JSON.stringify({ hooks: after })}\n`), restoreHooks: () => writeFileSync(hooksPath, originalHooks) }) - ).toThrow('repair transport failed') + ).rejects.toThrow('repair transport failed') expect(readFileSync(hooksPath, 'utf-8')).toBe(originalHooks) expect(readFileSync(configPath, 'utf-8')).toBe(originalConfig) }) diff --git a/src/main/codex/codex-user-hook-trust-rebase.ts b/src/main/codex/codex-user-hook-trust-rebase.ts index 0020e106c2f..67a69afce6e 100644 --- a/src/main/codex/codex-user-hook-trust-rebase.ts +++ b/src/main/codex/codex-user-hook-trust-rebase.ts @@ -4,7 +4,7 @@ import { getCodexAppServerHostKey, type CodexAppServerHostKey } from './codex-app-server-capability-cache' -import { runCodexUserHookTrustRebaseSessionSync } from './codex-app-server-grant-bridge' +import { runCodexUserHookTrustRebaseSession } from './codex-user-hook-trust-rebase-client' import { isCodexAppServerUnsupportedError } from './codex-app-server-session' import { CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS } from './codex-hook-trust-grant' import { createCodexHookTrustEntry } from './codex-hook-identity' @@ -14,6 +14,7 @@ import { restoreCodexTrustConfig, type CodexTrustConfigSnapshot } from './codex-trust-config-rollback' +import { runExclusivelyForCodexTrustConfig } from './codex-trust-config-mutation-queue' import { computeTrustKey, type CodexTrustEntry } from './config-toml-trust' import type { CodexUserHookTrustRebaseRequest, @@ -23,11 +24,13 @@ import type { type HooksByEvent = Record -type RebaseSessionRunnerSync = ( +type RebaseSessionRunner = ( request: CodexUserHookTrustRebaseRequest -) => CodexUserHookTrustRebaseResult +) => Promise -let runSessionSync: RebaseSessionRunnerSync = runCodexUserHookTrustRebaseSessionSync +// Why (#16441): the session runs in-process; forking it through spawnSync +// froze the main thread for the whole app-server deadline on every install. +let runSession: RebaseSessionRunner = runCodexUserHookTrustRebaseSession // Why: launch prep re-runs the callers on every pane spawn. A host stuck // without a usable rebase lane (old CLI, unmatched keys) must not pay a codex @@ -129,12 +132,25 @@ export function mutateRealHomeHooksPreservingUserTrust(args: { afterHooks: HooksByEvent writeHooks: () => void restoreHooks: () => void -}): CodexTrustConfigSnapshot | null { +}): Promise { const moves = getMovedCodexUserHookTrust(args.sourcePath, args.beforeHooks, args.afterHooks) if (moves.length === 0) { args.writeHooks() - return null + return Promise.resolve(null) } + // Why: capture/mutate/restore on one config.toml is not reentrant. + return runExclusivelyForCodexTrustConfig(args.tomlPath, () => rebaseMovedUserTrust(args, moves)) +} + +async function rebaseMovedUserTrust( + args: { + runtimeHomePath: string + tomlPath: string + writeHooks: () => void + restoreHooks: () => void + }, + moves: CodexUserHookTrustMove[] +): Promise { const hostKey = getCodexAppServerHostKey({ kind: 'native' }) if (!codexAppServerCapabilityCache.shouldTry(hostKey)) { throw new Error('codex app-server is marked unsupported on this host; trust rebase skipped') @@ -148,7 +164,7 @@ export function mutateRealHomeHooksPreservingUserTrust(args: { } const snapshot = captureCodexTrustConfig(args.tomlPath) - const baseRequest = resolveCodexTrustGrantHost({ kind: 'native' }).buildRequest({ + const baseRequest = (await resolveCodexTrustGrantHost({ kind: 'native' })).buildRequest({ runtimeHomePath: args.runtimeHomePath, managedCommand: '', expectedTrustKeys: [], @@ -158,7 +174,7 @@ export function mutateRealHomeHooksPreservingUserTrust(args: { // without shifting a user's positional trust key. let inspected: CodexUserHookTrustRebaseResult try { - inspected = runSessionSync({ + inspected = await runSession({ operation: 'inspect-user-hook-trust', invocation: baseRequest.invocation, hooksListCwd: baseRequest.hooksListCwd, @@ -177,7 +193,7 @@ export function mutateRealHomeHooksPreservingUserTrust(args: { try { args.writeHooks() hooksWritten = true - const repaired = runSessionSync({ + const repaired = await runSession({ operation: 'repair-user-hook-trust', invocation: baseRequest.invocation, hooksListCwd: baseRequest.hooksListCwd, @@ -197,8 +213,8 @@ export function mutateRealHomeHooksPreservingUserTrust(args: { } export const _internals = { - setSessionRunnerSync(runner: RebaseSessionRunnerSync | null): void { - runSessionSync = runner ?? runCodexUserHookTrustRebaseSessionSync + setSessionRunner(runner: RebaseSessionRunner | null): void { + runSession = runner ?? runCodexUserHookTrustRebaseSession }, resetRetryState(): void { rebaseRetryAfterByHost.clear() diff --git a/src/main/codex/hook-service-legacy-cleanup.test.ts b/src/main/codex/hook-service-legacy-cleanup.test.ts index 7093e0242d4..f2b941944c0 100644 --- a/src/main/codex/hook-service-legacy-cleanup.test.ts +++ b/src/main/codex/hook-service-legacy-cleanup.test.ts @@ -54,7 +54,7 @@ function legacyManagedHookCommand(): string { } describe('CodexHookService', () => { - it('removes legacy Orca-managed hooks from system ~/.codex during install', () => { + it('removes legacy Orca-managed hooks from system ~/.codex during install', async () => { const systemCodexHome = join(homes.tmpHome, '.codex') const systemHooksPath = join(systemCodexHome, 'hooks.json') const legacyCommand = legacyManagedHookCommand() @@ -102,7 +102,7 @@ describe('CodexHookService', () => { 'utf-8' ) - expect(new CodexHookService().install().state).toBe('installed') + expect((await new CodexHookService().install()).state).toBe('installed') const systemHooks = JSON.parse(readFileSync(systemHooksPath, 'utf-8')) as { hooks: Record @@ -117,7 +117,7 @@ describe('CodexHookService', () => { expect(systemToml).not.toContain(':session_start:0:0') }) - it('removes very large legacy Orca-managed hook lists from system ~/.codex', () => { + it('removes very large legacy Orca-managed hook lists from system ~/.codex', async () => { const systemCodexHome = join(homes.tmpHome, '.codex') const systemHooksPath = join(systemCodexHome, 'hooks.json') const legacyCommand = legacyManagedHookCommand() @@ -136,7 +136,7 @@ describe('CodexHookService', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { - expect(new CodexHookService().install().state).toBe('installed') + expect((await new CodexHookService().install()).state).toBe('installed') expect(warnSpy).not.toHaveBeenCalledWith( '[codex-hook-service] failed to clean legacy Codex hooks', @@ -151,18 +151,18 @@ describe('CodexHookService', () => { expect(systemHooks.hooks.Stop).toBeUndefined() }, 30_000) - it('removes the legacy Orca Codex profile file when it only contains managed hooks', () => { + it('removes the legacy Orca Codex profile file when it only contains managed hooks', async () => { const systemCodexHome = join(homes.tmpHome, '.codex') const profilePath = join(systemCodexHome, 'orca-agent-status.config.toml') mkdirSync(systemCodexHome, { recursive: true }) writeFileSync(profilePath, LEGACY_ORCA_PROFILE_LINES.join('\n'), 'utf-8') - expect(new CodexHookService().install().state).toBe('installed') + expect((await new CodexHookService().install()).state).toBe('installed') expect(existsSync(profilePath)).toBe(false) }) - it('removes only the legacy Orca block from a user-edited Codex profile file', () => { + it('removes only the legacy Orca block from a user-edited Codex profile file', async () => { const systemCodexHome = join(homes.tmpHome, '.codex') const profilePath = join(systemCodexHome, 'orca-agent-status.config.toml') mkdirSync(systemCodexHome, { recursive: true }) @@ -172,7 +172,7 @@ describe('CodexHookService', () => { 'utf-8' ) - expect(new CodexHookService().install().state).toBe('installed') + expect((await new CodexHookService().install()).state).toBe('installed') const profileConfig = readFileSync(profilePath, 'utf-8') expect(profileConfig).toContain('model = "gpt-5.5"') @@ -180,7 +180,7 @@ describe('CodexHookService', () => { expect(profileConfig).not.toContain('codex-hook') }) - it('cleans legacy system and profile hooks when runtime hooks.json is malformed during remove', () => { + it('cleans legacy system and profile hooks when runtime hooks.json is malformed during remove', async () => { const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home') mkdirSync(managedCodexHome, { recursive: true }) writeFileSync(join(managedCodexHome, 'hooks.json'), '{not json', 'utf-8') @@ -209,7 +209,7 @@ describe('CodexHookService', () => { ) writeFileSync(profilePath, LEGACY_ORCA_PROFILE_LINES.join('\n'), 'utf-8') - const status = new CodexHookService().remove() + const status = await new CodexHookService().remove() expect(status.state).toBe('error') expect(status.detail).toBe('Could not parse Codex hooks.json') @@ -221,7 +221,7 @@ describe('CodexHookService', () => { expect(existsSync(profilePath)).toBe(false) }) - it('sanitizes runtime hooks.json metadata during remove even without managed hooks', () => { + it('sanitizes runtime hooks.json metadata during remove even without managed hooks', async () => { const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home') const managedHooksPath = join(managedCodexHome, 'hooks.json') mkdirSync(managedCodexHome, { recursive: true }) @@ -244,7 +244,7 @@ describe('CodexHookService', () => { 'utf-8' ) - const status = new CodexHookService().remove() + const status = await new CodexHookService().remove() expect(status.state).toBe('not_installed') const hooksConfig = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as { @@ -256,7 +256,7 @@ describe('CodexHookService', () => { expect(hooksConfig.hooks.Stop).toEqual([{ hooks: [{ type: 'command', command: 'user-hook' }] }]) }) - it('cleans duplicate Codex hook representations while keeping status hooks in runtime CODEX_HOME', () => { + it('cleans duplicate Codex hook representations while keeping status hooks in runtime CODEX_HOME', async () => { const systemCodexHome = join(homes.tmpHome, '.codex') const systemHooksPath = join(systemCodexHome, 'hooks.json') const systemTomlPath = join(systemCodexHome, 'config.toml') @@ -307,7 +307,7 @@ describe('CodexHookService', () => { writeFileSync(legacyProfilePath, LEGACY_ORCA_PROFILE_LINES.join('\n'), 'utf-8') const service = new CodexHookService() - expect(service.install().state).toBe('installed') + expect((await service.install()).state).toBe('installed') const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home') const managedHooksPath = join(managedCodexHome, 'hooks.json') diff --git a/src/main/codex/hook-service-managed-install.test.ts b/src/main/codex/hook-service-managed-install.test.ts index 72852e45b55..5d0021f9499 100644 --- a/src/main/codex/hook-service-managed-install.test.ts +++ b/src/main/codex/hook-service-managed-install.test.ts @@ -28,6 +28,7 @@ vi.mock('os', async (importOriginal) => { }) import { CodexHookService } from './hook-service' +import { runExclusivelyForCodexTrustConfig } from './codex-trust-config-mutation-queue' const WINDOWS_POWERSHELL_LAUNCHER = /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ @@ -48,7 +49,58 @@ function localManagedCodexEvents(): string[] { } describe('CodexHookService', () => { - it('installs PermissionRequest with trust so Codex approval prompts reach Orca', () => { + // Why (#16441): install promotes in-Orca approvals into ~/.codex/config.toml + // and mirrors that file into the managed home, so holding only the runtime + // lane still lets it land inside a real-home grant's capture->restore window. + it('waits for an in-flight mutation of the system config.toml', async () => { + const systemCodexHome = join(homes.tmpHome, '.codex') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync(join(systemCodexHome, 'config.toml'), 'approval_policy = "on-request"\n', 'utf-8') + const managedHooksJsonPath = join(homes.userDataDir, 'codex-runtime-home', 'home', 'hooks.json') + let releaseGrant!: () => void + const grantHoldingSystemConfig = new Promise((resolve) => { + releaseGrant = resolve + }) + const held = runExclusivelyForCodexTrustConfig( + join(systemCodexHome, 'config.toml'), + () => grantHoldingSystemConfig + ) + + const install = new CodexHookService().install() + await new Promise((resolve) => setImmediate(resolve)) + expect(existsSync(managedHooksJsonPath)).toBe(false) + + releaseGrant() + await held + await expect(install).resolves.toMatchObject({ state: 'installed' }) + expect(existsSync(managedHooksJsonPath)).toBe(true) + }) + + it('makes the user-hook refresh wait for the system config.toml too', async () => { + const systemCodexHome = join(homes.tmpHome, '.codex') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync(join(systemCodexHome, 'config.toml'), 'approval_policy = "on-request"\n', 'utf-8') + const managedHooksJsonPath = join(homes.userDataDir, 'codex-runtime-home', 'home', 'hooks.json') + let releaseGrant!: () => void + const held = runExclusivelyForCodexTrustConfig( + join(systemCodexHome, 'config.toml'), + () => + new Promise((resolve) => { + releaseGrant = resolve + }) + ) + + const refresh = new CodexHookService().refreshRuntimeUserHooks() + await new Promise((resolve) => setImmediate(resolve)) + expect(existsSync(managedHooksJsonPath)).toBe(false) + + releaseGrant() + await held + await refresh + expect(existsSync(managedHooksJsonPath)).toBe(true) + }) + + it('installs PermissionRequest with trust so Codex approval prompts reach Orca', async () => { const systemCodexHome = join(homes.tmpHome, '.codex') mkdirSync(systemCodexHome, { recursive: true }) writeFileSync( @@ -57,7 +109,7 @@ describe('CodexHookService', () => { 'utf-8' ) - const status = new CodexHookService().install() + const status = await new CodexHookService().install() expect(status.state).toBe('installed') @@ -77,7 +129,7 @@ describe('CodexHookService', () => { expect(trustConfig).toContain(':permission_request:0:0') }) - it('installs managed hooks + trust into a per-account self-contained home, not the shared mirror', () => { + it('installs managed hooks + trust into a per-account self-contained home, not the shared mirror', async () => { const systemCodexHome = join(homes.tmpHome, '.codex') mkdirSync(systemCodexHome, { recursive: true }) writeFileSync(join(systemCodexHome, 'config.toml'), 'approval_policy = "on-request"\n', 'utf-8') @@ -86,7 +138,7 @@ describe('CodexHookService', () => { mkdirSync(perAccountHome, { recursive: true }) writeFileSync(join(perAccountHome, '.orca-managed-home'), 'account-1\n', 'utf-8') - const status = new CodexHookService().install(perAccountHome) + const status = await new CodexHookService().install(perAccountHome) expect(status.state).toBe('installed') // Hooks + trust land in THIS account's home. @@ -106,7 +158,7 @@ describe('CodexHookService', () => { expect(existsSync(join(systemCodexHome, 'hooks.json'))).toBe(false) }) - it('drops plugin manager metadata from runtime hooks.json during install', () => { + it('drops plugin manager metadata from runtime hooks.json during install', async () => { const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home') mkdirSync(managedCodexHome, { recursive: true }) writeFileSync( @@ -122,7 +174,7 @@ describe('CodexHookService', () => { 'utf-8' ) - expect(new CodexHookService().install().state).toBe('installed') + expect((await new CodexHookService().install()).state).toBe('installed') const hooksConfig = JSON.parse(readFileSync(join(managedCodexHome, 'hooks.json'), 'utf-8')) as { hooks: Record @@ -138,7 +190,7 @@ describe('CodexHookService', () => { // `cmd.exe /C` never sees the raw script path. it.skipIf(process.platform !== 'win32')( 'wraps the managed hook command when the profile path contains a space (#6078)', - () => { + async () => { const spaceHome = join(tmpdir(), 'orca home with spaces') mkdirSync(spaceHome, { recursive: true }) homedirMock.mockReturnValue(spaceHome) @@ -146,7 +198,7 @@ describe('CodexHookService', () => { const systemCodexHome = join(spaceHome, '.codex') mkdirSync(systemCodexHome, { recursive: true }) - const status = new CodexHookService().install() + const status = await new CodexHookService().install() expect(status.state).toBe('installed') const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home') @@ -168,7 +220,7 @@ describe('CodexHookService', () => { // plausible paths. Keep those rare cases on the encoded launcher from #6078. it.skipIf(process.platform !== 'win32')( 'keeps the encoded launcher when the profile path contains cmd metacharacters', - () => { + async () => { const metacharHome = join(tmpdir(), 'orca %ORCA_TEST% ^ home') mkdirSync(metacharHome, { recursive: true }) homedirMock.mockReturnValue(metacharHome) @@ -176,7 +228,7 @@ describe('CodexHookService', () => { const systemCodexHome = join(metacharHome, '.codex') mkdirSync(systemCodexHome, { recursive: true }) - const status = new CodexHookService().install() + const status = await new CodexHookService().install() expect(status.state).toBe('installed') const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home') @@ -199,8 +251,8 @@ describe('CodexHookService', () => { // speed that Codex 0.140's synchronous "Running hook" rows expose. it.skipIf(process.platform !== 'win32')( 'launches the managed .cmd directly when the profile path is cmd-safe', - () => { - const status = new CodexHookService().install() + async () => { + const status = await new CodexHookService().install() expect(status.state).toBe('installed') const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home') @@ -227,7 +279,7 @@ describe('CodexHookService', () => { it.skipIf(process.platform !== 'win32')( 'posts hook payloads via the curl-based managed script preserving UTF-8 and spaced metadata', async () => { - new CodexHookService().install() + await new CodexHookService().install() const scriptPath = join(homedir(), '.orca', 'agent-hooks', 'codex-hook.cmd') expect(existsSync(scriptPath)).toBe(true) @@ -297,7 +349,7 @@ describe('CodexHookService', () => { } ) - it('keeps hooks isolated by Orca userData instead of mutating system ~/.codex', () => { + it('keeps hooks isolated by Orca userData instead of mutating system ~/.codex', async () => { const systemCodexHome = join(homes.tmpHome, '.codex') const systemHooksPath = join(systemCodexHome, 'hooks.json') const existingSystemHooks = '{"hooks":{"Stop":[{"hooks":[{"command":"user-hook"}]}]}}\n' @@ -314,7 +366,7 @@ describe('CodexHookService', () => { throw new Error(`unexpected app.getPath(${name})`) }) process.env.ORCA_USER_DATA_PATH = devUserDataDir - expect(new CodexHookService().install().state).toBe('installed') + expect((await new CodexHookService().install()).state).toBe('installed') getPathMock.mockImplementation((name: string) => { if (name === 'userData') { @@ -323,7 +375,7 @@ describe('CodexHookService', () => { throw new Error(`unexpected app.getPath(${name})`) }) process.env.ORCA_USER_DATA_PATH = prodUserDataDir - expect(new CodexHookService().install().state).toBe('installed') + expect((await new CodexHookService().install()).state).toBe('installed') const devHooksPath = join(devUserDataDir, 'codex-runtime-home', 'home', 'hooks.json') const prodHooksPath = join(prodUserDataDir, 'codex-runtime-home', 'home', 'hooks.json') diff --git a/src/main/codex/hook-service-runtime-trust-repair.test.ts b/src/main/codex/hook-service-runtime-trust-repair.test.ts index ce8c660af67..5adc4b202b4 100644 --- a/src/main/codex/hook-service-runtime-trust-repair.test.ts +++ b/src/main/codex/hook-service-runtime-trust-repair.test.ts @@ -33,7 +33,7 @@ import { CodexHookService } from './hook-service' const homes = setupCodexHookHomes(homedirMock, getPathMock) describe('CodexHookService', () => { - it('removes managed trust entries when userData resolves through a symlink', () => { + it('removes managed trust entries when userData resolves through a symlink', async () => { const linkedUserDataDir = join(homes.tmpHome, 'linked-user-data') symlinkSync( homes.userDataDir, @@ -43,14 +43,14 @@ describe('CodexHookService', () => { process.env.ORCA_USER_DATA_PATH = linkedUserDataDir const service = new CodexHookService() - expect(service.install().state).toBe('installed') + expect((await service.install()).state).toBe('installed') const linkedManagedCodexHome = join(linkedUserDataDir, 'codex-runtime-home', 'home') const linkedHooksPath = join(linkedManagedCodexHome, 'hooks.json') let runtimeToml = readFileSync(join(linkedManagedCodexHome, 'config.toml'), 'utf-8') expect(runtimeToml).toContain(hookTrustHeader(`${linkedHooksPath}:permission_request:0:0`)) - const status = service.remove() + const status = await service.remove() expect(status.state).toBe('not_installed') runtimeToml = readFileSync(join(linkedManagedCodexHome, 'config.toml'), 'utf-8') @@ -58,9 +58,9 @@ describe('CodexHookService', () => { expect(runtimeToml).not.toContain(':stop:0:0') }) - it('removes legacy managed trust entries hashed before hook timeouts existed', () => { + it('removes legacy managed trust entries hashed before hook timeouts existed', async () => { const service = new CodexHookService() - expect(service.install().state).toBe('installed') + expect((await service.install()).state).toBe('installed') const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home') const managedHooksPath = join(managedCodexHome, 'hooks.json') @@ -88,13 +88,13 @@ describe('CodexHookService', () => { 'utf-8' ) - expect(service.remove().state).toBe('not_installed') + expect((await service.remove()).state).toBe('not_installed') const runtimeToml = readFileSync(runtimeTomlPath, 'utf-8') expect(runtimeToml).not.toContain(':permission_request:0:0') }) - it('mirrors system Codex config while preserving runtime hook trust on hook install', () => { + it('mirrors system Codex config while preserving runtime hook trust on hook install', async () => { const systemCodexHome = join(homes.tmpHome, '.codex') mkdirSync(systemCodexHome, { recursive: true }) writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "system-model"\n', 'utf-8') @@ -114,7 +114,7 @@ describe('CodexHookService', () => { 'utf-8' ) - const status = new CodexHookService().install() + const status = await new CodexHookService().install() expect(status.state).toBe('installed') const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') @@ -128,9 +128,9 @@ describe('CodexHookService', () => { it.skipIf(process.platform !== 'win32')( 'treats legacy forward-slash runtime trust keys as installed before canonicalizing on reinstall', - () => { + async () => { const service = new CodexHookService() - expect(service.install().state).toBe('installed') + expect((await service.install()).state).toBe('installed') const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home') const managedHooksPath = join(managedCodexHome, 'hooks.json') @@ -154,7 +154,7 @@ describe('CodexHookService', () => { expect(legacyToml).toContain(legacyPermissionHeader) expect(service.getStatus().state).toBe('installed') - expect(service.install().state).toBe('installed') + expect((await service.install()).state).toBe('installed') const repairedToml = readFileSync(runtimeTomlPath, 'utf-8') expect(repairedToml).not.toContain(legacyPermissionHeader) @@ -163,13 +163,13 @@ describe('CodexHookService', () => { } ) - it('repairs duplicate managed PermissionRequest trust tables on restart install', () => { + it('repairs duplicate managed PermissionRequest trust tables on restart install', async () => { const systemCodexHome = join(homes.tmpHome, '.codex') mkdirSync(systemCodexHome, { recursive: true }) writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "system-model"\n', 'utf-8') const service = new CodexHookService() - expect(service.install().state).toBe('installed') + expect((await service.install()).state).toBe('installed') const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home') const managedHooksPath = join(managedCodexHome, 'hooks.json') @@ -209,7 +209,7 @@ describe('CodexHookService', () => { // Why: preserving `enabled = false` is the repair contract; status can be // partial because the user-disabled managed hook remains disabled. - expect(['installed', 'partial']).toContain(service.install().state) + expect(['installed', 'partial']).toContain((await service.install()).state) const repairedToml = readFileSync(runtimeTomlPath, 'utf-8') expect(repairedToml.split(permissionRequestHeader)).toHaveLength(2) @@ -219,7 +219,7 @@ describe('CodexHookService', () => { expect(repairedToml).toContain('model = "system-model"') }) - it('preserves runtime-only project trust while honoring system project untrust', () => { + it('preserves runtime-only project trust while honoring system project untrust', async () => { const systemCodexHome = join(homes.tmpHome, '.codex') mkdirSync(systemCodexHome, { recursive: true }) writeFileSync( @@ -247,7 +247,7 @@ describe('CodexHookService', () => { 'utf-8' ) - const status = new CodexHookService().install() + const status = await new CodexHookService().install() expect(status.state).toBe('installed') const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') diff --git a/src/main/codex/hook-service-test-harness.ts b/src/main/codex/hook-service-test-harness.ts index 2893d3a12ba..f6a450a263d 100644 --- a/src/main/codex/hook-service-test-harness.ts +++ b/src/main/codex/hook-service-test-harness.ts @@ -7,6 +7,17 @@ import { getCodexExplicitHomeHookSourcePath, normalizeCodexHookSourcePath } from './config-toml-trust' +import { _internals as grantInternals } from './codex-hook-trust-grant' +import { _internals as rebaseInternals } from './codex-user-hook-trust-rebase' + +// Why (#16441): the grant/rebase sessions now run in-process instead of in a +// forked bundle that never existed under vitest. Without this stub these +// suites spawn the developer's real `codex app-server`, so they pass in CI +// (no codex installed) and fail on any machine that has one. Stand in for the +// missing binary so the fallback lane is exercised either way. +function stubMissingCodexBinary(): never { + throw Object.assign(new Error('spawn codex ENOENT'), { code: 'ENOENT' }) +} export type CodexHookHomes = { tmpHome: string @@ -14,6 +25,19 @@ export type CodexHookHomes = { } /** Mutable holder: fields are re-pointed at fresh temp dirs by the registered beforeEach. */ +/** Applies the stub above; for suites that build their own temp homes. */ +export function stubCodexTrustSessionsForTests(): void { + grantInternals.setGrantSessionRunner(stubMissingCodexBinary) + rebaseInternals.setSessionRunner(stubMissingCodexBinary) +} + +export function restoreCodexTrustSessionsForTests(): void { + grantInternals.setGrantSessionRunner(null) + grantInternals.resetDiagnostics() + rebaseInternals.setSessionRunner(null) + rebaseInternals.resetRetryState() +} + export function setupCodexHookHomes( homedirMock: Mock<() => string>, getPathMock: Mock<(name: string) => string> @@ -27,6 +51,7 @@ export function setupCodexHookHomes( previousUserDataPath = process.env.ORCA_USER_DATA_PATH process.env.ORCA_USER_DATA_PATH = homes.userDataDir homedirMock.mockReturnValue(homes.tmpHome) + stubCodexTrustSessionsForTests() getPathMock.mockImplementation((name: string) => { if (name === 'userData') { return homes.userDataDir @@ -36,6 +61,7 @@ export function setupCodexHookHomes( }) afterEach(() => { + restoreCodexTrustSessionsForTests() rmSync(homes.tmpHome, { recursive: true, force: true }) rmSync(homes.userDataDir, { recursive: true, force: true }) if (previousUserDataPath === undefined) { diff --git a/src/main/codex/hook-service-trust-grant.test.ts b/src/main/codex/hook-service-trust-grant.test.ts index 7814994c758..1b4ab0cd7ac 100644 --- a/src/main/codex/hook-service-trust-grant.test.ts +++ b/src/main/codex/hook-service-trust-grant.test.ts @@ -75,8 +75,8 @@ beforeEach(() => { }) afterEach(() => { - rebaseInternals.setSessionRunnerSync(null) - trustGrantInternals.setGrantSessionRunnerSync(null) + rebaseInternals.setSessionRunner(null) + trustGrantInternals.setGrantSessionRunner(null) trustGrantInternals.resetDiagnostics() codexAppServerCapabilityCache.clear() if (previousDisableTrustRpc === undefined) { @@ -123,7 +123,7 @@ function writeCodexLikeTrust(configPath: string, entries: CodexTrustEntry[]): vo function installCodexLikeGrantRunner(): ReturnType { const codexHash = (key: string): string => `sha256:codex-${parseTrustKey(key)?.eventLabel ?? 'unknown'}` - const runner = vi.fn((request: CodexHookTrustGrantRequest) => { + const runner = vi.fn(async (request: CodexHookTrustGrantRequest) => { const codexHome = request.invocation.env?.CODEX_HOME expect(codexHome).toBeTruthy() const entries: CodexTrustEntry[] = request.expectedTrustKeys.map((key) => { @@ -145,7 +145,7 @@ function installCodexLikeGrantRunner(): ReturnType { })) } }) - trustGrantInternals.setGrantSessionRunnerSync(runner) + trustGrantInternals.setGrantSessionRunner(runner) return runner } @@ -154,11 +154,11 @@ function prepareSystemHome(): void { } describe('CodexHookService app-server trust grant lane', () => { - it('treats Codex hashes as authoritative and records the verified grant', () => { + it('treats Codex hashes as authoritative and records the verified grant', async () => { prepareSystemHome() const runner = installCodexLikeGrantRunner() - const status = new CodexHookService().install() + const status = await new CodexHookService().install() expect(status.state).toBe('installed') expect(runner).toHaveBeenCalledTimes(1) @@ -177,15 +177,15 @@ describe('CodexHookService app-server trust grant lane', () => { expect(Object.keys(readCodexTrustGrantLedgerHome(managedHome)!.entries)).toHaveLength(8) }) - it('keeps config byte-stable and skips the session on a repeat ledger hit', () => { + it('keeps config byte-stable and skips the session on a repeat ledger hit', async () => { prepareSystemHome() const runner = installCodexLikeGrantRunner() const service = new CodexHookService() - expect(service.install().state).toBe('installed') + expect((await service.install()).state).toBe('installed') const managedHome = join(userDataDir, 'codex-runtime-home', 'home') const firstToml = readFileSync(join(managedHome, 'config.toml')) - expect(service.install().state).toBe('installed') + expect((await service.install()).state).toBe('installed') expect(runner).toHaveBeenCalledTimes(1) // Why: each launch validates the binary stamp once; getStatus reuses the // just-verified grant instead of repeating PATH/version-manager scans. @@ -193,7 +193,7 @@ describe('CodexHookService app-server trust grant lane', () => { expect(readFileSync(join(managedHome, 'config.toml'))).toEqual(firstToml) }) - it('retries ledger-proven real-home trust cleanup after the hook is already gone', () => { + it('retries ledger-proven real-home trust cleanup after the hook is already gone', async () => { prepareSystemHome() const systemHome = join(tmpHome, '.codex') const hooksPath = join(systemHome, 'hooks.json') @@ -223,7 +223,7 @@ describe('CodexHookService app-server trust grant lane', () => { }) installCodexLikeGrantRunner() - expect(new CodexHookService().install().state).toBe('installed') + expect((await new CodexHookService().install()).state).toBe('installed') expect(readHookTrustEntries(configPath).has(trustKey)).toBe(false) expect(readCodexTrustGrantLedgerHome(systemHome)).toBeNull() @@ -232,7 +232,7 @@ describe('CodexHookService app-server trust grant lane', () => { // Why: ordinary Windows CI tokens cannot create file symlinks without Developer Mode. it.skipIf(process.platform === 'win32')( 'keeps a real-home symlink and rebases later user trust during flag-off cleanup', - () => { + async () => { prepareSystemHome() const systemHome = join(tmpHome, '.codex') const hooksPath = join(systemHome, 'hooks.json') @@ -256,7 +256,7 @@ describe('CodexHookService app-server trust grant lane', () => { ) symlinkSync(targetPath, hooksPath) const operations: string[] = [] - rebaseInternals.setSessionRunnerSync((request) => { + rebaseInternals.setSessionRunner(async (request) => { operations.push(request.operation) if (request.operation === 'inspect-user-hook-trust') { return { @@ -273,7 +273,7 @@ describe('CodexHookService app-server trust grant lane', () => { }) installCodexLikeGrantRunner() - expect(new CodexHookService().install().state).toBe('installed') + expect((await new CodexHookService().install()).state).toBe('installed') expect(lstatSync(hooksPath).isSymbolicLink()).toBe(true) expect(JSON.parse(readFileSync(targetPath, 'utf-8')).hooks.Stop).toEqual([ @@ -285,7 +285,7 @@ describe('CodexHookService app-server trust grant lane', () => { it.skipIf(process.platform === 'win32')( 'preserves restrictive real-home hooks permissions during flag-off cleanup', - () => { + async () => { prepareSystemHome() const hooksPath = join(tmpHome, '.codex', 'hooks.json') const material = getCodexManagedHookInstallMaterial() @@ -296,17 +296,17 @@ describe('CodexHookService app-server trust grant lane', () => { chmodSync(hooksPath, 0o600) installCodexLikeGrantRunner() - expect(new CodexHookService().install().state).toBe('installed') + expect((await new CodexHookService().install()).state).toBe('installed') expect(statSync(hooksPath).mode & 0o777).toBe(0o600) } ) - it('does not accept a ledger hash after the recorded Codex binary stamp changes', () => { + it('does not accept a ledger hash after the recorded Codex binary stamp changes', async () => { prepareSystemHome() installCodexLikeGrantRunner() const service = new CodexHookService() - expect(service.install().state).toBe('installed') + expect((await service.install()).state).toBe('installed') const managedHome = join(userDataDir, 'codex-runtime-home', 'home') const ledger = readCodexTrustGrantLedgerHome(managedHome)! writeCodexTrustGrantLedgerHome(managedHome, { @@ -320,16 +320,16 @@ describe('CodexHookService app-server trust grant lane', () => { }) }) - it('upgrades self-computed trust in place without duplicate logical entries', () => { + it('upgrades self-computed trust in place without duplicate logical entries', async () => { prepareSystemHome() const service = new CodexHookService() process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1' - expect(service.install().state).toBe('installed') + expect((await service.install()).state).toBe('installed') const managedHome = join(userDataDir, 'codex-runtime-home', 'home') delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC installCodexLikeGrantRunner() - expect(service.install().state).toBe('installed') + expect((await service.install()).state).toBe('installed') const upgraded = readFileSync(join(managedHome, 'config.toml'), 'utf-8') // Why: the legacy Windows fallback intentionally writes slash variants; // duplicate detection is about the normalized trust identity. @@ -350,7 +350,7 @@ describe('CodexHookService app-server trust grant lane', () => { expect(upgraded).toContain('sha256:codex-session_start') }) - it('leaves user trust byte-untouched while granting managed entries', () => { + it('leaves user trust byte-untouched while granting managed entries', async () => { prepareSystemHome() const managedHome = join(userDataDir, 'codex-runtime-home', 'home') mkdirSync(managedHome, { recursive: true }) @@ -362,35 +362,35 @@ describe('CodexHookService app-server trust grant lane', () => { writeFileSync(join(managedHome, 'config.toml'), `${userBlock}\n`) installCodexLikeGrantRunner() - expect(new CodexHookService().install().state).toBe('installed') + expect((await new CodexHookService().install()).state).toBe('installed') expect(readFileSync(join(managedHome, 'config.toml'), 'utf-8')).toContain(userBlock) }) - it('keeps the forced fallback on self-computed writes', () => { + it('keeps the forced fallback on self-computed writes', async () => { prepareSystemHome() process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1' const runner = vi.fn() - trustGrantInternals.setGrantSessionRunnerSync(runner) + trustGrantInternals.setGrantSessionRunner(runner) const service = new CodexHookService() - expect(service.install().state).toBe('installed') + expect((await service.install()).state).toBe('installed') expect(service.getStatus().state).toBe('installed') expect(runner).not.toHaveBeenCalled() expect(resolveCodexCommandMock).not.toHaveBeenCalled() }) - it('restores exact config bytes before fallback after a mutating RPC failure', () => { + it('restores exact config bytes before fallback after a mutating RPC failure', async () => { prepareSystemHome() const service = new CodexHookService() process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1' - expect(service.install().state).toBe('installed') + expect((await service.install()).state).toBe('installed') const managedHome = join(userDataDir, 'codex-runtime-home', 'home') const baseline = readFileSync(join(managedHome, 'config.toml')) delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC rmSync(managedHome, { recursive: true, force: true }) trustGrantInternals.resetDiagnostics() - const runner = vi.fn((request: CodexHookTrustGrantRequest) => { + const runner = vi.fn(async (request: CodexHookTrustGrantRequest) => { const codexHome = request.invocation.env?.CODEX_HOME writeFileSync( join(codexHome!, 'config.toml'), @@ -398,9 +398,9 @@ describe('CodexHookService app-server trust grant lane', () => { ) throw new Error('transport failed after config/batchWrite') }) - trustGrantInternals.setGrantSessionRunnerSync(runner) + trustGrantInternals.setGrantSessionRunner(runner) - expect(service.install().state).toBe('installed') + expect((await service.install()).state).toBe('installed') expect(runner).toHaveBeenCalledTimes(1) expect(readFileSync(join(managedHome, 'config.toml'))).toEqual(baseline) }) diff --git a/src/main/codex/hook-service-user-hook-mirroring.test.ts b/src/main/codex/hook-service-user-hook-mirroring.test.ts index d06fec0b3a7..f13953a6051 100644 --- a/src/main/codex/hook-service-user-hook-mirroring.test.ts +++ b/src/main/codex/hook-service-user-hook-mirroring.test.ts @@ -73,10 +73,10 @@ function markHookTrustDisabled(toml: string, header: string): string { } describe('CodexHookService', () => { - it('preserves mirrored user hooks when the system hooks file cannot be read', () => { + it('preserves mirrored user hooks when the system hooks file cannot be read', async () => { const service = new CodexHookService() const { systemHooksPath, managedHooksPath } = seedSystemUserHook('user-hook') - expect(service.install().state).toBe('installed') + expect((await service.install()).state).toBe('installed') const systemBefore = readFileSync(systemHooksPath, 'utf-8') const before = readFileSync(managedHooksPath, 'utf-8') @@ -84,7 +84,7 @@ describe('CodexHookService', () => { mkdirSync(systemHooksPath) for (const retry of [() => service.install(), () => service.refreshRuntimeUserHooks()]) { - expect(retry()).toMatchObject({ + expect(await retry()).toMatchObject({ state: 'error', detail: 'Could not read system Codex hooks.json' }) @@ -93,16 +93,16 @@ describe('CodexHookService', () => { rmSync(systemHooksPath, { recursive: true }) writeFileSync(systemHooksPath, systemBefore, 'utf-8') - expect(service.install().state).toBe('installed') + expect((await service.install()).state).toBe('installed') expect(readRuntimeHookCommands(managedHooksPath)).toContain('user-hook') }) it.each(['absent', 'malformed'] as const)( 'rebuilds mirrored user hooks when the system source is %s', - (sourceState) => { + async (sourceState) => { const service = new CodexHookService() const { systemHooksPath, managedHooksPath } = seedSystemUserHook('stale-user-hook') - expect(service.install().state).toBe('installed') + expect((await service.install()).state).toBe('installed') if (sourceState === 'absent') { rmSync(systemHooksPath) @@ -110,12 +110,12 @@ describe('CodexHookService', () => { writeFileSync(systemHooksPath, '{ not json', 'utf-8') } - expect(service.install().state).toBe('installed') + expect((await service.install()).state).toBe('installed') expect(readRuntimeHookCommands(managedHooksPath)).not.toContain('stale-user-hook') } ) - it('mirrors trusted system user hook approvals into the runtime CODEX_HOME', () => { + it('mirrors trusted system user hook approvals into the runtime CODEX_HOME', async () => { const systemCodexHome = join(homes.tmpHome, '.codex') const systemHooksPath = join(systemCodexHome, 'hooks.json') mkdirSync(systemCodexHome, { recursive: true }) @@ -163,7 +163,7 @@ describe('CodexHookService', () => { 'utf-8' ) - expect(new CodexHookService().install().state).toBe('installed') + expect((await new CodexHookService().install()).state).toBe('installed') const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home') const managedHooksPath = join(managedCodexHome, 'hooks.json') @@ -183,7 +183,7 @@ describe('CodexHookService', () => { expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:stop:0:0`, true)) }) - it('runs managed PostToolUse status before mirrored user hooks', () => { + it('runs managed PostToolUse status before mirrored user hooks', async () => { const systemCodexHome = join(homes.tmpHome, '.codex') const systemHooksPath = join(systemCodexHome, 'hooks.json') mkdirSync(systemCodexHome, { recursive: true }) @@ -210,7 +210,7 @@ describe('CodexHookService', () => { 'utf-8' ) - expect(new CodexHookService().install().state).toBe('installed') + expect((await new CodexHookService().install()).state).toBe('installed') const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home') const managedHooksPath = join(managedCodexHome, 'hooks.json') @@ -231,7 +231,7 @@ describe('CodexHookService', () => { expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:post_tool_use:0:0`, true)) }) - it('mirrors system user hook approvals when the system trust indices are stale', () => { + it('mirrors system user hook approvals when the system trust indices are stale', async () => { const systemCodexHome = join(homes.tmpHome, '.codex') const systemHooksPath = join(systemCodexHome, 'hooks.json') mkdirSync(systemCodexHome, { recursive: true }) @@ -272,7 +272,7 @@ describe('CodexHookService', () => { 'utf-8' ) - expect(new CodexHookService().install().state).toBe('installed') + expect((await new CodexHookService().install()).state).toBe('installed') const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home') const managedHooksPath = join(managedCodexHome, 'hooks.json') @@ -284,7 +284,7 @@ describe('CodexHookService', () => { expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:stop:1:0`, true)) }) - it('skips plugin-placeholder system hooks when mirroring into runtime CODEX_HOME', () => { + it('skips plugin-placeholder system hooks when mirroring into runtime CODEX_HOME', async () => { const pluginCommands = [ 'node "${CLAUDE_PLUGIN_ROOT}/scripts/on-stop.mjs"', 'node "${CLAUDE_PLUGIN_DATA}/scripts/on-stop.mjs"', @@ -340,7 +340,7 @@ describe('CodexHookService', () => { 'utf-8' ) - expect(new CodexHookService().install().state).toBe('installed') + expect((await new CodexHookService().install()).state).toBe('installed') const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home') const managedHooksPath = join(managedCodexHome, 'hooks.json') @@ -368,7 +368,7 @@ describe('CodexHookService', () => { } }) - it('mirrors compact-event user hook approvals and disabled trust entries', () => { + it('mirrors compact-event user hook approvals and disabled trust entries', async () => { const systemCodexHome = join(homes.tmpHome, '.codex') const systemHooksPath = join(systemCodexHome, 'hooks.json') mkdirSync(systemCodexHome, { recursive: true }) @@ -409,7 +409,7 @@ describe('CodexHookService', () => { 'utf-8' ) - expect(new CodexHookService().install().state).toBe('installed') + expect((await new CodexHookService().install()).state).toBe('installed') const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home') const managedHooksPath = join(managedCodexHome, 'hooks.json') @@ -430,7 +430,7 @@ describe('CodexHookService', () => { expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:post_compact:0:0`, true)) }) - it('removes runtime user hook trust after system approval is revoked', () => { + it('removes runtime user hook trust after system approval is revoked', async () => { const systemCodexHome = join(homes.tmpHome, '.codex') const systemHooksPath = join(systemCodexHome, 'hooks.json') mkdirSync(systemCodexHome, { recursive: true }) @@ -456,7 +456,7 @@ describe('CodexHookService', () => { ) const service = new CodexHookService() - expect(service.install().state).toBe('installed') + expect((await service.install()).state).toBe('installed') const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home') const managedHooksPath = join(managedCodexHome, 'hooks.json') @@ -466,14 +466,14 @@ describe('CodexHookService', () => { ) writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "system-model"\n', 'utf-8') - expect(service.install().state).toBe('installed') + expect((await service.install()).state).toBe('installed') const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') expect(runtimeToml).not.toContain(runtimeUserTrustHeader) expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`)) }) - it('refreshes mirrored system user hooks when the system hooks file changes', () => { + it('refreshes mirrored system user hooks when the system hooks file changes', async () => { const systemCodexHome = join(homes.tmpHome, '.codex') const systemHooksPath = join(systemCodexHome, 'hooks.json') mkdirSync(systemCodexHome, { recursive: true }) @@ -486,7 +486,7 @@ describe('CodexHookService', () => { ) const service = new CodexHookService() - expect(service.install().state).toBe('installed') + expect((await service.install()).state).toBe('installed') writeFileSync( systemHooksPath, @@ -495,7 +495,7 @@ describe('CodexHookService', () => { })}\n`, 'utf-8' ) - expect(service.install().state).toBe('installed') + expect((await service.install()).state).toBe('installed') const managedHooksPath = join(homes.userDataDir, 'codex-runtime-home', 'home', 'hooks.json') const runtimeHooks = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as { @@ -509,7 +509,7 @@ describe('CodexHookService', () => { expect(stopCommands).not.toContain('user-hook-old') }) - it('refreshes runtime user hooks without installing Orca-managed hooks', () => { + it('refreshes runtime user hooks without installing Orca-managed hooks', async () => { const systemCodexHome = join(homes.tmpHome, '.codex') const systemHooksPath = join(systemCodexHome, 'hooks.json') mkdirSync(systemCodexHome, { recursive: true }) @@ -537,7 +537,7 @@ describe('CodexHookService', () => { ) const service = new CodexHookService() - expect(service.install().state).toBe('installed') + expect((await service.install()).state).toBe('installed') const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home') const managedHooksPath = join(managedCodexHome, 'hooks.json') const runtimeTomlPath = join(managedCodexHome, 'config.toml') @@ -559,7 +559,7 @@ describe('CodexHookService', () => { 'utf-8' ) - const status = service.refreshRuntimeUserHooks() + const status = await service.refreshRuntimeUserHooks() expect(status.state).toBe('not_installed') expect(status.managedHooksPresent).toBe(false) diff --git a/src/main/codex/hook-service-wsl-runtime.test.ts b/src/main/codex/hook-service-wsl-runtime.test.ts index dbc51f39b35..8e892e69147 100644 --- a/src/main/codex/hook-service-wsl-runtime.test.ts +++ b/src/main/codex/hook-service-wsl-runtime.test.ts @@ -82,7 +82,7 @@ function expectedManagedCommand(scriptPath: string): string { } describe('Codex WSL runtime hook install', () => { - it('plans WSL hook files with Linux command and trust paths', () => { + it('plans WSL hook files with Linux command and trust paths', async () => { const runtimeHome = '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home' @@ -100,7 +100,7 @@ describe('Codex WSL runtime hook install', () => { }) }) - it('plans WSL hooks when the distro home is mounted on a Windows drive', () => { + it('plans WSL hooks when the distro home is mounted on a Windows drive', async () => { const runtimeHome = 'D:\\wsl-home\\.local\\share\\orca\\codex-runtime-home\\home' expect( @@ -121,7 +121,7 @@ describe('Codex WSL runtime hook install', () => { }) }) - it('uses WSL-canonical paths for hook commands and trust keys', () => { + it('uses WSL-canonical paths for hook commands and trust keys', async () => { const runtimeHome = '\\\\wsl.localhost\\Ubuntu\\home\\alias\\.local\\share\\orca\\codex-runtime-home\\home' const canonicalHome = '/home/alice/.local/share/orca/codex-runtime-home/home' @@ -141,7 +141,7 @@ describe('Codex WSL runtime hook install', () => { expect(plan?.configPath).toBe(pathWin32.join(runtimeHome, 'hooks.json')) }) - it('removes managed trust when the WSL canonical path changes', () => { + it('removes managed trust when the WSL canonical path changes', async () => { const plan = createTestPlan() writeFileSync(plan.configPath, '{"hooks":{}}\n', 'utf-8') writeFileSync(plan.tomlPath, '', 'utf-8') @@ -151,7 +151,7 @@ describe('Codex WSL runtime hook install', () => { commandScriptPath: '/old/home/.orca/agent-hooks/codex-hook.sh', trustConfigPath: '/old/home/hooks.json' } - expect(_internals.installManagedHooksIntoWslRuntime(oldPlan).state).toBe('installed') + expect((await _internals.installManagedHooksIntoWslRuntime(oldPlan)).state).toBe('installed') const oldCommand = expectedManagedCommand(oldPlan.commandScriptPath) const oldKey = computeTrustKey(getManagedTrustEntry(oldPlan, oldCommand)) @@ -160,7 +160,7 @@ describe('Codex WSL runtime hook install', () => { commandScriptPath: '/new/home/.orca/agent-hooks/codex-hook.sh', trustConfigPath: '/new/home/hooks.json' } - expect(_internals.installManagedHooksIntoWslRuntime(newPlan).state).toBe('installed') + expect((await _internals.installManagedHooksIntoWslRuntime(newPlan)).state).toBe('installed') const newCommand = expectedManagedCommand(newPlan.commandScriptPath) const newKey = computeTrustKey(getManagedTrustEntry(newPlan, newCommand)) const trustEntries = readHookTrustEntries(plan.tomlPath) @@ -171,7 +171,7 @@ describe('Codex WSL runtime hook install', () => { it.skipIf(process.platform === 'win32')( 'drains stdin when the WSL runtime script is missing', - () => { + async () => { const basePlan = createTestPlan() const plan = { ...basePlan, @@ -180,7 +180,7 @@ describe('Codex WSL runtime hook install', () => { writeFileSync(plan.configPath, '{"hooks":{}}\n', 'utf-8') writeFileSync(plan.tomlPath, '', 'utf-8') - expect(_internals.installManagedHooksIntoWslRuntime(plan).state).toBe('installed') + expect((await _internals.installManagedHooksIntoWslRuntime(plan)).state).toBe('installed') const installed = JSON.parse(readFileSync(plan.configPath, 'utf-8')) as HooksConfig const command = installed.hooks.UserPromptSubmit[0]?.hooks?.[0]?.command expect(command).toBe(expectedManagedCommand(plan.commandScriptPath)) @@ -193,20 +193,20 @@ describe('Codex WSL runtime hook install', () => { } ) - it('sweeps all managed WSL trust for disable or confirmed absence', () => { + it('sweeps all managed WSL trust for disable or confirmed absence', async () => { // Why: disable and confirmed absence intentionally pass []. Transient // unavailability must NOT use this path — last known-good trust remains. const plan = createTestPlan() writeFileSync(plan.configPath, '{"hooks":{}}\n', 'utf-8') writeFileSync(plan.tomlPath, '', 'utf-8') - expect(_internals.installManagedHooksIntoWslRuntime(plan).state).toBe('installed') + expect((await _internals.installManagedHooksIntoWslRuntime(plan)).state).toBe('installed') _internals.removeStaleWslRuntimeManagedHookTrustEntries(plan.tomlPath, []) expect(readHookTrustEntries(plan.tomlPath).size).toBe(0) }) - it('reconciles only current, conclusive WSL path settlements', () => { + it('reconciles only current, conclusive WSL path settlements', async () => { expect( _internals.getWslHookReconciliationAction({ settlement: { status: 'unavailable' }, @@ -272,7 +272,7 @@ describe('Codex WSL runtime hook install', () => { ).toBe('reinstall') }) - it('generates a POSIX hook that bridges WSL loopback failures through Windows curl', () => { + it('generates a POSIX hook that bridges WSL loopback failures through Windows curl', async () => { const script = _internals.getManagedScript('posix') expect(script).toContain('load_hook_endpoint()') expect(script).toContain('"set ORCA_AGENT_HOOK_TOKEN="*)') @@ -287,7 +287,7 @@ describe('Codex WSL runtime hook install', () => { it.skipIf(process.platform === 'win32')( 'refreshes stale hook coordinates from a Windows endpoint file', - () => { + async () => { const plan = createTestPlan() const root = dirname(plan.configPath) const endpointPath = join(root, 'endpoint.cmd') @@ -339,7 +339,7 @@ describe('Codex WSL runtime hook install', () => { it.skipIf(process.platform === 'win32')( 'uses the Windows curl discovered from the WSL PATH after loopback fails', - () => { + async () => { const plan = createTestPlan() const root = dirname(plan.configPath) const binDir = join(root, 'bin') @@ -378,7 +378,7 @@ describe('Codex WSL runtime hook install', () => { } ) - it('installs trusted WSL hooks and removes only Orca entries when disabled', () => { + it('installs trusted WSL hooks and removes only Orca entries when disabled', async () => { const plan = createTestPlan() const userCommand = '/bin/sh /home/alice/user-hook.sh' writeFileSync( @@ -408,7 +408,7 @@ describe('Codex WSL runtime hook install', () => { 'utf-8' ) - expect(_internals.installManagedHooksIntoWslRuntime(plan).state).toBe('installed') + expect((await _internals.installManagedHooksIntoWslRuntime(plan)).state).toBe('installed') const installed = JSON.parse(readFileSync(plan.configPath, 'utf-8')) as HooksConfig expect(Object.keys(installed.hooks).sort()).toEqual([...managedEvents].sort()) @@ -457,7 +457,7 @@ describe('Codex WSL runtime hook install app-server grant lane', () => { }) afterEach(() => { - trustGrantInternals.setGrantSessionRunnerSync(null) + trustGrantInternals.setGrantSessionRunner(null) trustGrantInternals.resetDiagnostics() codexAppServerCapabilityCache.clear() if (previousUserDataPath === undefined) { @@ -467,12 +467,12 @@ describe('Codex WSL runtime hook install app-server grant lane', () => { } }) - it('grants WSL managed trust through codex inside the distro instead of self-computed writes', () => { + it('grants WSL managed trust through codex inside the distro instead of self-computed writes', async () => { const plan = createTestPlan() writeFileSync(plan.configPath, '{"hooks":{}}\n', 'utf-8') writeFileSync(plan.tomlPath, '', 'utf-8') - const runner = vi.fn((request: CodexHookTrustGrantRequest) => { + const runner = vi.fn(async (request: CodexHookTrustGrantRequest) => { // Simulate codex's side: write trusted_hash blocks the way its config // writer would, then report the entries trusted. upsertHookTrustEntries( @@ -496,9 +496,9 @@ describe('Codex WSL runtime hook install app-server grant lane', () => { })) } }) - trustGrantInternals.setGrantSessionRunnerSync(runner) + trustGrantInternals.setGrantSessionRunner(runner) - expect(_internals.installManagedHooksIntoWslRuntime(plan).state).toBe('installed') + expect((await _internals.installManagedHooksIntoWslRuntime(plan)).state).toBe('installed') expect(runner).toHaveBeenCalledTimes(1) const request = runner.mock.calls[0]![0]! @@ -519,7 +519,7 @@ describe('Codex WSL runtime hook install app-server grant lane', () => { ) }) - it('keeps the unchanged self-computed lane when the WSL grant falls back', () => { + it('keeps the unchanged self-computed lane when the WSL grant falls back', async () => { const plan = createTestPlan() writeFileSync(plan.configPath, '{"hooks":{}}\n', 'utf-8') writeFileSync(plan.tomlPath, '', 'utf-8') @@ -527,9 +527,9 @@ describe('Codex WSL runtime hook install app-server grant lane', () => { const runner = vi.fn(() => { throw new Error('wsl.exe not reachable') }) - trustGrantInternals.setGrantSessionRunnerSync(runner) + trustGrantInternals.setGrantSessionRunner(runner) - expect(_internals.installManagedHooksIntoWslRuntime(plan).state).toBe('installed') + expect((await _internals.installManagedHooksIntoWslRuntime(plan)).state).toBe('installed') expect(runner).toHaveBeenCalledTimes(1) const command = expectedManagedCommand(plan.commandScriptPath) @@ -540,12 +540,12 @@ describe('Codex WSL runtime hook install app-server grant lane', () => { }) }) - it('uses the previous ledger to remove stale Codex hashes after a canonical path change', () => { + it('uses the previous ledger to remove stale Codex hashes after a canonical path change', async () => { const basePlan = createTestPlan() writeFileSync(basePlan.configPath, '{"hooks":{}}\n', 'utf-8') writeFileSync(basePlan.tomlPath, '', 'utf-8') let staleKeyExpectedRemoved: string | null = null - const runner = vi.fn((request: CodexHookTrustGrantRequest) => { + const runner = vi.fn(async (request: CodexHookTrustGrantRequest) => { if (staleKeyExpectedRemoved) { expect(readHookTrustEntries(basePlan.tomlPath).has(staleKeyExpectedRemoved)).toBe(false) } @@ -571,7 +571,7 @@ describe('Codex WSL runtime hook install app-server grant lane', () => { })) } }) - trustGrantInternals.setGrantSessionRunnerSync(runner) + trustGrantInternals.setGrantSessionRunner(runner) const oldPlan = { ...basePlan, @@ -579,7 +579,7 @@ describe('Codex WSL runtime hook install app-server grant lane', () => { trustConfigPath: '/old/home/hooks.json', linuxRuntimeHome: '/old/home' } - expect(_internals.installManagedHooksIntoWslRuntime(oldPlan).state).toBe('installed') + expect((await _internals.installManagedHooksIntoWslRuntime(oldPlan)).state).toBe('installed') const oldKey = computeTrustKey( getManagedTrustEntry(oldPlan, expectedManagedCommand(oldPlan.commandScriptPath)) ) @@ -591,7 +591,7 @@ describe('Codex WSL runtime hook install app-server grant lane', () => { trustConfigPath: '/new/home/hooks.json', linuxRuntimeHome: '/new/home' } - expect(_internals.installManagedHooksIntoWslRuntime(newPlan).state).toBe('installed') + expect((await _internals.installManagedHooksIntoWslRuntime(newPlan)).state).toBe('installed') const newKey = computeTrustKey( getManagedTrustEntry(newPlan, expectedManagedCommand(newPlan.commandScriptPath)) ) diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 270906012b5..93dfaf12f41 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -73,7 +73,8 @@ import { snapshotCodexRuntimeHookTrustProvenance } from './hook-trust-promotion' import { grantManagedCodexHookTrust } from './codex-hook-trust-grant' -import { readCurrentCodexTrustGrantLedgerHome } from './codex-trust-grant-host' +import { runExclusivelyForCodexTrustConfig } from './codex-trust-config-mutation-queue' +import { readCurrentNativeCodexTrustGrantLedgerHome } from './codex-trust-grant-host' import { getCodexLedgerTrustedHash, readCodexTrustGrantLedgerHomeForReconciliation, @@ -585,7 +586,30 @@ function removeSystemManagedHookTrustEntries(systemHomePath: string, hooksJsonPa }) } -function cleanupLegacySystemManagedHooks(): void { +// Why (#16441): these sequences mutate the runtime config.toml *and* the +// system one — approval promotion, the system-config sync and the legacy sweep +// all touch ~/.codex/config.toml — so holding only the runtime lane still lets +// a real-home grant's capture->restore window swallow their writes. Lock order +// is always runtime-before-system; every other holder acquires it that way too. +function runExclusivelyForRuntimeAndSystemTrustConfig( + runtimeHomePath: string, + run: () => Promise +): Promise { + return runExclusivelyForCodexTrustConfig(getCodexConfigTomlPath(runtimeHomePath), () => + runExclusivelyForCodexTrustConfig(getSystemCodexConfigTomlPath(), run) + ) +} + +function cleanupLegacySystemManagedHooks(): Promise { + // Why: shares the real-home lane with ensureRealHomeCodexHookState — both + // capture, mutate and roll back the user's ~/.codex/config.toml. + return runExclusivelyForCodexTrustConfig( + getSystemCodexConfigTomlPath(), + sweepLegacySystemManagedHooks + ) +} + +async function sweepLegacySystemManagedHooks(): Promise { if (systemCodexHomeHookSweepSuppressed()) { return } @@ -650,7 +674,7 @@ function cleanupLegacySystemManagedHooks(): void { // Remove only stale Orca hook entries and preserve other managers' metadata. const hooksWritePath = resolveHooksJsonWritePath(legacyConfigPath) const previousMode = statSync(hooksWritePath).mode - mutateRealHomeHooksPreservingUserTrust({ + await mutateRealHomeHooksPreservingUserTrust({ sourcePath: legacyConfigPath, runtimeHomePath: systemHomePath, tomlPath: getSystemCodexConfigTomlPath(), @@ -717,9 +741,9 @@ function cleanupLegacyCodexProfileHooks(): void { } } -function cleanupLegacyManagedHookRepresentations(): void { +async function cleanupLegacyManagedHookRepresentations(): Promise { try { - cleanupLegacySystemManagedHooks() + await cleanupLegacySystemManagedHooks() cleanupLegacyCodexProfileHooks() } catch (error) { console.warn('[codex-hook-service] failed to clean legacy Codex hooks', error) @@ -859,9 +883,20 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ].join('\n') } +// Why (#16441): the grant inside awaits a codex app-server session, so a +// concurrent pane launch could write this config.toml between this run's +// capture and its restore. One lane per file keeps the sequence atomic. function installManagedHooksIntoWslRuntime( plan: CodexWslRuntimeHookInstallPlan -): AgentHookInstallStatus { +): Promise { + return runExclusivelyForCodexTrustConfig(plan.tomlPath, () => + installManagedHooksIntoWslRuntimeExclusively(plan) + ) +} + +async function installManagedHooksIntoWslRuntimeExclusively( + plan: CodexWslRuntimeHookInstallPlan +): Promise { const config = readHooksJson(plan.configPath) if (!config) { return { @@ -924,7 +959,7 @@ function installManagedHooksIntoWslRuntime( trustEntries, previousLedgerHome ? [previousLedgerHome] : [] ) - const grant = grantManagedCodexHookTrust({ + const grant = await grantManagedCodexHookTrust({ runtimeHomePath, tomlPath: plan.tomlPath, managedCommand: command, @@ -1050,17 +1085,24 @@ export class CodexHookService { return generation } - installForRuntimeHome( + async installForRuntimeHome( runtimeHomePath: string | null | undefined, target?: CodexWslRuntimeHookTarget - ): AgentHookInstallStatus | null { + ): Promise { const generation = this.supersedeWslReconciliation(runtimeHomePath) let installedTrustConfigPath: string | null = null - // Why: JS is single-threaded, so the synchronous install below finishes - // before any async `wsl.exe` settlement callback runs — this flag is - // always set by the time the callback reads it. let installSucceeded = false - const onCanonicalPathSettled = (settlement: WslCanonicalPathSettlement): void => { + // Why: the install below now awaits a codex app-server session, so a + // settlement callback can land mid-install. This gate keeps reconciliation + // reading the finished install's flags, as it did when the install was + // synchronous and no callback could interleave with it. + let markPrimaryInstallSettled!: () => void + let reconciliationChain = new Promise((resolve) => { + markPrimaryInstallSettled = resolve + }) + const reconcileSettledWslCanonicalPath = async ( + settlement: WslCanonicalPathSettlement + ): Promise => { if (!runtimeHomePath) { return } @@ -1097,7 +1139,7 @@ export class CodexHookService { if (!resolvedPlan) { return } - const status = installManagedHooksIntoWslRuntime(resolvedPlan) + const status = await installManagedHooksIntoWslRuntime(resolvedPlan) if (status.state === 'error') { console.warn('[codex-hook-service] failed to reconcile WSL hook path', status.detail) return @@ -1105,6 +1147,13 @@ export class CodexHookService { installedTrustConfigPath = resolvedPlan.trustConfigPath installSucceeded = status.state === 'installed' } + const onCanonicalPathSettled = (settlement: WslCanonicalPathSettlement): void => { + const run = (): Promise => reconcileSettledWslCanonicalPath(settlement) + reconciliationChain = reconciliationChain.then(run, run) + void reconciliationChain.catch((error: unknown) => { + console.warn('[codex-hook-service] failed to reconcile WSL hook path', error) + }) + } const wslPlan = createCodexWslRuntimeHookInstallPlan( runtimeHomePath, target, @@ -1112,9 +1161,13 @@ export class CodexHookService { onCanonicalPathSettled ) installedTrustConfigPath = wslPlan?.trustConfigPath ?? null - const status = wslPlan ? installManagedHooksIntoWslRuntime(wslPlan) : null - installSucceeded = status?.state === 'installed' - return status + try { + const status = wslPlan ? await installManagedHooksIntoWslRuntime(wslPlan) : null + installSucceeded = status?.state === 'installed' + return status + } finally { + markPrimaryInstallSettled() + } } refreshRuntimeUserHooksForRuntimeHome( @@ -1169,7 +1222,7 @@ export class CodexHookService { // hashes or wrote fallback hashes. Re-resolving PATH here doubles sync launch work. const ledgerHome = recentGrantEntries === null - ? readCurrentCodexTrustGrantLedgerHome(runtimeHomePath, { kind: 'native' }) + ? readCurrentNativeCodexTrustGrantLedgerHome(runtimeHomePath) : null const recentGrantHashes = new Map() for (const entry of recentGrantEntries ?? []) { @@ -1273,7 +1326,16 @@ export class CodexHookService { // Why: runtimeHomePath defaults to the shared managed mirror, but a managed // account launching against its own self-contained CODEX_HOME passes that // per-account home so hooks.json/config.toml/trust land where codex reads. - install(runtimeHomePath: string = getOrcaManagedCodexHomePath()): AgentHookInstallStatus { + install( + runtimeHomePath: string = getOrcaManagedCodexHomePath() + ): Promise { + // Why: same lane as the grant it performs — see installManagedHooksIntoWslRuntime. + return runExclusivelyForRuntimeAndSystemTrustConfig(runtimeHomePath, () => + this.installExclusively(runtimeHomePath) + ) + } + + private async installExclusively(runtimeHomePath: string): Promise { const configPath = getConfigPath(runtimeHomePath) const scriptPath = getManagedScriptPath() // Why: must run before this install rewrites hooks.json/config.toml — @@ -1375,7 +1437,7 @@ export class CodexHookService { // then carry Codex's verbatim hashes into stale cleanup so it cannot // delete what Codex just wrote. Mirrored user trust keeps its existing // verbatim-carry lane either way. - const grant = grantManagedCodexHookTrust({ + const grant = await grantManagedCodexHookTrust({ runtimeHomePath, tomlPath, managedCommand: command, @@ -1410,12 +1472,7 @@ export class CodexHookService { } } snapshotCodexRuntimeHookTrustProvenance(runtimeHomePath) - try { - cleanupLegacySystemManagedHooks() - cleanupLegacyCodexProfileHooks() - } catch (error) { - console.warn('[codex-hook-service] failed to clean legacy Codex hooks', error) - } + await cleanupLegacyManagedHookRepresentations() return this.getStatusAfterInstall(recentGrantEntries, runtimeHomePath) } @@ -1535,7 +1592,15 @@ export class CodexHookService { refreshRuntimeUserHooks( runtimeHomePath: string = getOrcaManagedCodexHomePath() - ): AgentHookInstallStatus { + ): Promise { + return runExclusivelyForRuntimeAndSystemTrustConfig(runtimeHomePath, () => + this.refreshRuntimeUserHooksExclusively(runtimeHomePath) + ) + } + + private async refreshRuntimeUserHooksExclusively( + runtimeHomePath: string + ): Promise { const configPath = getConfigPath(runtimeHomePath) // Why: same as install() — capture in-Orca approvals before this refresh // rewrites the runtime files they are keyed against. @@ -1543,7 +1608,7 @@ export class CodexHookService { const config = readHooksJson(configPath) if (!config) { // Why: disabled launch prep once called remove(); preserve that legacy cleanup even when runtime hooks.json is malformed. - cleanupLegacyManagedHookRepresentations() + await cleanupLegacyManagedHookRepresentations() return { agent: 'codex', state: 'error', @@ -1592,17 +1657,23 @@ export class CodexHookService { } snapshotCodexRuntimeHookTrustProvenance(runtimeHomePath) - cleanupLegacyManagedHookRepresentations() + await cleanupLegacyManagedHookRepresentations() return this.getStatus(runtimeHomePath) } - remove(): AgentHookInstallStatus { + remove(): Promise { + return runExclusivelyForRuntimeAndSystemTrustConfig(getOrcaManagedCodexHomePath(), () => + this.removeExclusively() + ) + } + + private async removeExclusively(): Promise { const configPath = getConfigPath() const configExists = existsSync(configPath) const config = readHooksJson(configPath) if (!config) { // Why: a malformed hooks.json shouldn't strand old hooks in ~/.codex or the legacy profile after disabling. - cleanupLegacyManagedHookRepresentations() + await cleanupLegacyManagedHookRepresentations() return { agent: 'codex', state: 'error', @@ -1635,7 +1706,7 @@ export class CodexHookService { // Why: drop trust entries so config.toml doesn't accumulate dead [hooks.state] blocks across install/remove cycles. removeRuntimeManagedHookTrustEntries(configPath) - cleanupLegacyManagedHookRepresentations() + await cleanupLegacyManagedHookRepresentations() return this.getStatus() } diff --git a/src/main/codex/hook-trust-promotion.test.ts b/src/main/codex/hook-trust-promotion.test.ts index 0f65b73b515..56a772bc1f4 100644 --- a/src/main/codex/hook-trust-promotion.test.ts +++ b/src/main/codex/hook-trust-promotion.test.ts @@ -39,6 +39,10 @@ vi.mock('os', async (importOriginal) => { } }) +import { + restoreCodexTrustSessionsForTests, + stubCodexTrustSessionsForTests +} from './hook-service-test-harness' import { CodexHookService } from './hook-service' let tmpHome: string @@ -50,6 +54,7 @@ beforeEach(() => { userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-user-data-')) previousUserDataPath = process.env.ORCA_USER_DATA_PATH process.env.ORCA_USER_DATA_PATH = userDataDir + stubCodexTrustSessionsForTests() homedirMock.mockReturnValue(tmpHome) getPathMock.mockImplementation((name: string) => { if (name === 'userData') { @@ -60,6 +65,7 @@ beforeEach(() => { }) afterEach(() => { + restoreCodexTrustSessionsForTests() rmSync(tmpHome, { recursive: true, force: true }) rmSync(userDataDir, { recursive: true, force: true }) if (previousUserDataPath === undefined) { @@ -130,7 +136,7 @@ function readSystemToml(): string { } describe('codex hook trust write-back promotion', () => { - it('mirrors default-home trust when the .codex directory is a symlink', () => { + it('mirrors default-home trust when the .codex directory is a symlink', async () => { const targetHome = join(tmpHome, 'dotfiles-codex') mkdirSync(targetHome) symlinkSync(targetHome, systemCodexDir(), process.platform === 'win32' ? 'junction' : 'dir') @@ -141,7 +147,7 @@ describe('codex hook trust write-back promotion', () => { upsertHookTrustEntriesInContent('', [systemEntry]) ) - expect(new CodexHookService().install().state).toBe('installed') + expect((await new CodexHookService().install()).state).toBe('installed') const runtimeTrust = readHookTrustEntries(join(runtimeHomeDir(), 'config.toml')) expect(runtimeTrust.get(computeTrustKey(runtimeUserStopEntry()))?.trustedHash).toBe( @@ -149,10 +155,10 @@ describe('codex hook trust write-back promotion', () => { ) }) - it('keeps an in-Orca approval of a user hook across launches and promotes it to ~/.codex', () => { + it('keeps an in-Orca approval of a user hook across launches and promotes it to ~/.codex', async () => { writeSystemUserHook() const service = new CodexHookService() - service.install() + await service.install() // The mirrored user hook has no system trust yet, so no runtime trust // entry exists for it — Codex would show it as pending review. @@ -163,7 +169,7 @@ describe('codex hook trust write-back promotion', () => { simulateCodexApproval(runtimeUserStopEntry()) const approvedHash = computeTrustedHash(runtimeUserStopEntry()) - service.install() + await service.install() // Approval survives the relaunch instead of being wiped as stale… expect(readHookTrustEntries(runtimeTomlPath).get(approvalKey)?.trustedHash).toBe(approvedHash) @@ -177,15 +183,15 @@ describe('codex hook trust write-back promotion', () => { // Steady state: another launch with no external changes rewrites nothing. const systemTomlAfterPromotion = readSystemToml() const runtimeTomlAfterPromotion = readFileSync(runtimeTomlPath, 'utf-8') - service.install() + await service.install() expect(readSystemToml()).toBe(systemTomlAfterPromotion) expect(readFileSync(runtimeTomlPath, 'utf-8')).toBe(runtimeTomlAfterPromotion) }) - it('never promotes trust for the Orca-managed status hook into ~/.codex', () => { + it('never promotes trust for the Orca-managed status hook into ~/.codex', async () => { writeSystemUserHook() const service = new CodexHookService() - service.install() + await service.install() // Simulate Codex rewriting the managed Stop hook's trust entry (as an // approval after hash drift would). @@ -206,20 +212,20 @@ describe('codex hook trust write-back promotion', () => { { hash: 'sha256:codex-corrected-managed-hash' } ) - service.install() + await service.install() expect(readSystemToml()).not.toContain(managedCommand) expect(readSystemToml()).not.toContain('codex-corrected-managed-hash') }) - it('does not resurrect trust the user revoked in ~/.codex/config.toml', () => { + it('does not resurrect trust the user revoked in ~/.codex/config.toml', async () => { writeSystemUserHook() // Pre-trust the hook in the system config, as a terminal Codex session would. const systemTomlPath = join(systemCodexDir(), 'config.toml') writeFileSync(systemTomlPath, upsertHookTrustEntriesInContent('', [systemUserStopEntry()])) const service = new CodexHookService() - service.install() + await service.install() const runtimeTomlPath = join(runtimeHomeDir(), 'config.toml') const approvalKey = computeTrustKey(runtimeUserStopEntry()) @@ -227,19 +233,19 @@ describe('codex hook trust write-back promotion', () => { // User revokes in the system config; the runtime copy must not win. writeFileSync(systemTomlPath, '') - service.install() + await service.install() expect(readHookTrustEntries(runtimeTomlPath).get(approvalKey)).toBeUndefined() expect(readSystemToml()).not.toContain('[hooks.state.') }) - it('promotes an in-Orca disable of a mirrored user hook back to the system config', () => { + it('promotes an in-Orca disable of a mirrored user hook back to the system config', async () => { writeSystemUserHook() const systemTomlPath = join(systemCodexDir(), 'config.toml') writeFileSync(systemTomlPath, upsertHookTrustEntriesInContent('', [systemUserStopEntry()])) const service = new CodexHookService() - service.install() + await service.install() // User disables the hook via /hooks inside Orca-launched Codex. const runtimeTomlPath = join(runtimeHomeDir(), 'config.toml') @@ -251,7 +257,7 @@ describe('codex hook trust write-back promotion', () => { upsertHookTrustEntriesInContent(runtimeToml, [{ ...runtimeUserStopEntry(), enabled: false }]) ) - service.install() + await service.install() const systemState = readHookTrustEntries(systemTomlPath).get( computeTrustKey(systemUserStopEntry()) @@ -261,17 +267,17 @@ describe('codex hook trust write-back promotion', () => { expect(readHookTrustEntries(runtimeTomlPath).get(approvalKey)?.enabled).toBe(false) }) - it('carries a Codex-written hash verbatim when it differs from the reproduced hash', () => { + it('carries a Codex-written hash verbatim when it differs from the reproduced hash', async () => { // Simulates Codex changing its trust hash algorithm: the approval hash in // the runtime config no longer matches computeTrustedHash's output. writeSystemUserHook() const service = new CodexHookService() - service.install() + await service.install() const driftedHash = 'sha256:codex-next-gen-hash-orca-cannot-reproduce' simulateCodexApproval(runtimeUserStopEntry(), { hash: driftedHash }) - service.install() + await service.install() const runtimeTomlPath = join(runtimeHomeDir(), 'config.toml') const approvalKey = computeTrustKey(runtimeUserStopEntry()) @@ -283,24 +289,24 @@ describe('codex hook trust write-back promotion', () => { ).toBe(driftedHash) // And the launch after that still keeps it. - service.install() + await service.install() expect(readHookTrustEntries(runtimeTomlPath).get(approvalKey)?.trustedHash).toBe(driftedHash) }) - it('does not touch ~/.codex on the first launch after upgrading (no provenance yet)', () => { + it('does not touch ~/.codex on the first launch after upgrading (no provenance yet)', async () => { // Simulates an existing install: runtime home fully materialized by a // build without provenance snapshots, managed hooks only. const service = new CodexHookService() - service.install() + await service.install() rmSync(join(runtimeHomeDir(), '.orca-hook-trust-provenance.json'), { force: true }) - service.install() + await service.install() expect(readSystemToml()).toBe('') expect(existsSync(join(systemCodexDir(), 'config.toml'))).toBe(false) }) - it('re-promoting mirrored trust without provenance is a no-op on ~/.codex', () => { + it('re-promoting mirrored trust without provenance is a no-op on ~/.codex', async () => { // Existing install with a system-trusted user hook, upgraded to this // build: the mirrored runtime entry has no provenance, so promotion must // sit out this launch and leave the system config byte-identical. @@ -308,16 +314,16 @@ describe('codex hook trust write-back promotion', () => { const systemTomlPath = join(systemCodexDir(), 'config.toml') writeFileSync(systemTomlPath, upsertHookTrustEntriesInContent('', [systemUserStopEntry()])) const service = new CodexHookService() - service.install() + await service.install() rmSync(join(runtimeHomeDir(), '.orca-hook-trust-provenance.json'), { force: true }) const systemTomlBefore = readSystemToml() - service.install() + await service.install() expect(readSystemToml()).toBe(systemTomlBefore) }) - it('does not resurrect trust revoked in ~/.codex before the first provenance snapshot', () => { + it('does not resurrect trust revoked in ~/.codex before the first provenance snapshot', async () => { // Old build mirrored a system-trusted hook into the runtime home; the // user then revoked it in ~/.codex/config.toml and upgraded to this // build. The stale runtime mirror must not be mistaken for an approval. @@ -325,11 +331,11 @@ describe('codex hook trust write-back promotion', () => { const systemTomlPath = join(systemCodexDir(), 'config.toml') writeFileSync(systemTomlPath, upsertHookTrustEntriesInContent('', [systemUserStopEntry()])) const service = new CodexHookService() - service.install() + await service.install() rmSync(join(runtimeHomeDir(), '.orca-hook-trust-provenance.json'), { force: true }) writeFileSync(systemTomlPath, '') - service.install() + await service.install() expect(readSystemToml()).not.toContain('[hooks.state.') expect( @@ -339,21 +345,21 @@ describe('codex hook trust write-back promotion', () => { ).toBeUndefined() }) - it('does not flip a hook the user disabled in ~/.codex back to enabled after upgrading', () => { + it('does not flip a hook the user disabled in ~/.codex back to enabled after upgrading', async () => { // Old build mirrored the hook enabled=true; the user then set // enabled = false in ~/.codex/config.toml and upgraded to this build. writeSystemUserHook() const systemTomlPath = join(systemCodexDir(), 'config.toml') writeFileSync(systemTomlPath, upsertHookTrustEntriesInContent('', [systemUserStopEntry()])) const service = new CodexHookService() - service.install() + await service.install() rmSync(join(runtimeHomeDir(), '.orca-hook-trust-provenance.json'), { force: true }) writeFileSync( systemTomlPath, upsertHookTrustEntriesInContent('', [{ ...systemUserStopEntry(), enabled: false }]) ) - service.install() + await service.install() expect( readHookTrustEntries(systemTomlPath).get(computeTrustKey(systemUserStopEntry()))?.enabled @@ -365,13 +371,13 @@ describe('codex hook trust write-back promotion', () => { ).toBe(false) }) - it('promotes one approval to every identical system hook collapsed by deduping', () => { + it('promotes one approval to every identical system hook collapsed by deduping', async () => { writeSystemUserHook([USER_HOOK_COMMAND, USER_HOOK_COMMAND]) const service = new CodexHookService() - service.install() + await service.install() simulateCodexApproval(runtimeUserStopEntry()) - service.install() + await service.install() const systemTrust = readHookTrustEntries(join(systemCodexDir(), 'config.toml')) const approvedHash = computeTrustedHash(runtimeUserStopEntry()) @@ -379,16 +385,16 @@ describe('codex hook trust write-back promotion', () => { expect(systemTrust.get(computeTrustKey(systemUserStopEntry(1)))?.trustedHash).toBe(approvedHash) }) - it('skips promotion when the approved hook no longer exists in ~/.codex/hooks.json', () => { + it('skips promotion when the approved hook no longer exists in ~/.codex/hooks.json', async () => { writeSystemUserHook() const service = new CodexHookService() - service.install() + await service.install() simulateCodexApproval(runtimeUserStopEntry()) // User deletes the hook from their system hooks.json before relaunching. writeFileSync(join(systemCodexDir(), 'hooks.json'), JSON.stringify({ hooks: {} })) - service.install() + await service.install() expect(readSystemToml()).not.toContain('[hooks.state.') // The runtime copy of the deleted hook (and its approval) is cleaned up. @@ -398,10 +404,10 @@ describe('codex hook trust write-back promotion', () => { ).toBeUndefined() }) - it('promotes approvals recorded while status hooks are disabled (refresh path)', () => { + it('promotes approvals recorded while status hooks are disabled (refresh path)', async () => { writeSystemUserHook() const service = new CodexHookService() - service.refreshRuntimeUserHooks() + await service.refreshRuntimeUserHooks() // Without the managed status hook, the mirrored user hook sits at group 0. const refreshedRuntimeEntry: CodexTrustEntry = { @@ -411,7 +417,7 @@ describe('codex hook trust write-back promotion', () => { simulateCodexApproval(refreshedRuntimeEntry) const approvedHash = computeTrustedHash(refreshedRuntimeEntry) - service.refreshRuntimeUserHooks() + await service.refreshRuntimeUserHooks() expect( readHookTrustEntries(join(systemCodexDir(), 'config.toml')).get( diff --git a/src/main/codex/managed-home-shell-preflight.test.ts b/src/main/codex/managed-home-shell-preflight.test.ts index 38cd9b7d146..8cc519d3c79 100644 --- a/src/main/codex/managed-home-shell-preflight.test.ts +++ b/src/main/codex/managed-home-shell-preflight.test.ts @@ -35,7 +35,7 @@ describe('managed Codex shell preflight', () => { ).toBe(home) }) - it('accepts a marker-proven account home and installs only while hooks are enabled', () => { + it('accepts a marker-proven account home and installs only while hooks are enabled', async () => { const userDataPath = makeRoot() const home = join(userDataPath, 'codex-accounts', 'account-1', 'home') mkdirSync(home, { recursive: true }) @@ -50,12 +50,22 @@ describe('managed Codex shell preflight', () => { const env = { CODEX_HOME: home, ORCA_CODEX_HOME: home } expect( - prepareManagedCodexHomeBeforeShellLaunch({ userDataPath, hooksEnabled: true, env, install }) + await prepareManagedCodexHomeBeforeShellLaunch({ + userDataPath, + hooksEnabled: true, + env, + install + }) ).toMatchObject({ state: 'installed' }) expect(install).toHaveBeenCalledWith(home) expect( - prepareManagedCodexHomeBeforeShellLaunch({ userDataPath, hooksEnabled: false, env, install }) + await prepareManagedCodexHomeBeforeShellLaunch({ + userDataPath, + hooksEnabled: false, + env, + install + }) ).toBeNull() expect(install).toHaveBeenCalledTimes(1) }) diff --git a/src/main/codex/managed-home-shell-preflight.ts b/src/main/codex/managed-home-shell-preflight.ts index 35dd3a68a70..d94b31e23d7 100644 --- a/src/main/codex/managed-home-shell-preflight.ts +++ b/src/main/codex/managed-home-shell-preflight.ts @@ -77,12 +77,20 @@ export function resolveManagedCodexShellPreflightHome( return resolveAccountManagedHome(codexHome, userDataPath) } -export function prepareManagedCodexHomeBeforeShellLaunch(args: { +/** + * Shell-startup preflight for a managed CODEX_HOME. + * + * Async because the Codex install awaits an app-server trust-grant session + * in-process. The old lane forked that session through spawnSync purely to + * borrow an event loop; the CLI already has one, so awaiting here removes a + * whole ELECTRON_RUN_AS_NODE process from every managed-home shell launch. + */ +export async function prepareManagedCodexHomeBeforeShellLaunch(args: { env?: ShellPreflightEnvironment userDataPath: string hooksEnabled: boolean - install?: (runtimeHomePath: string) => AgentHookInstallStatus -}): AgentHookInstallStatus | null { + install?: (runtimeHomePath: string) => AgentHookInstallStatus | Promise +}): Promise { if (!args.hooksEnabled) { return null } diff --git a/src/main/codex/retained-codex-hook-state.test.ts b/src/main/codex/retained-codex-hook-state.test.ts index 642b8bc7476..4dcdb9dc9a0 100644 --- a/src/main/codex/retained-codex-hook-state.test.ts +++ b/src/main/codex/retained-codex-hook-state.test.ts @@ -13,11 +13,11 @@ function status(state: 'installed' | 'not_installed' | 'error'): AgentHookInstal } describe('retained Codex hook state', () => { - it('repairs Orca hooks before a retained shell can launch Codex', () => { + it('repairs Orca hooks before a retained shell can launch Codex', async () => { const install = vi.fn(() => status('installed')) const refreshRuntimeUserHooks = vi.fn(() => status('not_installed')) - reconcileRetainedCodexHookHomes({ + await reconcileRetainedCodexHookHomes({ hookService: { install, refreshRuntimeUserHooks }, hooksEnabled: true, runtimeHomePaths: ['/orca/shared-home', '/orca/account-home'] @@ -29,11 +29,11 @@ describe('retained Codex hook state', () => { expect(refreshRuntimeUserHooks).not.toHaveBeenCalled() }) - it('removes only Orca hooks from retained homes when hooks are disabled', () => { + it('removes only Orca hooks from retained homes when hooks are disabled', async () => { const install = vi.fn(() => status('installed')) const refreshRuntimeUserHooks = vi.fn(() => status('not_installed')) - reconcileRetainedCodexHookHomes({ + await reconcileRetainedCodexHookHomes({ hookService: { install, refreshRuntimeUserHooks }, hooksEnabled: false, runtimeHomePaths: ['/orca/shared-home'] diff --git a/src/main/codex/retained-codex-hook-state.ts b/src/main/codex/retained-codex-hook-state.ts index ccdf8c86af3..f3e6304e390 100644 --- a/src/main/codex/retained-codex-hook-state.ts +++ b/src/main/codex/retained-codex-hook-state.ts @@ -1,20 +1,31 @@ import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' type RetainedCodexHookService = { - install: (runtimeHomePath: string) => AgentHookInstallStatus - refreshRuntimeUserHooks: (runtimeHomePath: string) => AgentHookInstallStatus + install: (runtimeHomePath: string) => AgentHookInstallStatus | Promise + refreshRuntimeUserHooks: ( + runtimeHomePath: string + ) => AgentHookInstallStatus | Promise } -export function reconcileRetainedCodexHookHomes(args: { +/** + * Repairs the hook state of Codex homes that retained shells still point at. + * + * Why not on the startup critical path (#16441): each home can run a codex + * app-server trust-grant session, so N retained homes used to mean N sequential + * multi-second blocks before the first window could paint. Callers start this + * and move on — the repair only matters before a retained shell's next Codex + * invocation, which cannot happen until the daemon provider is already serving. + */ +export async function reconcileRetainedCodexHookHomes(args: { hookService: RetainedCodexHookService hooksEnabled: boolean runtimeHomePaths: readonly string[] -}): void { +}): Promise { for (const runtimeHomePath of args.runtimeHomePaths) { try { const status = args.hooksEnabled - ? args.hookService.install(runtimeHomePath) - : args.hookService.refreshRuntimeUserHooks(runtimeHomePath) + ? await args.hookService.install(runtimeHomePath) + : await args.hookService.refreshRuntimeUserHooks(runtimeHomePath) if (status.state === 'error') { console.warn('[codex-hook-service] failed to reconcile retained Codex home', status.detail) } diff --git a/src/main/index.ts b/src/main/index.ts index 0f7e44742a3..249700bcd16 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1073,12 +1073,17 @@ function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServices { if (livePtyIds) { reconcileCodexPaneAccountsWithLivePtys(livePtyIds) const settings = store?.getSettings() - reconcileRetainedCodexHookHomes({ + // Why (#16441): each retained home can run a codex app-server grant + // session. Awaiting them here delayed the first window by N sessions; + // a retained shell cannot invoke Codex before this provider serves. + void reconcileRetainedCodexHookHomes({ hookService: codexHookService, hooksEnabled: isAgentStatusHooksEnabled(settings) && settings?.disabledTuiAgents.includes('codex') !== true, runtimeHomePaths: codexRuntimeHome.getRetainedHostCodexHookHomePaths(livePtyIds) + }).catch((error: unknown) => { + console.warn('[codex-hook-service] retained Codex home reconcile failed:', error) }) } } @@ -1139,24 +1144,24 @@ function bindTerminalRuntimeStartupServices( localPtyProviderStartupReady = services.then((value) => value.localPtyProviderReady) } -function prepareCodexRuntimeHomeForLaunch( +async function prepareCodexRuntimeHomeForLaunch( target?: CodexAccountSelectionTarget, launchEnv?: NodeJS.ProcessEnv, launchContext?: CodexHomeLaunchContext -): string | null { +): Promise { if ( target?.runtime !== 'wsl' && launchContext?.launchAgent === 'codex' && launchContext.workspacePath ) { try { - // Why: renderer quick-launch cannot await trust IPC before its PTY mounts; launch prep runs synchronously before every recognized Codex spawn. - markCodexProjectTrusted(launchContext.workspacePath) + // Why: renderer quick-launch cannot await trust IPC before its PTY mounts; launch prep runs before every recognized Codex spawn. + await markCodexProjectTrusted(launchContext.workspacePath) } catch (error) { console.warn('[codex-project-trust] failed to pre-mark launch workspace:', error) } } - const ensureRealHomeHooksIfSelected = (): boolean => { + const ensureRealHomeHooksIfSelected = async (): Promise => { if ( target?.runtime === 'wsl' || !codexRuntimeHome!.isHostSystemDefaultRealHomeSelected(launchEnv) @@ -1167,13 +1172,13 @@ function prepareCodexRuntimeHomeForLaunch( // and trusted by codex's own app-server grant — in the real ~/.codex before // the pane spawns. An incapable grant flips the lane gate so the launch // below falls back to the managed home instead of a status-blind pane. - ensureRealHomeCodexHookState({ + await ensureRealHomeCodexHookState({ hooksEnabled: isAgentStatusHooksEnabled(store?.getSettings()), userDataPath: app.getPath('userData') }) return true } - let realHomeHooksPrepared = ensureRealHomeHooksIfSelected() + let realHomeHooksPrepared = await ensureRealHomeHooksIfSelected() // Why: a ManagedCodexHomeTemporarilyUnavailableError must escape uncaught — // the fallbacks below all key off `null`, which means "system default", so // swallowing the refusal would launch the wrong account (#STA-4422). @@ -1184,7 +1189,7 @@ function prepareCodexRuntimeHomeForLaunch( // Why: launch prep can reject an untrusted managed home and clear its // selection. Establish hook capability for that newly selected lane, then // re-resolve if the capability gate rejects it. - realHomeHooksPrepared = ensureRealHomeHooksIfSelected() + realHomeHooksPrepared = await ensureRealHomeHooksIfSelected() if (realHomeHooksPrepared) { runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target, launchEnv, { unavailableManagedHomePath: launchContext?.unavailableManagedHomePath @@ -1207,12 +1212,12 @@ function prepareCodexRuntimeHomeForLaunch( try { // Why: honor the persisted off switch so post-startup launches can't reinstall removed hooks. const status = hooksEnabled - ? (codexHookService.installForRuntimeHome(runtimeHomePath, hookTarget) ?? + ? ((await codexHookService.installForRuntimeHome(runtimeHomePath, hookTarget)) ?? // Why: a managed account's launch home is its own self-contained // CODEX_HOME, so hooks/trust must install there, not the shared mirror. - codexHookService.install(runtimeHomePath ?? undefined)) + (await codexHookService.install(runtimeHomePath ?? undefined))) : (codexHookService.refreshRuntimeUserHooksForRuntimeHome(runtimeHomePath, hookTarget) ?? - codexHookService.refreshRuntimeUserHooks(runtimeHomePath ?? undefined)) + (await codexHookService.refreshRuntimeUserHooks(runtimeHomePath ?? undefined))) if (status.state === 'error') { console.warn( `[codex-hook-service] failed to ${ @@ -1306,7 +1311,7 @@ async function prepareCodexSessionResumeForLaunch(args: { if (args.workspacePath) { try { - markCodexProjectTrusted(args.workspacePath) + await markCodexProjectTrusted(args.workspacePath) } catch (error) { console.warn('[codex-project-trust] failed to pre-mark resumed workspace:', error) } @@ -1317,11 +1322,14 @@ async function prepareCodexSessionResumeForLaunch(args: { const hooksEnabled = isAgentStatusHooksEnabled(settingsStore.getSettings()) try { if (isSystemHome) { - ensureRealHomeCodexHookState({ hooksEnabled, userDataPath: app.getPath('userData') }) + await ensureRealHomeCodexHookState({ + hooksEnabled, + userDataPath: app.getPath('userData') + }) } else if (hooksEnabled) { - codexHookService.install(resumeHome) + await codexHookService.install(resumeHome) } else { - codexHookService.refreshRuntimeUserHooks(resumeHome) + await codexHookService.refreshRuntimeUserHooks(resumeHome) } } catch (error) { // Why: hook repair is best-effort; session provenance must still win over the currently selected home. @@ -3060,30 +3068,40 @@ void app.whenReady().then(async () => { console.warn('[worktrees] Failed to sweep leftover worktree directories:', error) }) 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()), - userDataPath: app.getPath('userData') - }) - } + // Why (#16441): the real-home grant runs a codex app-server session. It stays + // ordered before managed-hook reconciliation — an incapable host must re-arm + // and complete the legacy real-home sweep first — but awaiting it inline + // stalled app init behind that session, so chain instead of blocking. + const realHomeCodexHookState = codexRuntimeHome.isHostSystemDefaultRealHomeSelected() + ? ensureRealHomeCodexHookState({ + hooksEnabled: isAgentStatusHooksEnabled(store.getSettings()), + userDataPath: app.getPath('userData') + }).catch((error: unknown) => { + console.warn('[codex-real-home-hooks] startup ensure failed:', error) + }) + : Promise.resolve() 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())) { const managedHookStore = store - void applyAgentStatusHooksEnabled(true, managedHookStore.getSettings(), { - shouldHydrateShellPath: app.isPackaged, - onInstallError: recordManagedHookInstallFailure, - shouldContinue: (agent) => { - const settings = managedHookStore.getSettings() - return shouldContinueManagedHookStartup(isQuitting, settings, agent) - } - }).catch((error) => { - console.warn('[agent-hooks] failed to reconcile managed hooks on startup:', error) - }) + void realHomeCodexHookState + .then(() => + applyAgentStatusHooksEnabled(true, managedHookStore.getSettings(), { + shouldHydrateShellPath: app.isPackaged, + onInstallError: recordManagedHookInstallFailure, + shouldContinue: (agent) => { + const settings = managedHookStore.getSettings() + return shouldContinueManagedHookStartup(isQuitting, settings, agent) + } + }) + ) + .catch((error: unknown) => { + console.warn('[agent-hooks] failed to reconcile managed hooks on startup:', error) + }) } else { - removeManagedAgentHooks() + void removeManagedAgentHooks().catch((error: unknown) => { + console.warn('[agent-hooks] failed to remove managed hooks on startup:', error) + }) } } // Why: process-gone metrics only see survivors; retain a recent whole-app diff --git a/src/main/ipc/pty/host-env/codex-home.ts b/src/main/ipc/pty/host-env/codex-home.ts index ae825e31e78..7abde336181 100644 --- a/src/main/ipc/pty/host-env/codex-home.ts +++ b/src/main/ipc/pty/host-env/codex-home.ts @@ -114,8 +114,10 @@ type ManagedCodexAuthResolutionArgs = { getSettings: () => GlobalSettings | undefined requiredCodexHomePath?: string target: CodexAccountSelectionTarget - resolveCurrent: () => string | null - resolveAfterUnavailable: (unavailableManagedHomePath: string) => string | null + resolveCurrent: () => string | null | Promise + resolveAfterUnavailable: ( + unavailableManagedHomePath: string + ) => string | null | Promise } export function resolveCodexHomeAfterManagedAuthReadiness( @@ -150,7 +152,7 @@ async function continueCodexHomeAfterManagedAuthWait( if (args.requiredCodexHomePath) { return selectedCodexHomePath } - const currentCodexHomePath = args.resolveCurrent() + const currentCodexHomePath = await args.resolveCurrent() if (codexHomeSelectionsEqual(selectedCodexHomePath, currentCodexHomePath)) { return selectedCodexHomePath } @@ -172,7 +174,7 @@ async function continueCodexHomeAfterManagedAuthWait( if (args.requiredCodexHomePath) { throw new Error(CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE) } - selectedCodexHomePath = args.resolveAfterUnavailable(selectedCodexHomePath!) + selectedCodexHomePath = await args.resolveAfterUnavailable(selectedCodexHomePath!) if (attempt === 1) { break } diff --git a/src/main/ipc/pty/host-env/codex-resume.ts b/src/main/ipc/pty/host-env/codex-resume.ts index a32aefe9057..62e199e09c3 100644 --- a/src/main/ipc/pty/host-env/codex-resume.ts +++ b/src/main/ipc/pty/host-env/codex-resume.ts @@ -103,14 +103,14 @@ export function resolveCodexResumeLaunch( }) } -export function reconcileSharedRuntimeResumeHome( +export async function reconcileSharedRuntimeResumeHome( resumeHome: Extract, - resolveCurrentHome: () => string | null -): string { + resolveCurrentHome: () => string | null | Promise +): Promise { if (!resumeHome.reconcileSharedRuntimeAuth) { return resumeHome.codexHomePath } - const currentHome = resolveCurrentHome() + const currentHome = await resolveCurrentHome() if (!codexHomePathsEqual(currentHome, resumeHome.codexHomePath)) { throw new Error(CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE) } diff --git a/src/main/ipc/pty/host-env/types.ts b/src/main/ipc/pty/host-env/types.ts index 61ff335b43d..298949d615a 100644 --- a/src/main/ipc/pty/host-env/types.ts +++ b/src/main/ipc/pty/host-env/types.ts @@ -39,11 +39,14 @@ export type CodexHomeLaunchContext = { unavailableManagedHomePath?: string } +// Why (#16441): Codex launch prep grants hook trust through a codex app-server +// session. It resolves asynchronously so the Electron main thread stays +// responsive; every consumer already runs inside an async spawn path. export type GetSelectedCodexHomePath = ( target?: CodexAccountSelectionTarget, launchEnv?: NodeJS.ProcessEnv, launchContext?: CodexHomeLaunchContext -) => string | null +) => string | null | Promise export type PrepareCodexSessionResume = (args: { providerSession: AgentProviderSessionMetadata diff --git a/src/main/ipc/pty/ipc/spawn-env-codex.ts b/src/main/ipc/pty/ipc/spawn-env-codex.ts index 655a848e980..42550f6732d 100644 --- a/src/main/ipc/pty/ipc/spawn-env-codex.ts +++ b/src/main/ipc/pty/ipc/spawn-env-codex.ts @@ -51,24 +51,23 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis // Why: declared after the strip so a local-provider spawn cannot capture the // pre-strip env — only the daemon branch below re-derives this from baseEnv. ctx.env = ctx.baseEnv + const selectLaunchCodexHome = async (): Promise => + (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, { + workspacePath: ctx.cwd, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined + })) ?? null ctx.selectedCodexHomePath = !ctx.preAdoptedStablePane && !args.connectionId ? getCompatibleSelectedCodexHomePath( ctx.codexSelectionTarget, codexResumeHome - ? ctx.deps.reconcileSharedRuntimeResumeHome(codexResumeHome, () => + ? await ctx.deps.reconcileSharedRuntimeResumeHome(codexResumeHome, async () => getCompatibleSelectedCodexHomePath( ctx.codexSelectionTarget, - ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, { - workspacePath: ctx.cwd, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined - }) ?? null + await selectLaunchCodexHome() ) ) - : (ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, { - workspacePath: ctx.cwd, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined - }) ?? null) + : await selectLaunchCodexHome() ) : null if (!ctx.preAdoptedStablePane && args.launchAgent === 'codex' && args.sessionId === undefined) { @@ -77,22 +76,22 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis getSettings: () => ctx.deps.getSettings?.(), requiredCodexHomePath: codexResumeHome?.codexHomePath, target: ctx.codexSelectionTarget, - resolveCurrent: () => + resolveCurrent: async () => getCompatibleSelectedCodexHomePath( ctx.codexSelectionTarget, - ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, { + (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, { workspacePath: ctx.cwd, launchAgent: 'codex' - }) ?? null + })) ?? null ), - resolveAfterUnavailable: (unavailableManagedHomePath) => + resolveAfterUnavailable: async (unavailableManagedHomePath) => getCompatibleSelectedCodexHomePath( ctx.codexSelectionTarget, - ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, { + (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, { workspacePath: ctx.cwd, launchAgent: 'codex', unavailableManagedHomePath - }) ?? null + })) ?? null ) }) ctx.selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution diff --git a/src/main/ipc/pty/ipc/spawn-types.ts b/src/main/ipc/pty/ipc/spawn-types.ts index 68e1cc62227..af5abe564ed 100644 --- a/src/main/ipc/pty/ipc/spawn-types.ts +++ b/src/main/ipc/pty/ipc/spawn-types.ts @@ -110,8 +110,8 @@ export type PtySpawnIpcDeps = { ) => Promise reconcileSharedRuntimeResumeHome: ( resumeHome: Extract, - resolveCurrent: () => string | null - ) => string + resolveCurrent: () => string | null | Promise + ) => Promise stripSequencedStartupResumeArgv: | undefined>( env: T, launch: CodexResumeLaunch diff --git a/src/main/ipc/pty/provider/local-configure.ts b/src/main/ipc/pty/provider/local-configure.ts index 2051d937c94..50f3c3e4016 100644 --- a/src/main/ipc/pty/provider/local-configure.ts +++ b/src/main/ipc/pty/provider/local-configure.ts @@ -38,7 +38,7 @@ export function configureLocalPtyProvider(args: { getWindowsPowerShellImplementation: () => getSettings ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') : undefined, pwshAvailable: () => isPwshAvailableAsync(), - buildSpawnEnv: (id, baseEnv, ctx) => { + buildSpawnEnv: async (id, baseEnv, ctx) => { const codexSelectionTarget: CodexAccountSelectionTarget = ctx?.isWsl === true ? { runtime: 'wsl', wslDistro: ctx.wslDistro ?? null } @@ -47,10 +47,10 @@ export function configureLocalPtyProvider(args: { codexSelectionTarget, ctx?.codexHomePathOverride ? ctx.codexHomePathOverride.value - : (getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { + : ((await getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { workspacePath: ctx?.cwd, launchAgent: ctx?.launchAgent - }) ?? null) + })) ?? null) ) const skipCodexHomeEnv = ctx?.isWsl === true && !selectedCodexHomePath const ptySettings = getSettings?.() diff --git a/src/main/ipc/pty/runtime/controller-deps.ts b/src/main/ipc/pty/runtime/controller-deps.ts index 4abebc3b28c..da6c45ce5c5 100644 --- a/src/main/ipc/pty/runtime/controller-deps.ts +++ b/src/main/ipc/pty/runtime/controller-deps.ts @@ -40,8 +40,8 @@ export type PtyRuntimeControllerDeps = { noCodexResumeLaunch: (command: string | undefined) => CodexResumeLaunch reconcileSharedRuntimeResumeHome: ( resumeHome: Extract, - resolveCurrent: () => string | null - ) => string + resolveCurrent: () => string | null | Promise + ) => Promise stripSequencedStartupResumeArgv: | undefined>( env: T, launch: CodexResumeLaunch diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 45c3ea1172f..f1ce7df7633 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -171,24 +171,23 @@ export async function prepareRuntimePtySpawn( if (args.preAllocatedHandle) { ctx.env = { ...ctx.env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle } } + const selectLaunchCodexHome = async (): Promise => + (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, { + workspacePath: ctx.cwd, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined + })) ?? null ctx.selectedCodexHomePath = !ctx.preAdoptedStablePane && !args.connectionId ? getCompatibleSelectedCodexHomePath( ctx.codexSelectionTarget, codexResumeHome - ? ctx.deps.reconcileSharedRuntimeResumeHome(codexResumeHome, () => + ? await ctx.deps.reconcileSharedRuntimeResumeHome(codexResumeHome, async () => getCompatibleSelectedCodexHomePath( ctx.codexSelectionTarget, - ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, { - workspacePath: ctx.cwd, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined - }) ?? null + await selectLaunchCodexHome() ) ) - : (ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, { - workspacePath: ctx.cwd, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined - }) ?? null) + : await selectLaunchCodexHome() ) : null if ( @@ -201,22 +200,22 @@ export async function prepareRuntimePtySpawn( getSettings: () => ctx.deps.getSettings?.(), requiredCodexHomePath: codexResumeHome?.codexHomePath, target: ctx.codexSelectionTarget, - resolveCurrent: () => + resolveCurrent: async () => getCompatibleSelectedCodexHomePath( ctx.codexSelectionTarget, - ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, { + (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, { workspacePath: ctx.cwd, launchAgent: 'codex' - }) ?? null + })) ?? null ), - resolveAfterUnavailable: (unavailableManagedHomePath) => + resolveAfterUnavailable: async (unavailableManagedHomePath) => getCompatibleSelectedCodexHomePath( ctx.codexSelectionTarget, - ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, { + (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, { workspacePath: ctx.cwd, launchAgent: 'codex', unavailableManagedHomePath - }) ?? null + })) ?? null ) }) ctx.selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution diff --git a/src/main/providers/local-pty-provider-spawn-session.test.ts b/src/main/providers/local-pty-provider-spawn-session.test.ts index b5ec1706225..c4ef90816ed 100644 --- a/src/main/providers/local-pty-provider-spawn-session.test.ts +++ b/src/main/providers/local-pty-provider-spawn-session.test.ts @@ -317,6 +317,30 @@ describe('LocalPtyProvider', () => { expect(spawnMock).toHaveBeenCalledOnce() }) + // Why (#16441): the Codex hook install and trust grant moved into + // buildSpawnEnv, so the env build is now the long await before node-pty + // exists — shutdown must be able to cancel the session id during it. + it('does not spawn after shutdown cancels a pending spawn during the env build', async () => { + spawnMock.mockClear() + let finishEnvBuild!: (env: Record) => void + const buildSpawnEnv = vi.fn( + (_id: string, baseEnv: Record) => + new Promise>((resolve) => { + finishEnvBuild = () => resolve(baseEnv) + }) + ) + const envProvider = new LocalPtyProvider({ buildSpawnEnv }) + + const spawn = envProvider.spawn({ cols: 80, rows: 24, sessionId: 'env-build-session' }) + const canceledSpawn = expect(spawn).rejects.toThrow('PTY spawn canceled: env-build-session') + await vi.waitFor(() => expect(buildSpawnEnv).toHaveBeenCalledOnce()) + + await envProvider.shutdown('env-build-session', { immediate: true }) + finishEnvBuild({}) + await canceledSpawn + expect(spawnMock).not.toHaveBeenCalled() + }) + it('coalesces a concurrent same-session-id spawn before launching a redundant shell (F3)', async () => { spawnMock.mockClear() const procA = { ...mockProc, pid: 1001 } diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 4cdab4df4af..d8ddc7cd902 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -373,17 +373,19 @@ function allocatePtyId(sessionId: string | undefined): string { return id } -async function prepareLocalPtySpawn(id: string): Promise { +/** Awaits pre-launch work that shutdown must be able to cancel: no node-pty + * process exists yet, so cancellation can only be observed after the await. */ +async function awaitCancelableLocalPtySpawn(id: string, operation: T | Promise): Promise { const pendingSpawn: PendingLocalPtySpawn = { canceled: false } const pending = pendingLocalPtySpawns.get(id) ?? new Set() pending.add(pendingSpawn) pendingLocalPtySpawns.set(id, pending) try { - // Why: shutdown must be able to cancel a stable session id during the async macOS capability probe, before node-pty exists. - await prepareMacosTccLoginShell() + const result = await operation if (pendingSpawn.canceled) { throw new Error(`PTY spawn canceled: ${id}`) } + return result } finally { pending.delete(pendingSpawn) if (pending.size === 0) { @@ -534,7 +536,10 @@ export type LocalPtyProviderOptions = { isWsl?: boolean wslDistro?: string | null } - ) => Record + // Why (#16441): Codex launch prep grants hook trust through a codex + // app-server session. `spawn` already awaits, so returning a promise keeps + // the Electron main thread responsive instead of blocking on spawnSync. + ) => Record | Promise> /** Whether worktree-scoped shell history is enabled; when true (or absent) with a worktreeId, HISTFILE is scoped per-worktree. */ isHistoryEnabled?: () => boolean /** Why: COMSPEC is always cmd.exe, so this callback injects the user's persisted shell preference. Undefined when none set. */ @@ -743,16 +748,21 @@ export class LocalPtyProvider implements IPtyProvider { const isWslShell = Boolean(wslInfo) || pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe' const launchWslDistro = isWslShell ? (launchWslContext?.distro ?? null) : null + // Why (#16441): building the env now awaits Codex hook installs and trust + // grants, so shutdown must be able to cancel this session id here too. const finalEnv = this.opts.buildSpawnEnv - ? this.opts.buildSpawnEnv(id, spawnEnv, { - command: args.command, - launchAgent: args.launchAgent, - codexHomePathOverride: args.codexHomePathOverride, - cwd, - shellPath, - isWsl: isWslShell, - wslDistro: launchWslDistro - }) + ? await awaitCancelableLocalPtySpawn( + id, + this.opts.buildSpawnEnv(id, spawnEnv, { + command: args.command, + launchAgent: args.launchAgent, + codexHomePathOverride: args.codexHomePathOverride, + cwd, + shellPath, + isWsl: isWslShell, + wslDistro: launchWslDistro + }) + ) : spawnEnv // Why: app-level env hooks can re-add scrubbed vars; delete last so shims like Claude Agent Teams keep their PATH. for (const key of args.envToDelete ?? []) { @@ -914,7 +924,8 @@ export class LocalPtyProvider implements IPtyProvider { primaryLaunchEnvKeys = Object.keys(shellLaunch.env) } - await prepareLocalPtySpawn(id) + // Why: the async macOS capability probe runs before node-pty exists. + await awaitCancelableLocalPtySpawn(id, prepareMacosTccLoginShell()) if (args.signal?.aborted) { throw new Error('client_disconnected') } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 54c6b711173..32373ee98cc 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -24483,7 +24483,20 @@ export class OrcaRuntimeService { } } - private markLocalWorkspaceTrustedForAgent(agent: TuiAgent, workspacePath: string): void { + private markWorkspaceTrustedForAgent( + agent: TuiAgent, + connectionId: string | null | undefined, + workspacePath: string + ): Promise { + return connectionId + ? this.markRemoteWorkspaceTrustedForAgent(agent, connectionId, workspacePath) + : this.markLocalWorkspaceTrustedForAgent(agent, workspacePath) + } + + private async markLocalWorkspaceTrustedForAgent( + agent: TuiAgent, + workspacePath: string + ): Promise { const preset = TUI_AGENT_CONFIG[agent].preflightTrust if (!preset) { return @@ -24494,7 +24507,9 @@ export class OrcaRuntimeService { } else if (preset === 'copilot') { markCopilotFolderTrusted(workspacePath) } else if (preset === 'codex') { - markCodexProjectTrusted(workspacePath) + // Why: the Codex write queues behind any in-flight hook grant, so the + // agent must not launch until it has actually landed. + await markCodexProjectTrusted(workspacePath) } } catch { // Best-effort: the user can still accept the agent trust prompt manually. @@ -25019,7 +25034,7 @@ export class OrcaRuntimeService { try { const startupTrustAgent = effectiveDraftPaste?.agent ?? effectiveCreatedWithAgent if (startupTrustAgent) { - this.markLocalWorkspaceTrustedForAgent(startupTrustAgent, worktree.path) + await this.markLocalWorkspaceTrustedForAgent(startupTrustAgent, worktree.path) } const terminal = await this.createTerminal(`id:${worktree.id}`, { command: effectiveStartup.command, @@ -25812,7 +25827,7 @@ export class OrcaRuntimeService { // session later, matching `orca terminal create` background semantics. const startupTrustAgent = effectiveDraftPaste?.agent ?? effectiveCreatedWithAgent if (startupTrustAgent) { - this.markLocalWorkspaceTrustedForAgent(startupTrustAgent, worktreePath) + await this.markLocalWorkspaceTrustedForAgent(startupTrustAgent, worktreePath) } const terminal = await this.createTerminal(`id:${worktree.id}`, { command: sequencedStartup.command, @@ -28298,11 +28313,7 @@ export class OrcaRuntimeService { return opts } - if (workspace.connectionId) { - await this.markRemoteWorkspaceTrustedForAgent(agent, workspace.connectionId, workspace.path) - } else { - this.markLocalWorkspaceTrustedForAgent(agent, workspace.path) - } + await this.markWorkspaceTrustedForAgent(agent, workspace.connectionId, workspace.path) return { ...opts, @@ -28436,15 +28447,7 @@ export class OrcaRuntimeService { if (!startup) { throw new Error('agent_session_identity_required') } - if (workspace.connectionId) { - await this.markRemoteWorkspaceTrustedForAgent( - request.agent, - workspace.connectionId, - workspace.path - ) - } else { - this.markLocalWorkspaceTrustedForAgent(request.agent, workspace.path) - } + await this.markWorkspaceTrustedForAgent(request.agent, workspace.connectionId, workspace.path) if (_caller.signal?.aborted) { throw new Error('client_disconnected') } @@ -28605,15 +28608,7 @@ export class OrcaRuntimeService { if (!startup) { throw new Error('agent_session_identity_required') } - if (workspace.connectionId) { - await this.markRemoteWorkspaceTrustedForAgent( - request.agent, - workspace.connectionId, - workspace.path - ) - } else { - this.markLocalWorkspaceTrustedForAgent(request.agent, workspace.path) - } + await this.markWorkspaceTrustedForAgent(request.agent, workspace.connectionId, workspace.path) if (caller.signal?.aborted) { throw new Error('client_disconnected') } @@ -29245,11 +29240,7 @@ export class OrcaRuntimeService { throw new Error('Repository for the selected workspace is no longer available.') } const startup = this.buildStartupForAgent(repo, opts.agent, opts.prompt) - if (repo.connectionId) { - await this.markRemoteWorkspaceTrustedForAgent(opts.agent, repo.connectionId, worktree.path) - } else { - this.markLocalWorkspaceTrustedForAgent(opts.agent, worktree.path) - } + await this.markWorkspaceTrustedForAgent(opts.agent, repo.connectionId, worktree.path) return await this.createTerminal(`id:${worktree.id}`, { command: startup.startup.command, env: startup.startup.env, @@ -29638,15 +29629,7 @@ export class OrcaRuntimeService { if (opts.agentPrompt && startupPlan.followupPrompt) { throw new Error(`Agent ${opts.agent} does not support startup prompt quick commands.`) } - if (workspace.connectionId) { - await this.markRemoteWorkspaceTrustedForAgent( - opts.agent, - workspace.connectionId, - workspace.path - ) - } else { - this.markLocalWorkspaceTrustedForAgent(opts.agent, workspace.path) - } + await this.markWorkspaceTrustedForAgent(opts.agent, workspace.connectionId, workspace.path) return { command: startupPlan.launchCommand, env: startupPlan.env, diff --git a/src/main/text-generation/commit-message-agent-environment.ts b/src/main/text-generation/commit-message-agent-environment.ts index de83bcc3559..3c5523bc420 100644 --- a/src/main/text-generation/commit-message-agent-environment.ts +++ b/src/main/text-generation/commit-message-agent-environment.ts @@ -4,7 +4,9 @@ import { readShellStartupEnvVar } from '../pty/shell-startup-env' import { parseWslUncPath } from '../../shared/wsl-paths' export type CommitMessageAgentEnvironmentResolvers = { - prepareForCodexLaunch?: (target?: CommitMessageAgentRuntimeTarget) => string | null + prepareForCodexLaunch?: ( + target?: CommitMessageAgentRuntimeTarget + ) => string | null | Promise prepareForClaudeLaunch?: ( target?: CommitMessageAgentRuntimeTarget ) => Promise @@ -100,7 +102,7 @@ export async function prepareLocalCommitMessageAgentEnv( try { if (agentId === 'codex' && resolvers.prepareForCodexLaunch) { - const codexHomePath = resolvers.prepareForCodexLaunch(target) + const codexHomePath = await resolvers.prepareForCodexLaunch(target) const wslCodexHome = codexHomePath ? parseWslUncPath(codexHomePath) : null if (target?.runtime === 'wsl') { const codexHomeForTarget = wslCodexHome?.linuxPath ?? null diff --git a/src/shared/capability-probe-cache.ts b/src/shared/capability-probe-cache.ts new file mode 100644 index 00000000000..0a571dcf4da --- /dev/null +++ b/src/shared/capability-probe-cache.ts @@ -0,0 +1,128 @@ +/** + * Optimistic capability probing with a bounded retry window and in-flight + * probe dedupe. + * + * Extracted from GitCapabilityCache so every host-capability cache in the tree + * gets the same three behaviors: probe once, remember only a positive absence + * signal, and let a concurrent caller wait on the probe already running rather + * than starting a duplicate one. + */ +export type CapabilityProbeOutcome = 'supported' | 'unsupported' | 'unknown' + +export class CapabilityProbeCache { + private readonly retryAfterByCapability = new Map() + private readonly probesByCapability = new Map>() + private readonly supportedCapabilities = new Set() + + constructor(private readonly retryIntervalMs: number) {} + + shouldTry(capability: TCapability, nowMs = Date.now()): boolean { + const retryAfterMs = this.retryAfterByCapability.get(capability) + if (retryAfterMs === undefined) { + return true + } + if (nowMs < retryAfterMs) { + return false + } + this.retryAfterByCapability.delete(capability) + return true + } + + isKnownSupported(capability: TCapability): boolean { + return this.supportedCapabilities.has(capability) + } + + rememberSupported(capability: TCapability): void { + this.retryAfterByCapability.delete(capability) + this.supportedCapabilities.add(capability) + } + + rememberUnsupported(capability: TCapability, nowMs = Date.now()): void { + // Why: optimistic probes preserve newer behavior, but repeating a known + // failure on every poll/search wastes subprocesses and trace space. + this.supportedCapabilities.delete(capability) + this.retryAfterByCapability.set(capability, nowMs + this.retryIntervalMs) + } + + async runWithFallback( + capability: TCapability, + runPreferred: () => Promise, + runFallback: () => Promise, + isUnsupportedError: (error: unknown) => boolean + ): Promise { + if (this.supportedCapabilities.has(capability)) { + // Why: supported commands are real work, not disposable probes. Let + // sibling repo/SSH calls retain their intended concurrency. + return this.runPreferredOrFallback(capability, runPreferred, runFallback, isUnsupportedError) + } + if (!this.shouldTry(capability)) { + return runFallback() + } + + const inFlightProbe = this.probesByCapability.get(capability) + if (inFlightProbe) { + const outcome = await inFlightProbe + if (outcome === 'unsupported' || !this.shouldTry(capability)) { + return runFallback() + } + return this.runPreferredOrFallback(capability, runPreferred, runFallback, isUnsupportedError) + } + + let settleProbe!: (outcome: CapabilityProbeOutcome) => void + const probe = new Promise((resolve) => { + settleProbe = resolve + }) + this.probesByCapability.set(capability, probe) + try { + return await this.runPreferredOrFallback( + capability, + runPreferred, + runFallback, + isUnsupportedError, + settleProbe + ) + } finally { + if (this.probesByCapability.get(capability) === probe) { + this.probesByCapability.delete(capability) + } + // Backstop: `isUnsupportedError` or `rememberUnsupported` can throw + // before the settle below them runs; waiters must not hang behind it. + settleProbe('unknown') + } + } + + clear(): void { + this.retryAfterByCapability.clear() + this.probesByCapability.clear() + this.supportedCapabilities.clear() + } + + private async runPreferredOrFallback( + capability: TCapability, + runPreferred: () => Promise, + runFallback: () => Promise, + isUnsupportedError: (error: unknown) => boolean, + settleProbe?: (outcome: CapabilityProbeOutcome) => void + ): Promise { + try { + const result = await runPreferred() + // A preferred callback can detect a weaker positive signal (old Git's + // exit-zero option echo) and remember it as unsupported, so do not + // overwrite that stronger signal. + const outcome = this.retryAfterByCapability.has(capability) ? 'unsupported' : 'supported' + if (outcome === 'supported') { + this.supportedCapabilities.add(capability) + } + settleProbe?.(outcome) + return result + } catch (error) { + if (!isUnsupportedError(error)) { + settleProbe?.('unknown') + throw error + } + this.rememberUnsupported(capability) + settleProbe?.('unsupported') + return runFallback() + } + } +} diff --git a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt index fcf8258d6b3..03e06eb374f 100644 --- a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt +++ b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt @@ -44,10 +44,8 @@ src/main/claude-accounts/keychain.ts src/main/codex-accounts/runtime-home-service.ts src/main/codex-accounts/service.ts src/main/codex/codex-app-server-client.ts -src/main/codex/codex-app-server-grant-bridge.ts src/main/codex/codex-app-server-session.ts src/main/codex/codex-state-db-backfill-recovery.ts -src/main/codex/codex-trust-grant-host.ts src/main/codex/codex-wsl-hook-install-plan.ts src/main/computer/desktop-script-provider-bridge.ts src/main/computer/macos-computer-use-permission-status.ts diff --git a/src/shared/git-capability-cache.ts b/src/shared/git-capability-cache.ts index 9e3d4ce89e0..06d0e957c74 100644 --- a/src/shared/git-capability-cache.ts +++ b/src/shared/git-capability-cache.ts @@ -1,3 +1,5 @@ +import { CapabilityProbeCache } from './capability-probe-cache' + // Why: suppress hot-loop failures while still detecting an in-place Git // upgrade during a long Orca session without requiring a restart. export const GIT_CAPABILITY_RETRY_INTERVAL_MS = 30 * 60_000 @@ -10,107 +12,8 @@ export type GitCapability = | 'rev-parse-path-format' | 'worktree-list-z' -type GitCapabilityProbeOutcome = 'supported' | 'unsupported' | 'unknown' - -export class GitCapabilityCache { - private readonly retryAfterByCapability = new Map() - private readonly probesByCapability = new Map>() - private readonly supportedCapabilities = new Set() - - shouldTry(capability: GitCapability, nowMs = Date.now()): boolean { - const retryAfterMs = this.retryAfterByCapability.get(capability) - if (retryAfterMs === undefined) { - return true - } - if (nowMs < retryAfterMs) { - return false - } - this.retryAfterByCapability.delete(capability) - return true - } - - rememberUnsupported(capability: GitCapability, nowMs = Date.now()): void { - // Why: optimistic probes preserve newer Git behavior, but repeating a - // known failure on every poll/search wastes subprocesses and trace space. - this.supportedCapabilities.delete(capability) - this.retryAfterByCapability.set(capability, nowMs + GIT_CAPABILITY_RETRY_INTERVAL_MS) - } - - async runWithFallback( - capability: GitCapability, - runPreferred: () => Promise, - runFallback: () => Promise, - isUnsupportedError: (error: unknown) => boolean - ): Promise { - if (this.supportedCapabilities.has(capability)) { - // Why: supported commands are real work, not disposable probes. Let - // sibling repo/SSH calls retain their intended concurrency. - return this.runPreferredOrFallback(capability, runPreferred, runFallback, isUnsupportedError) - } - if (!this.shouldTry(capability)) { - return runFallback() - } - - const inFlightProbe = this.probesByCapability.get(capability) - if (inFlightProbe) { - const outcome = await inFlightProbe - if (outcome === 'unsupported' || !this.shouldTry(capability)) { - return runFallback() - } - return this.runPreferredOrFallback(capability, runPreferred, runFallback, isUnsupportedError) - } - - let settleProbe!: (outcome: GitCapabilityProbeOutcome) => void - const probe = new Promise((resolve) => { - settleProbe = resolve - }) - this.probesByCapability.set(capability, probe) - try { - return await this.runPreferredOrFallback( - capability, - runPreferred, - runFallback, - isUnsupportedError, - settleProbe - ) - } finally { - if (this.probesByCapability.get(capability) === probe) { - this.probesByCapability.delete(capability) - } - } - } - - clear(): void { - this.retryAfterByCapability.clear() - this.probesByCapability.clear() - this.supportedCapabilities.clear() - } - - private async runPreferredOrFallback( - capability: GitCapability, - runPreferred: () => Promise, - runFallback: () => Promise, - isUnsupportedError: (error: unknown) => boolean, - settleProbe?: (outcome: GitCapabilityProbeOutcome) => void - ): Promise { - try { - const result = await runPreferred() - // A preferred callback can detect old Git's exit-zero option echo and - // remember it as unsupported, so do not overwrite that stronger signal. - const outcome = this.retryAfterByCapability.has(capability) ? 'unsupported' : 'supported' - if (outcome === 'supported') { - this.supportedCapabilities.add(capability) - } - settleProbe?.(outcome) - return result - } catch (error) { - if (!isUnsupportedError(error)) { - settleProbe?.('unknown') - throw error - } - this.rememberUnsupported(capability) - settleProbe?.('unsupported') - return runFallback() - } +export class GitCapabilityCache extends CapabilityProbeCache { + constructor() { + super(GIT_CAPABILITY_RETRY_INTERVAL_MS) } } From 88ecc739e3ee7a261e978f28dd0bb129b832ca1c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:49:31 -0700 Subject: [PATCH 031/152] fix(browser): bound the agent-browser daemon's life instead of hoping teardown runs (#16367) (#16588) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(browser): bound the agent-browser daemon lifetime (#16367) `agent-browser` is a client/daemon CLI. Orca only ever spawns the short-lived client; that client forks a daemon Orca holds no handle on, which reparents to pid 1 immediately. Nothing in Orca reclaimed it, so a crashed or SIGKILL'd run left one daemon per browser tab alive forever — two of them at ~25.6 GiB and ~7.2 GiB RSS saturated a 64 GiB cgroup under headless `orca serve`. Three fixes, in order of how much they cover: 1. Set `AGENT_BROWSER_IDLE_TIMEOUT_MS` on both spawn paths (the bundled-binary bridge and the orcad external-Chromium provider). This is the only bound that survives every way Orca can die, including SIGKILL, where no teardown code ever runs. 10 minutes: >6x the bridge's 90s `EXEC_TIMEOUT_MS`, so it can never cut a command, a retry chain, or an ordinary gap between two user commands, while capping an abandoned daemon at minutes instead of days. Verified against agent-browser 0.27.0: an idle daemon exits and takes its Chromium tree and socket sidecar files with it, and a daemon attached over `--cdp` (the bridge's case) leaves the attached browser running, so an Orca tab is never closed by its daemon idling out. 2. Await `destroyAllSessions()` in the will-quit teardown barrier. It was fire-and-forget and the only browser member missing from `settleTeardownWithinDeadline`; each session's close is its own agent-browser child taking hundreds of ms, so `app.quit()` won. 3. Sweep daemons a previous run left behind, using agent-browser's own `session list` / `close` rather than a pid walk (see `windows-pty-job.ts` for why walking your own orphans is guesswork). `closeStaleAgentBrowserSession` only ever reset the one name a new tab was about to reuse. The sweep runs only when `AGENT_BROWSER_SOCKET_DIR` is set, because that private per-profile directory is what proves the enumeration can only see this Orca profile's daemons; it is never set on Windows, so Windows gets no enumeration rather than a machine-wide sweep that could close a daemon Orca does not own. Windows stays bounded by the idle timeout, which needs no ownership proof. orcad's session name is stable across runs, so it closes that one name at start instead — a killed orcad's daemon would otherwise be reused while still holding the previous run's Chromium on a dead serve port. Where the 25 GiB went is inference from code, not a measurement: `captureStart` sets `activeCapture` and only an explicit `captureStop` ends it, so a HAR capture in a daemon living for days is unbounded. Not claimed as proven; the idle bound caps it either way. Not re-landed: the queue bounds from #10179 (reverted by #10255) bound Orca's own main-process heap, not the daemon's RSS, so they do not address this report. Also true but left alone: the 3-strike breaker's `destroySession` is an unawaited call whose `close` is `catch {}`-swallowed, and it only fires while a command is in flight — an idle-but-bloated daemon is never noticed. The idle timeout now bounds that case. `getOffscreenBrowserBackend()?.destroyAll?.()` is declared `void` and fully synchronous, so unlike `destroyAllSessions` it has no promise to lose and needs no barrier entry. * fix(browser): scope the daemon idle bound and close every daemon Orca owns Review follow-ups on the agent-browser orphan fix. - Never idle-bound the orcad external-Chromium daemon: it owns the user's remote browser, so the 10-minute bound closed a live session and every tab in it. Per-tab helper daemons keep the bound; the stable session name plus the `close` in start() is what reclaims a killed orcad's Chromium tree. - Retire a page's daemon from the headless offscreen backend, which is the only place `orca serve` closes a page and never reached the bridge. Credit to @Jinwoo-H (#16564) for identifying this owner-boundary gap. - Bound the teardown close at 5s so the will-quit barrier member cannot inherit the 90s exec timeout, and close sessions still being created. - Gate the startup sweep on a socket directory Orca derived itself; an inherited AGENT_BROWSER_SOCKET_DIR is no proof of per-profile ownership. - Replay a session's network routes when the daemon idled out between two commands, instead of silently serving unstubbed requests. * fix(browser): give the orphan sweep a kill switch Of the three behaviours this PR adds, two are already recoverable in the field without a build: the idle bound is an env passthrough an operator can raise, and the quit close is bounded by its own timeout inside the teardown deadline. The startup sweep was the exception — it fires unconditionally, and if it closes a daemon it should not, or spawns one process per stale name on a profile holding hundreds, the only remedy was a revert. ORCA_DISABLE_AGENT_BROWSER_SWEEP=1 turns it off, matching the existing ORCA_DISABLE_CODEX_TRUST_RPC / ORCA_DISABLE_HTTP2 convention. Note for anyone reaching for it on macOS: a Finder-launched Orca does not see shell env, so it needs launchctl setenv or a terminal launch. * fix(orcad): reuse a surviving browser session instead of closing the user's start() closed the daemon before every open, killing the Chromium tree with it. That runs on every provider start, not just after a crash — so an `orca serve` restart took the remote user's browser and every tab in it. The justification was borrowed from the pane bridge, which passes --cdp and so really does hold a port that dies with its Orca. This session passes only --session and --profile: nothing binds it to the old process, and the daemon owns its Chromium independently. A survivor is reusable as-is. start() now probes for an active tab first and returns it untouched. Only a name that answers nothing gets closed and reopened — which is still the killed-orcad case the stable session name exists to recover. This matters more as orcad becomes the backend the remote host runs on: the browser it manages belongs to a user, not to the process that happens to be driving it this minute. It is also the same principle that already exempts this path from AGENT_BROWSER_IDLE_TIMEOUT_MS. --- ...t-browser-bridge-command-transport.test.ts | 6 +- ...t-browser-bridge-session-lifecycle.test.ts | 95 +++++++++ src/main/browser/agent-browser-bridge.ts | 81 +++++++- .../agent-browser-orphan-sweep.test.ts | 190 ++++++++++++++++++ .../browser/agent-browser-orphan-sweep.ts | 91 +++++++++ .../agent-browser-process-environment.test.ts | 80 ++++++-- .../agent-browser-process-environment.ts | 43 +++- ...ffscreen-browser-backend-lifecycle.test.ts | 15 ++ src/main/index.ts | 7 +- .../external-chromium-browser-session.test.ts | 130 ++++++++++++ .../external-chromium-browser-session.ts | 59 +++++- ...uit-teardown-agent-browser-daemons.test.ts | 33 +++ 12 files changed, 786 insertions(+), 44 deletions(-) create mode 100644 src/main/browser/agent-browser-orphan-sweep.test.ts create mode 100644 src/main/browser/agent-browser-orphan-sweep.ts create mode 100644 src/main/orcad/external-chromium-browser-session.test.ts create mode 100644 src/main/quit-teardown-agent-browser-daemons.test.ts diff --git a/src/main/browser/agent-browser-bridge-command-transport.test.ts b/src/main/browser/agent-browser-bridge-command-transport.test.ts index e8c5b786a9b..733cd966ce7 100644 --- a/src/main/browser/agent-browser-bridge-command-transport.test.ts +++ b/src/main/browser/agent-browser-bridge-command-transport.test.ts @@ -51,6 +51,7 @@ vi.mock('./cdp-bridge', () => ({ })) import { AgentBrowserBridge } from './agent-browser-bridge' +import { AGENT_BROWSER_IDLE_TIMEOUT_MS } from './agent-browser-process-environment' import { createSucceedWith, mockBrowserManager, @@ -338,7 +339,10 @@ describe('AgentBrowserBridge', () => { expect(args).toContain('wait') expect(args).toContain('#ready') expect(options.timeout).toBe(2200) - expect(options.env).toBe(process.env) + // Why not toBe(process.env): the bridge hands the daemon an idle-lifetime bound (#16367). + const env = options.env as NodeJS.ProcessEnv + expect(env.PATH).toBe(process.env.PATH) + expect(env.AGENT_BROWSER_IDLE_TIMEOUT_MS).toBe(String(AGENT_BROWSER_IDLE_TIMEOUT_MS)) }) it('returns browser_timeout for timed conditional waits without recycling the session', async () => { diff --git a/src/main/browser/agent-browser-bridge-session-lifecycle.test.ts b/src/main/browser/agent-browser-bridge-session-lifecycle.test.ts index 4ef3d24fa25..5eab202541c 100644 --- a/src/main/browser/agent-browser-bridge-session-lifecycle.test.ts +++ b/src/main/browser/agent-browser-bridge-session-lifecycle.test.ts @@ -64,6 +64,12 @@ overrideBridgeWebContentsLookup(AgentBrowserBridge.prototype, webContentsFromIdM const succeedWith = createSucceedWith(execFileMock, stdinWrites) +function closeCallCount(): number { + return execFileMock.mock.calls.filter((call: unknown[]) => + (call[1] as string[]).includes('close') + ).length +} + describe('AgentBrowserBridge', () => { let bridge: AgentBrowserBridge @@ -453,6 +459,52 @@ describe('AgentBrowserBridge', () => { ).toBe(0) }) + // Why: the daemon's own idle timer retires it between commands; a replacement still serves the + // page but has none of the session's network routes, so leaving them dropped is a silent wrong + // answer for the next request the caller expected to be stubbed (#16367). + it('replays intercept routes after the daemon idles out', async () => { + succeedWith({ ok: true }) + await bridge.interceptEnable(['https://api.example/**']) + + const sessions = (bridge as unknown as { sessions: Map }) + .sessions + const session = sessions.get('orca-tab-tab-1')! + session.lastCommandAt = Date.now() - 11 * 60 * 1000 + + const commandCalls: string[][] = [] + execFileMock.mockImplementation( + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { + commandCalls.push(args) + cb(null, JSON.stringify({ success: true, data: { snapshot: 'tree' } }), '') + } + ) + await bridge.snapshot() + + const routeCalls = commandCalls.filter( + (args) => args.includes('network') && args.includes('route') + ) + expect(routeCalls).toHaveLength(1) + expect(routeCalls[0]).toContain('https://api.example/**') + }) + + it('leaves a session alone while the daemon is still within its idle bound', async () => { + succeedWith({ ok: true }) + await bridge.interceptEnable(['https://api.example/**']) + + const commandCalls: string[][] = [] + execFileMock.mockImplementation( + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { + commandCalls.push(args) + cb(null, JSON.stringify({ success: true, data: { snapshot: 'tree' } }), '') + } + ) + await bridge.snapshot() + + expect( + commandCalls.filter((args) => args.includes('network') && args.includes('route')) + ).toHaveLength(0) + }) + // ── destroyAllSessions ── it('makes runtime-wide session destruction terminal', async () => { @@ -591,4 +643,47 @@ describe('AgentBrowserBridge', () => { expect(CdpWsProxyMock.instances).toHaveLength(1) expect(execFileMock).toHaveBeenCalledTimes(1) }) + + // Why: quit awaits destroyAllSessions inside a 20s barrier, so an unbounded close can hold the + // window up for the whole deadline when the daemon is wedged (#16367). + it('bounds every teardown close well inside the quit barrier', async () => { + succeedWith({ snapshot: 'tree' }) + await bridge.snapshot() + + succeedWith(null) + await bridge.destroyAllSessions() + + const closeCall = execFileMock.mock.calls.findLast((c: unknown[]) => + (c[1] as string[]).includes('close') + ) + expect((closeCall![2] as { timeout: number }).timeout).toBeLessThanOrEqual(5_000) + }) + + // Why: the daemon is already spawned by the time the name reaches pendingSessionCreation, so a + // quit that only walks `sessions` leaves exactly the orphan the barrier was added to prevent. + it('closes a session still being created when everything is torn down', async () => { + let releaseProxyStart: (() => void) | undefined + CdpWsProxyMock.mockImplementationOnce(function (this: Record) { + this.start = vi.fn( + () => + new Promise((resolve) => { + releaseProxyStart = () => resolve('ws://127.0.0.1:9222') + }) + ) + this.stop = vi.fn(async () => {}) + this.getPort = vi.fn(() => 9222) + }) + + succeedWith({ snapshot: 'tree' }) + const inFlight = bridge.snapshot() + await vi.waitFor(() => expect(releaseProxyStart).toBeDefined()) + + // Why the baseline: session creation already spawned a stale-session `close` of its own. + const closesBeforeTeardown = closeCallCount() + const teardown = bridge.destroyAllSessions() + releaseProxyStart!() + await Promise.allSettled([inFlight, teardown]) + + expect(closeCallCount()).toBeGreaterThan(closesBeforeTeardown) + }) }) diff --git a/src/main/browser/agent-browser-bridge.ts b/src/main/browser/agent-browser-bridge.ts index 10a64dc16ce..54611bffa8f 100644 --- a/src/main/browser/agent-browser-bridge.ts +++ b/src/main/browser/agent-browser-bridge.ts @@ -52,12 +52,17 @@ import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency' import { iterateBrowserTextInsertionChunks } from './browser-text-insertion' import { createAgentBrowserProcessEnvironment } from './agent-browser-process-environment' +import { + ORCA_TAB_SESSION_PREFIX, + sweepOrphanedAgentBrowserSessions +} from './agent-browser-orphan-sweep' // Why: must exceed agent-browser's internal timeouts (goto 30s, wait 60s) so the bridge never kills a command before its own timeout fires. const EXEC_TIMEOUT_MS = 90_000 const CONSECUTIVE_TIMEOUT_LIMIT = 3 const WAIT_PROCESS_TIMEOUT_GRACE_MS = 1_000 const STALE_SESSION_CLOSE_TIMEOUT_MS = 3_000 +// Why separate from EXEC_TIMEOUT_MS: a close is a member of the 20s will-quit barrier and must finish well inside it. const AGENT_BROWSER_CLEANUP_TIMEOUT_MS = 5_000 const AGENT_BROWSER_CLEANUP_CONCURRENCY = 4 const EMBEDDED_NAVIGATION_TIMEOUT_MS = 30_000 @@ -72,6 +77,8 @@ type SessionState = { // Why: track active interception patterns so they can be re-enabled after session restart activeInterceptPatterns: string[] activeCapture: boolean + // Why: the daemon retires itself once idle; the gap since the last command is how the bridge notices. + lastCommandAt: number // Why: verify the tab is alive at execution time, not just enqueue time — queue delay can destroy it in between. webContentsId: number activeProcess: ChildProcess | null @@ -348,7 +355,7 @@ function isTabClosedTransportError(message: string): boolean { } function pageUnavailableMessageForSession(sessionName: string): string { - const prefix = 'orca-tab-' + const prefix = ORCA_TAB_SESSION_PREFIX const browserPageId = sessionName.startsWith(prefix) ? sessionName.slice(prefix.length) : null return browserPageId ? `Browser page ${browserPageId} is no longer available` @@ -587,6 +594,9 @@ export class AgentBrowserBridge { private screenshotTurn: Promise = Promise.resolve() private readonly agentBrowserBin: string private readonly agentBrowserEnv: NodeJS.ProcessEnv + private readonly ownsAgentBrowserSocketDirectory: boolean + // Why: null when nothing bounds the daemon, so the bridge never guesses that one was replaced. + private readonly agentBrowserIdleTimeoutMs: number | null // Why: stash intercept patterns from a swap-destroyed session, keyed by name, so the next session restores them. private readonly pendingInterceptRestore = new Map() // Why: promise-lock so two concurrent ensureSession calls don't both create the session entry. @@ -601,11 +611,15 @@ export class AgentBrowserBridge { private readonly options: AgentBrowserBridgeOptions = {} ) { this.agentBrowserBin = resolveAgentBrowserBinary() - this.agentBrowserEnv = createAgentBrowserProcessEnvironment({ + const processEnvironment = createAgentBrowserProcessEnvironment({ inheritedEnv: process.env, platform: process.platform, userDataPath: app.getPath('userData') }) + this.agentBrowserEnv = processEnvironment.env + this.ownsAgentBrowserSocketDirectory = processEnvironment.ownsSocketDirectory + const idleTimeoutMs = Number(this.agentBrowserEnv.AGENT_BROWSER_IDLE_TIMEOUT_MS) + this.agentBrowserIdleTimeoutMs = idleTimeoutMs > 0 ? idleTimeoutMs : null } // ── Tab tracking ── @@ -691,9 +705,15 @@ export class AgentBrowserBridge { this.options.onTabsChanged?.(owningWorktreeId) } - /** Retire a helper by its stable page identity when WebContents mapping is gone. */ + /** + * Retire a page's daemon by page id. + * + * The headless offscreen backend owns pages by id and unregisters the guest + * itself, so `onTabClosed`'s webContentsId lookup can never resolve one — it + * has to say which page closed (#16367). + */ async onPageClosed(browserPageId: string): Promise { - const sessionName = `orca-tab-${browserPageId}` + const sessionName = `${ORCA_TAB_SESSION_PREFIX}${browserPageId}` await this.destroySession(sessionName) this.pendingInterceptRestore.delete(sessionName) } @@ -704,7 +724,7 @@ export class AgentBrowserBridge { previousWebContentsId?: number ): Promise { // Why: an Electron process swap keeps browserPageId but gives a new webContentsId — destroy the session so the next command recreates it. - const sessionName = `orca-tab-${browserPageId}` + const sessionName = `${ORCA_TAB_SESSION_PREFIX}${browserPageId}` const session = this.sessions.get(sessionName) const oldWebContentsId = previousWebContentsId ?? session?.webContentsId const owningWorktreeId = this.browserManager.getWorktreeIdForTab(browserPageId) @@ -898,7 +918,9 @@ export class AgentBrowserBridge { navigationTimeout = null } if (!this.getWebContents(target.webContentsId)) { - throw this.createPageUnavailableError(`orca-tab-${target.browserPageId}`) + throw this.createPageUnavailableError( + `${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}` + ) } // Why: ERR_ABORTED also covers a page vetoing unload; that navigation did not succeed. if ( @@ -1619,7 +1641,9 @@ export class AgentBrowserBridge { throw error } if (!this.getWebContents(target.webContentsId)) { - throw this.createPageUnavailableError(`orca-tab-${target.browserPageId}`) + throw this.createPageUnavailableError( + `${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}` + ) } throw new BrowserError( 'browser_error', @@ -2057,8 +2081,22 @@ export class AgentBrowserBridge { // ── Session lifecycle ── + // Why: a previous run that crashed or was SIGKILL'd left one daemon per open tab with + // nobody holding its name — closeStaleAgentBrowserSession only resets a name being reused. + async sweepOrphanedSessions(): Promise { + return sweepOrphanedAgentBrowserSessions({ + binaryPath: this.agentBrowserBin, + env: this.agentBrowserEnv, + ownsSocketDirectory: this.ownsAgentBrowserSocketDirectory, + isSessionLive: (sessionName) => + this.sessions.has(sessionName) || this.pendingSessionCreation.has(sessionName) + }) + } + async destroyAllSessions(options?: AgentBrowserCleanupOptions): Promise { this.shutdownStarted = true + // Why the union: a session still being created has already spawned its daemon but is not in + // `sessions` yet, so closing only `sessions` lets that daemon outlive the quit (#16367). const sessionNames = new Set([ ...this.sessions.keys(), ...this.pendingSessionCreation.keys(), @@ -2094,7 +2132,7 @@ export class AgentBrowserBridge { ): Promise { this.assertCommandAdmission() const target = this.resolveCommandTarget(worktreeId, browserPageId, options.requireScopedTarget) - const sessionName = `orca-tab-${target.browserPageId}` + const sessionName = `${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}` if (options.ensureSession !== false) { await this.ensureSession(sessionName, target.browserPageId, target.webContentsId) @@ -2360,6 +2398,7 @@ export class AgentBrowserBridge { consecutiveTimeouts: 0, activeInterceptPatterns: [], activeCapture: false, + lastCommandAt: Date.now(), webContentsId, activeProcess: null }) @@ -2471,6 +2510,7 @@ export class AgentBrowserBridge { const destroy = (async (): Promise => { try { // Why: each tab has its own named session — close without --session leaves this tab's daemon running. + // Why bounded: this runs inside the 20s will-quit barrier, so it cannot inherit the 90s exec timeout. await this.runAgentBrowserRaw( sessionName, ['--session', sessionName, 'close'], @@ -2506,6 +2546,26 @@ export class AgentBrowserBridge { } } + /** + * Notice that the daemon retired itself between two commands. + * + * A replacement daemon still serves the page (every call reasserts `--cdp`) + * but carries none of the session's network routes, so without this the + * interception the caller configured is silently gone (#16367). + */ + private reinitializeIfDaemonIdledOut(sessionName: string, session: SessionState): void { + if ( + this.agentBrowserIdleTimeoutMs === null || + Date.now() - session.lastCommandAt < this.agentBrowserIdleTimeoutMs + ) { + return + } + session.initialized = false + if (session.activeInterceptPatterns.length > 0) { + this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns]) + } + } + private assertCommandAdmission(): void { if (this.shutdownStarted) { throw new BrowserError('browser_owner_unavailable', 'Browser runtime is shutting down') @@ -2529,6 +2589,9 @@ export class AgentBrowserBridge { throw this.createPageUnavailableError(sessionName) } + this.reinitializeIfDaemonIdledOut(sessionName, session) + session.lastCommandAt = Date.now() + const args = ['--session', sessionName] const managesInterceptRoutes = commandArgs[0] === 'network' && (commandArgs[1] === 'route' || commandArgs[1] === 'unroute') @@ -2810,7 +2873,7 @@ export class AgentBrowserBridge { private requireTargetWebContents(target: ResolvedBrowserCommandTarget): WebContents { const wc = this.getWebContents(target.webContentsId) if (!wc || wc.isDestroyed()) { - throw this.createPageUnavailableError(`orca-tab-${target.browserPageId}`) + throw this.createPageUnavailableError(`${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}`) } return wc } diff --git a/src/main/browser/agent-browser-orphan-sweep.test.ts b/src/main/browser/agent-browser-orphan-sweep.test.ts new file mode 100644 index 00000000000..ce565e2fa6e --- /dev/null +++ b/src/main/browser/agent-browser-orphan-sweep.test.ts @@ -0,0 +1,190 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const runProcessMock = vi.fn() +vi.mock('../../shared/child-process/run-process', () => ({ + runProcess: (spec: unknown) => runProcessMock(spec) +})) + +import { sweepOrphanedAgentBrowserSessions } from './agent-browser-orphan-sweep' + +type Spec = { args?: readonly string[] } + +const BIN = '/opt/orca/agent-browser' +const SCOPED = { + env: { AGENT_BROWSER_SOCKET_DIR: '/tmp/orca-ab-0123456789abcdef' }, + ownsSocketDirectory: true +} + +function respond(sessions: string[]): void { + runProcessMock.mockImplementation((spec: Spec) => { + if (spec.args?.[0] === 'session') { + return Promise.resolve({ + code: 0, + signal: null, + stdout: JSON.stringify({ success: true, data: { sessions } }), + stderr: '', + timedOut: false + }) + } + return Promise.resolve({ code: 0, signal: null, stdout: '', stderr: '', timedOut: false }) + }) +} + +function closedArgs(): string[][] { + return runProcessMock.mock.calls + .map((call) => [...((call[0] as Spec).args ?? [])]) + .filter((args) => args.includes('close')) +} + +describe('agent-browser orphan sweep', () => { + beforeEach(() => { + runProcessMock.mockReset() + }) + + it('closes tab daemons left by a previous run', async () => { + respond(['orca-tab-aaa', 'orca-tab-bbb']) + + const closed = await sweepOrphanedAgentBrowserSessions({ binaryPath: BIN, ...SCOPED }) + + expect(closed).toEqual(['orca-tab-aaa', 'orca-tab-bbb']) + expect(closedArgs()).toEqual([ + ['--session', 'orca-tab-aaa', 'close'], + ['--session', 'orca-tab-bbb', 'close'] + ]) + }) + + it('never closes a daemon outside Orca tab naming', async () => { + respond(['default', 'agent1', 'orca-orcad-deadbeef', 'orca-tab-aaa']) + + await sweepOrphanedAgentBrowserSessions({ binaryPath: BIN, ...SCOPED }) + + expect(closedArgs()).toEqual([['--session', 'orca-tab-aaa', 'close']]) + }) + + it('leaves sessions this run already owns alone', async () => { + respond(['orca-tab-live', 'orca-tab-orphan']) + + await sweepOrphanedAgentBrowserSessions({ + binaryPath: BIN, + ...SCOPED, + isSessionLive: (name) => name === 'orca-tab-live' + }) + + expect(closedArgs()).toEqual([['--session', 'orca-tab-orphan', 'close']]) + }) + + // Why: without a socket dir Orca derived itself, `session list` can reach daemons another Orca + // profile owns (Windows named pipes, or an inherited AGENT_BROWSER_SOCKET_DIR). Idle timeout bounds those. + it.each([ + ['no socket directory at all', { PATH: 'C:\\Windows' }], + ['a socket directory Orca inherited', { AGENT_BROWSER_SOCKET_DIR: '/tmp/shared-ab' }] + ])('does not enumerate with %s', async (_label, env) => { + respond(['orca-tab-aaa']) + + const closed = await sweepOrphanedAgentBrowserSessions({ + binaryPath: BIN, + env, + ownsSocketDirectory: false + }) + + expect(closed).toEqual([]) + expect(runProcessMock).not.toHaveBeenCalled() + }) + + it('closes nothing when the listing is unusable', async () => { + runProcessMock.mockResolvedValue({ + code: 1, + signal: null, + stdout: 'not json', + stderr: 'boom', + timedOut: false + }) + + await expect( + sweepOrphanedAgentBrowserSessions({ binaryPath: BIN, ...SCOPED }) + ).resolves.toEqual([]) + expect(closedArgs()).toEqual([]) + }) + + it('survives a listing that never returns', async () => { + runProcessMock.mockRejectedValue(new Error('ENOENT')) + + await expect( + sweepOrphanedAgentBrowserSessions({ binaryPath: BIN, ...SCOPED }) + ).resolves.toEqual([]) + }) + + it('keeps sweeping after one close fails', async () => { + runProcessMock.mockImplementation((spec: Spec) => { + if (spec.args?.[0] === 'session') { + return Promise.resolve({ + code: 0, + signal: null, + stdout: JSON.stringify({ data: { sessions: ['orca-tab-aaa', 'orca-tab-bbb'] } }), + stderr: '', + timedOut: false + }) + } + if (spec.args?.[1] === 'orca-tab-aaa') { + return Promise.reject(new Error('spawn failed')) + } + return Promise.resolve({ code: 0, signal: null, stdout: '', stderr: '', timedOut: false }) + }) + + const closed = await sweepOrphanedAgentBrowserSessions({ binaryPath: BIN, ...SCOPED }) + + expect(closed).toEqual(['orca-tab-bbb']) + }) + + it('bounds every child it starts', async () => { + respond(['orca-tab-aaa']) + + await sweepOrphanedAgentBrowserSessions({ binaryPath: BIN, ...SCOPED }) + + for (const call of runProcessMock.mock.calls) { + expect((call[0] as { timeoutMs?: number | null }).timeoutMs).toBeGreaterThan(0) + } + }) +}) + +describe('sweep kill switch', () => { + const previous = process.env.ORCA_DISABLE_AGENT_BROWSER_SWEEP + + afterEach(() => { + if (previous === undefined) { + delete process.env.ORCA_DISABLE_AGENT_BROWSER_SWEEP + } else { + process.env.ORCA_DISABLE_AGENT_BROWSER_SWEEP = previous + } + }) + + // Why: the idle bound is an env passthrough an operator can raise and the quit close is + // self-bounded, so the sweep is the only new behaviour whose failure would need a revert. + it('enumerates nothing when disabled, even when Orca owns the socket directory', async () => { + process.env.ORCA_DISABLE_AGENT_BROWSER_SWEEP = '1' + runProcessMock.mockClear() + + const closed = await sweepOrphanedAgentBrowserSessions({ + binaryPath: BIN, + env: {}, + ownsSocketDirectory: true + }) + + expect(closed).toEqual([]) + expect(runProcessMock).not.toHaveBeenCalled() + }) + + it('still sweeps when the flag holds any other value', async () => { + process.env.ORCA_DISABLE_AGENT_BROWSER_SWEEP = '0' + runProcessMock.mockClear() + runProcessMock.mockResolvedValue({ code: 0, stdout: '{"data":{"sessions":[]}}', stderr: '' }) + + await sweepOrphanedAgentBrowserSessions({ + binaryPath: BIN, + env: {}, + ownsSocketDirectory: true + }) + + expect(runProcessMock).toHaveBeenCalled() + }) +}) diff --git a/src/main/browser/agent-browser-orphan-sweep.ts b/src/main/browser/agent-browser-orphan-sweep.ts new file mode 100644 index 00000000000..062eb1235db --- /dev/null +++ b/src/main/browser/agent-browser-orphan-sweep.ts @@ -0,0 +1,91 @@ +import { runProcess } from '../../shared/child-process/run-process' + +/** Session-name namespace Orca gives one daemon per browser tab. */ +export const ORCA_TAB_SESSION_PREFIX = 'orca-tab-' + +const SWEEP_TIMEOUT_MS = 5_000 +const SWEEP_MAX_OUTPUT_BYTES = 256 * 1024 + +type SessionListEnvelope = { + data?: { sessions?: unknown } +} + +function parseSessionNames(stdout: string): string[] { + let envelope: SessionListEnvelope + try { + envelope = JSON.parse(stdout) as SessionListEnvelope + } catch { + return [] + } + const sessions = envelope?.data?.sessions + if (!Array.isArray(sessions)) { + return [] + } + return sessions.filter((name): name is string => typeof name === 'string' && name.length > 0) +} + +/** + * Close agent-browser daemons left behind by a previous Orca run. + * + * A crash (or SIGKILL) leaves one daemon per open tab with nobody holding its + * name; `closeStaleAgentBrowserSession` only resets the single name a new tab + * is about to reuse, so the rest persist. This closes them through + * agent-browser's own CLI rather than by walking pids. + * + * Scoping — this only runs when Orca derived the socket directory itself + * (`ownsSocketDirectory`), because that private per-profile directory is what + * proves the enumeration can only see this Orca profile's daemons. An inherited + * `AGENT_BROWSER_SOCKET_DIR` can be shared with a second Orca profile, and + * Windows gets none at all (named pipes make the directory moot); both cases + * skip the sweep rather than run a `session list` that could close a daemon Orca + * does not own, and stay bounded by `AGENT_BROWSER_IDLE_TIMEOUT_MS` instead. + * + * `ORCA_DISABLE_AGENT_BROWSER_SWEEP=1` turns it off in the field. The other two + * behaviours this PR adds are already recoverable without a build — the idle bound + * is an env passthrough an operator can raise, and the quit close is bounded by its + * own timeout — but a sweep that closes the wrong daemon, or spawns one process per + * stale name on a profile with hundreds, would otherwise need a revert. + */ +export async function sweepOrphanedAgentBrowserSessions(options: { + binaryPath: string + env: NodeJS.ProcessEnv + ownsSocketDirectory: boolean + isSessionLive?: (sessionName: string) => boolean +}): Promise { + if (!options.ownsSocketDirectory || process.env.ORCA_DISABLE_AGENT_BROWSER_SWEEP === '1') { + return [] + } + let listed: string[] + try { + const result = await runProcess({ + program: options.binaryPath, + args: ['session', 'list', '--json'], + env: options.env, + timeoutMs: SWEEP_TIMEOUT_MS, + maxOutputBytes: SWEEP_MAX_OUTPUT_BYTES + }) + listed = result.timedOut ? [] : parseSessionNames(result.stdout) + } catch { + return [] + } + + const closed: string[] = [] + for (const sessionName of listed) { + if (!sessionName.startsWith(ORCA_TAB_SESSION_PREFIX) || options.isSessionLive?.(sessionName)) { + continue + } + try { + await runProcess({ + program: options.binaryPath, + args: ['--session', sessionName, 'close'], + env: options.env, + timeoutMs: SWEEP_TIMEOUT_MS, + maxOutputBytes: SWEEP_MAX_OUTPUT_BYTES + }) + closed.push(sessionName) + } catch { + // A daemon that died mid-sweep needs no closing. + } + } + return closed +} diff --git a/src/main/browser/agent-browser-process-environment.test.ts b/src/main/browser/agent-browser-process-environment.test.ts index 739567e49f1..69606d1241d 100644 --- a/src/main/browser/agent-browser-process-environment.test.ts +++ b/src/main/browser/agent-browser-process-environment.test.ts @@ -1,40 +1,86 @@ import { describe, expect, it, vi } from 'vitest' -import { createAgentBrowserProcessEnvironment } from './agent-browser-process-environment' +import { + AGENT_BROWSER_IDLE_TIMEOUT_MS, + createAgentBrowserProcessEnvironment +} from './agent-browser-process-environment' vi.mock('node:fs', () => ({ mkdirSync: vi.fn(), chmodSync: vi.fn() })) describe('agent-browser process environment', () => { it('bounds Unix socket paths independently of a long profile path', () => { - const env = createAgentBrowserProcessEnvironment({ + const { env, ownsSocketDirectory } = createAgentBrowserProcessEnvironment({ inheritedEnv: { PATH: '/bin' }, platform: 'darwin', userDataPath: `/private/var/folders/${'long-profile-segment/'.repeat(12)}` }) const socketDirectory = env.AGENT_BROWSER_SOCKET_DIR + expect(ownsSocketDirectory).toBe(true) expect(socketDirectory).toMatch(/^\/tmp\/orca-ab-[0-9a-f]{16}$/) expect( `${socketDirectory}/orca-tab-00000000-0000-4000-8000-000000000000.sock`.length ).toBeLessThan(104) }) - it('preserves explicit overrides and leaves Windows unchanged', () => { - const configured = { AGENT_BROWSER_SOCKET_DIR: '/custom/socket-dir' } - expect( - createAgentBrowserProcessEnvironment({ - inheritedEnv: configured, - platform: 'linux', + // Why ownsSocketDirectory is false for both: a directory Orca did not derive can be shared with + // another Orca profile, so `session list` under it is no proof of ownership. + it('preserves explicit overrides and leaves Windows socket routing unchanged', () => { + const configured = createAgentBrowserProcessEnvironment({ + inheritedEnv: { AGENT_BROWSER_SOCKET_DIR: '/custom/socket-dir' }, + platform: 'linux', + userDataPath: '/profile' + }) + expect(configured.env.AGENT_BROWSER_SOCKET_DIR).toBe('/custom/socket-dir') + expect(configured.ownsSocketDirectory).toBe(false) + + const windows = createAgentBrowserProcessEnvironment({ + inheritedEnv: { PATH: 'C:\\Windows' }, + platform: 'win32', + userDataPath: 'C:\\Users\\Orca' + }) + expect(windows.env.AGENT_BROWSER_SOCKET_DIR).toBeUndefined() + expect(windows.env.PATH).toBe('C:\\Windows') + expect(windows.ownsSocketDirectory).toBe(false) + }) + + // Why: the only daemon bound that survives a SIGKILL'd Orca, so it must be set on every platform. + it.each(['darwin', 'linux', 'win32'])( + 'bounds daemon idle lifetime on %s', + (platform) => { + const { env } = createAgentBrowserProcessEnvironment({ + inheritedEnv: { PATH: '/bin' }, + platform, userDataPath: '/profile' }) - ).toBe(configured) + expect(env.AGENT_BROWSER_IDLE_TIMEOUT_MS).toBe(String(AGENT_BROWSER_IDLE_TIMEOUT_MS)) + } + ) - const windows = { PATH: 'C:\\Windows' } - expect( - createAgentBrowserProcessEnvironment({ - inheritedEnv: windows, - platform: 'win32', - userDataPath: 'C:\\Users\\Orca' - }) - ).toBe(windows) + it('never cuts a command short: idle timeout exceeds the bridge exec timeout', () => { + expect(AGENT_BROWSER_IDLE_TIMEOUT_MS).toBeGreaterThan(90_000) + }) + + it('honors an explicit idle timeout from the environment', () => { + const { env } = createAgentBrowserProcessEnvironment({ + inheritedEnv: { AGENT_BROWSER_IDLE_TIMEOUT_MS: '5000' }, + platform: 'darwin', + userDataPath: '/profile' + }) + expect(env.AGENT_BROWSER_IDLE_TIMEOUT_MS).toBe('5000') + }) + + it('still bounds the daemon when the socket directory cannot be created', async () => { + const fs = await import('node:fs') + vi.mocked(fs.mkdirSync).mockImplementationOnce(() => { + throw new Error('EACCES') + }) + const { env, ownsSocketDirectory } = createAgentBrowserProcessEnvironment({ + inheritedEnv: { PATH: '/bin' }, + platform: 'linux', + userDataPath: '/profile' + }) + expect(env.AGENT_BROWSER_SOCKET_DIR).toBeUndefined() + expect(ownsSocketDirectory).toBe(false) + expect(env.AGENT_BROWSER_IDLE_TIMEOUT_MS).toBe(String(AGENT_BROWSER_IDLE_TIMEOUT_MS)) }) }) diff --git a/src/main/browser/agent-browser-process-environment.ts b/src/main/browser/agent-browser-process-environment.ts index 9d25236b8dd..ae21104f8d3 100644 --- a/src/main/browser/agent-browser-process-environment.ts +++ b/src/main/browser/agent-browser-process-environment.ts @@ -4,13 +4,45 @@ import { join } from 'node:path' const AGENT_BROWSER_SOCKET_DIRECTORY_PREFIX = 'orca-ab-' +/** + * Lifetime bound for the agent-browser daemon. + * + * `agent-browser` is a client/daemon CLI: Orca only ever spawns the short-lived + * client, which forks a daemon Orca holds no handle on and that reparents to + * pid 1 immediately. Nothing in Orca can reap it — not teardown, not a pid walk + * (see `windows-pty-job.ts` for why walking your own orphans is guesswork) — + * and a SIGKILL'd Orca never runs teardown at all. The daemon's own idle timer + * is the only bound that survives every way Orca can die (#16367). + * + * 10 minutes: >6x `EXEC_TIMEOUT_MS` (90s) so no command, retry chain, or normal + * gap between two user commands can be cut short by it, while capping an + * abandoned daemon at minutes instead of days. Only per-tab helper daemons get + * this: see `externalChromiumAgentBrowserEnvironment` for why the daemon that + * owns a whole Chromium tree must not be idled out. + */ +export const AGENT_BROWSER_IDLE_TIMEOUT_MS = 10 * 60 * 1000 + +export type AgentBrowserProcessEnvironment = { + env: NodeJS.ProcessEnv + /** + * True only when Orca derived the socket directory itself. An inherited + * `AGENT_BROWSER_SOCKET_DIR` can be shared with another Orca profile, so it is + * no proof that `session list` under it sees only this profile's daemons. + */ + ownsSocketDirectory: boolean +} + export function createAgentBrowserProcessEnvironment(options: { inheritedEnv: NodeJS.ProcessEnv platform: NodeJS.Platform userDataPath: string -}): NodeJS.ProcessEnv { - if (options.platform === 'win32' || options.inheritedEnv.AGENT_BROWSER_SOCKET_DIR?.trim()) { - return options.inheritedEnv +}): AgentBrowserProcessEnvironment { + const env = { ...options.inheritedEnv } + if (!env.AGENT_BROWSER_IDLE_TIMEOUT_MS?.trim()) { + env.AGENT_BROWSER_IDLE_TIMEOUT_MS = String(AGENT_BROWSER_IDLE_TIMEOUT_MS) + } + if (options.platform === 'win32' || env.AGENT_BROWSER_SOCKET_DIR?.trim()) { + return { env, ownsSocketDirectory: false } } const profileKey = createHash('sha256').update(options.userDataPath).digest('hex').slice(0, 16) const socketDirectory = join('/tmp', `${AGENT_BROWSER_SOCKET_DIRECTORY_PREFIX}${profileKey}`) @@ -18,7 +50,8 @@ export function createAgentBrowserProcessEnvironment(options: { mkdirSync(socketDirectory, { recursive: true, mode: 0o700 }) chmodSync(socketDirectory, 0o700) } catch { - return options.inheritedEnv + return { env, ownsSocketDirectory: false } } - return { ...options.inheritedEnv, AGENT_BROWSER_SOCKET_DIR: socketDirectory } + env.AGENT_BROWSER_SOCKET_DIR = socketDirectory + return { env, ownsSocketDirectory: true } } diff --git a/src/main/browser/offscreen-browser-backend-lifecycle.test.ts b/src/main/browser/offscreen-browser-backend-lifecycle.test.ts index 52a555c9f5f..da393fa273d 100644 --- a/src/main/browser/offscreen-browser-backend-lifecycle.test.ts +++ b/src/main/browser/offscreen-browser-backend-lifecycle.test.ts @@ -282,4 +282,19 @@ describe('OffscreenBrowserBackend lifecycle', () => { expect(peakRetirements).toBe(4) }) + + it('closes the page even when daemon retirement throws', async () => { + const browserManager = { registerOffscreenGuest: vi.fn(), unregisterGuest: vi.fn() } + const backend = new OffscreenBrowserBackend(browserManager as never, { + getAgentBrowserBridge: () => ({ + onPageClosed: vi.fn(async () => { + throw new Error('daemon gone') + }) + }) + }) + + await backend.createTab({ browserPageId: 'page-1', url: 'about:blank', worktreeId: 'wt' }) + await expect(backend.closeTab('page-1')).resolves.toBeUndefined() + expect(browserManager.unregisterGuest).toHaveBeenCalledWith('page-1') + }) }) diff --git a/src/main/index.ts b/src/main/index.ts index 249700bcd16..a142fc6cf7d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3035,6 +3035,8 @@ void app.whenReady().then(async () => { onTabsChanged: (worktreeId) => runtimeService.notifyMobileSessionTabsChanged(worktreeId) }) runtimeService.setAgentBrowserBridge(agentBrowserBridge) + // Why: daemons a crashed or SIGKILL'd previous run left behind answer to nobody; nothing else reclaims them. + void agentBrowserBridge.sweepOrphanedSessions() const browserClientAutomationDispatcher = new RpcDispatcher({ runtime: runtimeService }) configureBrowserClientPageAutomationRuntime({ browserManager, @@ -3534,7 +3536,10 @@ app.on('will-quit', (e) => { // Why: cancels relay restart/reinstall timers and kills wsl.exe children deterministically, not via stdio-pipe teardown. wslHookRelayManager.disposeAll() const statsFlush = stats?.flushAsync() ?? Promise.resolve() - // Why: retire headless page owners first, then sweep residual helper sessions without duplicate close fanout. + // Why: agent-browser daemon processes would otherwise linger after quit, holding ports and stale session state on disk. + // Why the barrier below: each session's close is its own agent-browser child taking hundreds of ms, + // so an unawaited call reaches app.quit() first and every open tab's daemon survives the quit (#16367). + // Why retire headless page owners first: it closes those helpers without a duplicate close fanout. const browserShutdown = (async (): Promise => { await runtime?.getOffscreenBrowserBackend()?.destroyAll?.() await runtime?.getAgentBrowserBridge()?.destroyAllSessions() diff --git a/src/main/orcad/external-chromium-browser-session.test.ts b/src/main/orcad/external-chromium-browser-session.test.ts new file mode 100644 index 00000000000..730a3d36e2d --- /dev/null +++ b/src/main/orcad/external-chromium-browser-session.test.ts @@ -0,0 +1,130 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const runProcessMock = vi.fn() +vi.mock('../../shared/child-process/run-process', () => ({ + runProcess: (spec: unknown) => runProcessMock(spec) +})) +vi.mock('node:fs/promises', () => ({ + mkdir: vi.fn(async () => undefined), + readFile: vi.fn(async () => Buffer.from('')), + rm: vi.fn(async () => undefined) +})) + +import { + ExternalChromiumBrowserSession, + externalChromiumAgentBrowserEnvironment +} from './external-chromium-browser-session' + +const BASE = { + executablePath: '/opt/orca/chromium', + profilePath: '/state/browser-chromium', + sessionName: 'orca-orcad-0123456789abcdef' +} + +type Spec = { args?: readonly string[]; env?: NodeJS.ProcessEnv } + +function commands(): string[][] { + return runProcessMock.mock.calls.map((call) => [...((call[0] as Spec).args ?? [])]) +} + +describe('orcad external-chromium agent-browser environment', () => { + beforeEach(() => { + runProcessMock.mockReset() + }) + + // Why: this daemon owns the user's remote Chromium, so an idle bound would close a live browser. + it('never bounds the daemon that owns the Chromium tree', () => { + const env = externalChromiumAgentBrowserEnvironment({ inheritedEnv: {}, ...BASE }) + + expect(env.AGENT_BROWSER_IDLE_TIMEOUT_MS).toBeUndefined() + expect(env.AGENT_BROWSER_EXECUTABLE_PATH).toBe(BASE.executablePath) + expect(env.AGENT_BROWSER_SESSION).toBe(BASE.sessionName) + }) + + it('passes an operator-set idle timeout through untouched', () => { + const env = externalChromiumAgentBrowserEnvironment({ + inheritedEnv: { AGENT_BROWSER_IDLE_TIMEOUT_MS: '1234' }, + ...BASE + }) + + expect(env.AGENT_BROWSER_IDLE_TIMEOUT_MS).toBe('1234') + }) + + it('keeps launch arguments joined for the daemon', () => { + const env = externalChromiumAgentBrowserEnvironment({ + inheritedEnv: {}, + ...BASE, + browserArgs: ['--headless=new', '--no-sandbox'] + }) + + expect(env.AGENT_BROWSER_ARGS).toBe('--headless=new\n--no-sandbox') + }) + + // Why (#16367): orcad is the backend a remote user's Chromium hangs off, and this session + // never passes --cdp, so a daemon an earlier orcad left behind is not bound to the dead + // process. Closing it on every start would take their browser and every tab with it. + it("reuses a surviving session instead of closing the user's browser", async () => { + runProcessMock.mockImplementation((spec: Spec) => { + const data = spec.args?.includes('tab') + ? { tabs: [{ active: true, tabId: 'tab-live', title: 'x', url: 'https://example.test' }] } + : {} + return Promise.resolve({ + code: 0, + signal: null, + stdout: JSON.stringify({ success: true, data }), + stderr: '', + timedOut: false + }) + }) + + const session = new ExternalChromiumBrowserSession( + '/opt/orca/agent-browser', + { executablePath: BASE.executablePath, provider: 'chromium' }, + '/state' + ) + await expect(session.start()).resolves.toBe('tab-live') + + const issued = commands().map((args) => args.filter((arg) => !arg.startsWith('-'))) + expect(issued.some((args) => args.includes('close'))).toBe(false) + expect(issued.some((args) => args.includes('open'))).toBe(false) + }) + + // Why: a name that answers nothing is wedged or half-dead, so reclaiming it is correct — + // that is the killed-orcad case the stable session name exists to recover. + it('reclaims a session that answers nothing, then opens', async () => { + let listed = 0 + runProcessMock.mockImplementation((spec: Spec) => { + if (spec.args?.includes('tab')) { + listed += 1 + // First probe finds nothing; after `open` the page exists. + const tabs = + listed === 1 ? [] : [{ active: true, tabId: 'tab-1', title: 'x', url: 'about:blank' }] + return Promise.resolve({ + code: 0, + signal: null, + stdout: JSON.stringify({ success: true, data: { tabs } }), + stderr: '', + timedOut: false + }) + } + return Promise.resolve({ + code: 0, + signal: null, + stdout: JSON.stringify({ success: true, data: {} }), + stderr: '', + timedOut: false + }) + }) + + const session = new ExternalChromiumBrowserSession( + '/opt/orca/agent-browser', + { executablePath: BASE.executablePath, provider: 'chromium' }, + '/state' + ) + await expect(session.start()).resolves.toBe('tab-1') + + const issued = commands().map((args) => args.filter((arg) => !arg.startsWith('-'))) + expect(issued.some((args) => args.includes('close'))).toBe(true) + expect(issued.some((args) => args.includes('open'))).toBe(true) + }) +}) diff --git a/src/main/orcad/external-chromium-browser-session.ts b/src/main/orcad/external-chromium-browser-session.ts index a8aadac3ccd..de72ae8a061 100644 --- a/src/main/orcad/external-chromium-browser-session.ts +++ b/src/main/orcad/external-chromium-browser-session.ts @@ -51,6 +51,25 @@ function classifyAgentBrowserError(message: string): string { return 'browser_error' } +// Why no AGENT_BROWSER_IDLE_TIMEOUT_MS here: unlike a per-tab helper daemon, this one owns the +// user's remote Chromium tree, so idling it out would close their live browser and every tab in it. +// The stable session name plus the `close` in start() is what reclaims a killed orcad's tree (#16367). +export function externalChromiumAgentBrowserEnvironment(options: { + inheritedEnv: NodeJS.ProcessEnv + executablePath: string + profilePath: string + sessionName: string + browserArgs?: readonly string[] +}): NodeJS.ProcessEnv { + return { + ...options.inheritedEnv, + AGENT_BROWSER_EXECUTABLE_PATH: options.executablePath, + AGENT_BROWSER_PROFILE: options.profilePath, + AGENT_BROWSER_SESSION: options.sessionName, + AGENT_BROWSER_ARGS: options.browserArgs?.join('\n') ?? '' + } +} + export class ExternalChromiumBrowserSession { private readonly profilePath: string private readonly sessionName: string @@ -70,16 +89,34 @@ export class ExternalChromiumBrowserSession { async start(): Promise { await mkdir(this.profilePath, { recursive: true }) + // Why: the session name is stable across runs, so a daemon an earlier orcad left behind is + // still driving the user's Chromium. Unlike the pane bridge this session never passes --cdp, + // so nothing binds it to the old process — a surviving one is reusable as-is, and closing it + // would take the remote user's browser and every tab with it (#16367). + const reusable = await this.readActiveTabId() + if (reusable) { + return reusable + } + // Nothing answered, so anything under this name is wedged or half-dead; reclaim it. + await this.stop() await this.run(['open', 'about:blank']) - const tabs = await this.readTabs() - const active = tabs.find((tab) => tab.active) ?? tabs[0] - if (!active) { + const opened = await this.readActiveTabId() + if (!opened) { throw new BrowserError( BROWSER_UNAVAILABLE_ERROR_CODE, 'The browser launched without an automation target.' ) } - return active.tabId + return opened + } + + private async readActiveTabId(): Promise { + try { + const tabs = await this.readTabs() + return (tabs.find((tab) => tab.active) ?? tabs[0])?.tabId ?? null + } catch { + return null + } } async stop(): Promise { @@ -121,13 +158,13 @@ export class ExternalChromiumBrowserSession { args.push('--args', this.launch.browserArgs.join('\n')) } args.push(...command, '--json') - const env = { - ...process.env, - AGENT_BROWSER_EXECUTABLE_PATH: this.launch.executablePath, - AGENT_BROWSER_PROFILE: this.profilePath, - AGENT_BROWSER_SESSION: this.sessionName, - AGENT_BROWSER_ARGS: this.launch.browserArgs?.join('\n') ?? '' - } + const env = externalChromiumAgentBrowserEnvironment({ + inheritedEnv: process.env, + executablePath: this.launch.executablePath, + profilePath: this.profilePath, + sessionName: this.sessionName, + browserArgs: this.launch.browserArgs + }) const result = await runProcess({ program: this.agentBrowserPath, args, diff --git a/src/main/quit-teardown-agent-browser-daemons.test.ts b/src/main/quit-teardown-agent-browser-daemons.test.ts new file mode 100644 index 00000000000..cc2c7d7a26e --- /dev/null +++ b/src/main/quit-teardown-agent-browser-daemons.test.ts @@ -0,0 +1,33 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * agent-browser forks a daemon per browser tab that Orca holds no handle on, and + * `destroyAllSessions` closes each one by spawning another agent-browser child — + * hundreds of ms apiece. Left off the will-quit barrier, `app.quit()` fired first and + * every open tab's daemon outlived the app (#16367). + */ +const source = readFileSync(join(__dirname, 'index.ts'), 'utf8') + +function teardownBarrierMembers(): string { + const start = source.indexOf('settleTeardownWithinDeadline([') + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf('])', start) + expect(end).toBeGreaterThan(start) + return source.slice(start, end) +} + +describe('quit teardown of agent-browser daemons', () => { + it('joins the will-quit teardown barrier', () => { + expect(teardownBarrierMembers()).toContain("{ name: 'browser', promise: browserShutdown }") + }) + + it('captures the destroyAllSessions promise instead of firing and forgetting', () => { + expect(source).toMatch( + /const browserShutdown = \(async \(\): Promise => \{[\s\S]*?await runtime\?\.getAgentBrowserBridge\(\)\?\.destroyAllSessions\(\)\s+\}\)\(\)/ + ) + // Why: a second, uncaptured call site is the pre-fix shape — it loses the race to app.quit(). + expect(source.match(/getAgentBrowserBridge\(\)\?\.destroyAllSessions\(\)/g)).toHaveLength(1) + }) +}) From 07f2e14c0801fa3b9b1f341701c383b61d9b442d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:00:21 -0700 Subject: [PATCH 032/152] refactor(codex): make WSL account surfaces direct-home aware (#16499) * refactor(codex): make WSL account surfaces direct-home aware * fix(wsl): keep Codex relay hooks on managed runtime home * test(wsl): assert relay hooks use managed Codex home --- .../remote-hook-service-installers.test.ts | 38 +++++++++---------- .../remote-managed-hook-installers.ts | 18 ++++----- src/main/agent-hooks/wsl-hook-fs-adapter.ts | 9 +++-- .../wsl-hook-relay-live.integration.test.ts | 17 +++------ .../wsl-hook-relay-manager.test.ts | 4 +- .../runtime-home-per-account-homes.test.ts | 29 ++++++++++++++ .../codex-accounts/runtime-home-service.ts | 22 ++++++----- .../codex/codex-legacy-session-resume.test.ts | 21 ++++++++++ src/main/codex/codex-legacy-session-resume.ts | 2 + src/main/codex/hook-service.ts | 7 +--- .../host-readable-transcript-path.test.ts | 12 ++++++ .../host-readable-transcript-path.ts | 16 +++++++- src/main/runtime/orca-runtime.ts | 4 ++ 13 files changed, 134 insertions(+), 65 deletions(-) diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 28d11c73f0f..051b8bc48d1 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -241,7 +241,20 @@ describe('remote hook service installers', () => { expect(toml).toContain('trusted_hash = "sha256:') }) - it('installs Codex hooks into an explicit redirected CODEX_HOME (WSL managed runtime home)', async () => { + it('reports Codex trust-write failures without rolling back installed hooks', async () => { + const { sftp, fs } = createFakeSftp() + fs.failRenameTo.add('/home/dev/.codex/config.toml') + + const status = await new CodexHookService().installRemote(sftp, '/home/dev') + + expect(status.state).toBe('error') + expect(status.managedHooksPresent).toBe(true) + expect(status.detail).toContain('trust entries could not be written') + expect(fs.files.get('/home/dev/.codex/hooks.json')).toContain('codex-hook.sh') + expect(fs.files.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toContain('#!/bin/sh') + }) + + it('installs Codex hooks into an explicit redirected CODEX_HOME', async () => { const runtimeHome = '/home/dev/.local/share/orca/codex-runtime-home/home' const { sftp, fs } = createFakeSftp({ [`${runtimeHome}/config.toml`]: 'model = "gpt-5.2-codex"\n' @@ -261,12 +274,12 @@ describe('remote hook service installers', () => { expect(hooks.hooks.Stop?.[0]?.hooks?.[0]?.command).toContain( '/home/dev/.orca/agent-hooks/codex-hook.sh' ) - const toml = fs.files.get(`${runtimeHome}/config.toml`) - expect(toml).toContain('model = "gpt-5.2-codex"') - expect(toml).toContain(`${runtimeHome}/hooks.json:stop:0:0`) + expect(fs.files.get(`${runtimeHome}/config.toml`)).toContain( + `${runtimeHome}/hooks.json:stop:0:0` + ) }) - it('defers Codex trust writes until the redirected config.toml exists (launch-path seed race)', async () => { + it('defers redirected Codex trust writes until config.toml exists', async () => { const runtimeHome = '/home/dev/.local/share/orca/codex-runtime-home/home' const { sftp, fs } = createFakeSftp() @@ -278,24 +291,9 @@ describe('remote hook service installers', () => { expect(status.state).toBe('installed') expect(status.detail).toContain('deferred') expect(fs.files.get(`${runtimeHome}/hooks.json`)).toContain('codex-hook.sh') - // Why: creating config.toml here would make the launch path's - // only-if-absent seed skip the user's real config. expect(fs.files.has(`${runtimeHome}/config.toml`)).toBe(false) }) - it('reports Codex trust-write failures without rolling back installed hooks', async () => { - const { sftp, fs } = createFakeSftp() - fs.failRenameTo.add('/home/dev/.codex/config.toml') - - const status = await new CodexHookService().installRemote(sftp, '/home/dev') - - expect(status.state).toBe('error') - expect(status.managedHooksPresent).toBe(true) - expect(status.detail).toContain('trust entries could not be written') - expect(fs.files.get('/home/dev/.codex/hooks.json')).toContain('codex-hook.sh') - expect(fs.files.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toContain('#!/bin/sh') - }) - it('installs remote Gemini, Antigravity, Cursor, Command Code, Grok, and Devin configs using their CLI-specific schemas', async () => { const gemini = createFakeSftp() const antigravity = createFakeSftp() diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index 6471ec5cfb4..bf97436f8f8 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -16,11 +16,10 @@ import { kimiHookService } from '../kimi/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' export type RemoteManagedHookInstallOptions = { - /** Explicit CODEX_HOME dir for redirected runtimes (WSL managed runtime - * home). Codex-only: it is the one agent whose home Orca redirects. Also - * defers the config.toml trust write until that file exists, so the - * launch path's only-if-absent seed is never pre-empted. */ + /** Explicit CODEX_HOME dir for redirected runtimes (for example WSL's managed runtime home). */ codexHomeDir?: string + /** Skip the trust write when a redirected runtime config is seeded by the launch path. */ + deferTrustUntilConfigToml?: boolean /** Explicit GROK_HOME for remote runtimes that redirect Grok's config. */ grokHomeDir?: string /** Stops before starting the next installer when the owning relay request @@ -46,13 +45,10 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [ [ 'codex', (sftp, remoteHome, options) => - codexHookService.installRemote( - sftp, - remoteHome, - options?.codexHomeDir - ? { codexHomeDir: options.codexHomeDir, deferTrustUntilConfigToml: true } - : undefined - ) + codexHookService.installRemote(sftp, remoteHome, { + codexHomeDir: options?.codexHomeDir, + deferTrustUntilConfigToml: options?.deferTrustUntilConfigToml + }) ], ['gemini', (sftp, remoteHome) => geminiHookService.installRemote(sftp, remoteHome)], ['antigravity', (sftp, remoteHome) => antigravityHookService.installRemote(sftp, remoteHome)], diff --git a/src/main/agent-hooks/wsl-hook-fs-adapter.ts b/src/main/agent-hooks/wsl-hook-fs-adapter.ts index 9578fda1d10..12563b65bd8 100644 --- a/src/main/agent-hooks/wsl-hook-fs-adapter.ts +++ b/src/main/agent-hooks/wsl-hook-fs-adapter.ts @@ -15,9 +15,7 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env' import { WSL_HOOK_FS_METHODS, type WslFsResult } from '../../shared/wsl-hook-relay-contract' -/** Run the shared remote hook installers against a WSL guest over the relay's - * fs bridge. Codex is the one agent whose home Orca redirects for WSL - * sessions, so its hooks go to the managed runtime home. */ +/** Run the shared remote hook installers against a WSL guest over the relay's fs bridge. */ export async function installWslGuestHooks(options: { mux: SshChannelMultiplexer guestHome: string @@ -45,8 +43,11 @@ export async function installWslGuestHooks(options: { return } const results = await installHooks(createWslHookSftpAdapter(mux), guestHome, { + agents, + // WSL Codex launches use Orca's managed runtime CODEX_HOME, not ~/.codex. + // Keep relay hooks in that active home so status callbacks are received. codexHomeDir: wslCodexRuntimeHomeForGuestHome(guestHome), - agents + deferTrustUntilConfigToml: true }) const failed = results.filter((r) => r.state === 'error').length if (failed > 0) { diff --git a/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts b/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts index ab6d6a03e45..ea6856e9862 100644 --- a/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts @@ -13,6 +13,7 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import { AgentHookServer } from './server' import { WslHookRelayManager } from './wsl-hook-relay-manager' +import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env' const BUNDLE_DIR = join(process.cwd(), 'out', 'relay', 'wsl') const BUNDLE_JS = join(BUNDLE_DIR, 'wsl-agent-hook-relay.js') @@ -113,18 +114,10 @@ describe.skipIf(process.platform === 'win32')( manager.ensureForDistro('LiveDistro') - // Codex hooks land in the redirected managed runtime home. Waiting on - // this artifact (not Claude's, which is written first) keeps the + // Waiting on Codex's artifact (not Claude's, which is written first) keeps the // assertions behind the still-running 14-agent installer loop. - const codexRuntimeHome = join( - fakeHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home' - ) - await vi.waitFor(() => expect(existsSync(join(codexRuntimeHome, 'hooks.json'))).toBe(true), { + const codexHome = wslCodexRuntimeHomeForGuestHome(fakeHome) + await vi.waitFor(() => expect(existsSync(join(codexHome, 'hooks.json'))).toBe(true), { timeout: 15_000 }) expect(existsSync(join(fakeHome, '.claude', 'settings.json'))).toBe(true) @@ -135,7 +128,7 @@ describe.skipIf(process.platform === 'win32')( expect(claudeScript).toContain('/hook/claude') // Trust TOML is deferred so the launch-path seed is never pre-empted. - expect(existsSync(join(codexRuntimeHome, 'config.toml'))).toBe(false) + expect(existsSync(join(codexHome, 'config.toml'))).toBe(false) expect(existsSync(join(fakeHome, '.codex', 'hooks.json'))).toBe(false) // Re-coordinate exactly like a hook script: read the relay-written diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts index e6a3a727fb4..797b461d133 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts @@ -244,10 +244,10 @@ describe('WslHookRelayManager', () => { manager.ensureForDistro('Ubuntu') await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1)) expect(deps.spawnRelay).toHaveBeenCalledTimes(1) - // Codex is the one agent whose home Orca redirects for WSL sessions. expect(deps.installHooks).toHaveBeenCalledWith(expect.anything(), home, { + agents: ['codex'], codexHomeDir: `${home}/.local/share/orca/codex-runtime-home/home`, - agents: ['codex'] + deferTrustUntilConfigToml: true }) expect(manager.getGuestEndpointFilePath('Ubuntu')).toBe( diff --git a/src/main/codex-accounts/runtime-home-per-account-homes.test.ts b/src/main/codex-accounts/runtime-home-per-account-homes.test.ts index fd4823acfbc..7877482f355 100644 --- a/src/main/codex-accounts/runtime-home-per-account-homes.test.ts +++ b/src/main/codex-accounts/runtime-home-per-account-homes.test.ts @@ -305,6 +305,35 @@ describe('CodexRuntimeHomeService', () => { expect(discovery).toContain(home1) }) + it('includes WSL account homes in session discovery', async () => { + const wslHome = + '\\\\wsl.localhost\\Ubuntu\\home\\me\\.local\\share\\orca\\codex-accounts\\account-1\\home' + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'wsl@example.com', + managedHomePath: wslHome, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/me/.local/share/orca/codex-accounts/account-1/home', + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + ) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(service.getHostCodexHomePathsForSessionDiscovery()).toContain(wslHome) + }) + it('surfaces per-account rollouts for session discovery on the mirror lane', async () => { // A Windows host keeps the shared system-default mirror, but its managed // accounts still launch from their own homes and accumulate rollouts there. diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 1d6f80dbafc..cf3a0105d63 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -356,14 +356,14 @@ export class CodexRuntimeHomeService { return account } - // Why: session discovery must surface a managed account's own rollouts wherever - // they physically live. Every host managed home is a live CODEX_HOME, so scan - // them all. - private getManagedHostAccountHomesForSessionDiscovery(): string[] { + // Why: session discovery must surface every account's own rollouts wherever they live. + private getManagedAccountHomesForSessionDiscovery(): string[] { const settings = this.store.getSettings() const homes: string[] = [] for (const account of settings.codexManagedAccounts) { - if (this.getWslManagedHomePath(account)) { + const wslHome = this.getWslManagedHomePath(account) + if (wslHome) { + homes.push(wslHome) continue } const trustedHome = this.getTrustedSelfContainedManagedHomePath(account) @@ -374,6 +374,12 @@ export class CodexRuntimeHomeService { return homes } + private getManagedHostAccountHomesForSessionDiscovery(): string[] { + return this.getManagedAccountHomesForSessionDiscovery().filter( + (home) => parseWslUncPath(home) === null + ) + } + private prepareSelfContainedManagedHomeForLaunch( account: CodexManagedAccount, unavailableManagedHomePath?: string @@ -583,10 +589,8 @@ export class CodexRuntimeHomeService { // mirror, so include the real root for both directly-routed host lanes. homes.push(getSystemCodexHomePath()) } - // Why: each managed host account runs in its own self-contained home, so - // its rollouts live there rather than in the shared mirror. Scan every such - // home so account-scoped sessions still surface in the AI Vault. - for (const perAccountHome of this.getManagedHostAccountHomesForSessionDiscovery()) { + // Why: account-scoped rollouts live in each account's own home, including WSL. + for (const perAccountHome of this.getManagedAccountHomesForSessionDiscovery()) { homes.push(perAccountHome) } return homes.filter((home, index) => homes.indexOf(home) === index) diff --git a/src/main/codex/codex-legacy-session-resume.test.ts b/src/main/codex/codex-legacy-session-resume.test.ts index ee5f7a61d9a..a895653b047 100644 --- a/src/main/codex/codex-legacy-session-resume.test.ts +++ b/src/main/codex/codex-legacy-session-resume.test.ts @@ -325,6 +325,27 @@ describe('per-account resume repin', () => { expect(result).toEqual({ useRealCodexHome: false }) }) + it('does not consult the host selection while resuming a WSL account session', async () => { + const wslHome = + '\\\\wsl.localhost\\Ubuntu\\home\\me\\.local\\share\\orca\\codex-accounts\\account-1\\home' + const result = await prepareLegacySharedCodexSessionResume( + { + agent: 'codex', + filePath: `${wslHome}\\sessions\\2026\\07\\20\\rollout-session.jsonl`, + codexHome: wslHome, + executionHostId: 'local' + }, + { + ...repinOptions(), + getSelectedHostAccountCodexHomePath: () => { + throw new Error('host lane must not be consulted') + } + } + ) + + expect(result).toEqual({ useRealCodexHome: false }) + }) + it('declines a transcript outside the dated rollout layout', async () => { const straySessionPath = join(peerHome, 'sessions', 'stray.jsonl') writeFileSync(straySessionPath, '{"type":"session_meta"}\n', 'utf-8') diff --git a/src/main/codex/codex-legacy-session-resume.ts b/src/main/codex/codex-legacy-session-resume.ts index f61135b6aff..29112c38d03 100644 --- a/src/main/codex/codex-legacy-session-resume.ts +++ b/src/main/codex/codex-legacy-session-resume.ts @@ -9,6 +9,7 @@ import type { import { isPerAccountManagedCodexHome } from '../../shared/ai-vault-resume-preparation' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { parseWslUncPath } from '../../shared/wsl-paths' import { appendCodexSessionHealAuditRecord, createCodexSessionBackfillAuditWriter @@ -104,6 +105,7 @@ async function resolveSelectedAccountCodexHomeForResume( args.agent !== 'codex' || args.executionHostId !== LOCAL_EXECUTION_HOST_ID || !args.codexHome || + parseWslUncPath(args.codexHome) !== null || !isPerAccountManagedCodexHome(args.codexHome) ) { return null diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 93dfaf12f41..6dc78fdabf1 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -1479,12 +1479,7 @@ export class CodexHookService { async installRemote( sftp: SFTPWrapper, remoteHome: string, - options?: { - /** Explicit CODEX_HOME dir (flat layout). WSL sessions read Orca's managed runtime home, not ~/.codex, so the default location leaves them hookless. */ - codexHomeDir?: string - /** Skip the trust write when config.toml is absent — the WSL launch path seeds it only-if-absent, so creating it here would cancel that seed. */ - deferTrustUntilConfigToml?: boolean - } + options?: { codexHomeDir?: string; deferTrustUntilConfigToml?: boolean } ): Promise { const codexHomeBase = options?.codexHomeDir?.replace(/\/$/, '') ?? `${remoteHome.replace(/\/$/, '')}/.codex` diff --git a/src/main/native-chat/host-readable-transcript-path.test.ts b/src/main/native-chat/host-readable-transcript-path.test.ts index 4d19b3fa8e8..cc655dcb9c6 100644 --- a/src/main/native-chat/host-readable-transcript-path.test.ts +++ b/src/main/native-chat/host-readable-transcript-path.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { + configureHostReadableTranscriptPathSources, isGuestAbsoluteLinuxPath, needsWslHostTranslation, resetHostReadableTranscriptPathCacheForTests, @@ -216,4 +217,15 @@ describe('wslCodexSessionsDirs', () => { `${UBUNTU_HOME}\\.codex\\sessions` ]) }) + + it('includes WSL managed-account session roots supplied by the runtime', async () => { + const accountHome = `${UBUNTU_HOME}\\.local\\share\\orca\\codex-accounts\\account-1\\home` + configureHostReadableTranscriptPathSources({ + getAdditionalCodexHomePaths: () => [accountHome, '/host/account/home'] + }) + + await expect( + wslCodexSessionsDirs({ platform: 'win32', listWslHomeDirs: async () => [UBUNTU_HOME] }) + ).resolves.toContain(`${accountHome}\\sessions`) + }) }) diff --git a/src/main/native-chat/host-readable-transcript-path.ts b/src/main/native-chat/host-readable-transcript-path.ts index 9337281cf7f..8650477e430 100644 --- a/src/main/native-chat/host-readable-transcript-path.ts +++ b/src/main/native-chat/host-readable-transcript-path.ts @@ -72,6 +72,13 @@ const WSL_HOME_DIRS_TTL_MS = 5 * 60_000 let cachedWslHomeDirs: string[] | null = null let cachedWslHomeDirsExpiresAt = 0 let inflightWslHomeDirs: Promise | null = null +let getAdditionalCodexHomePaths: (() => readonly string[]) | undefined + +export function configureHostReadableTranscriptPathSources(options: { + getAdditionalCodexHomePaths?: () => readonly string[] +}): void { + getAdditionalCodexHomePaths = options.getAdditionalCodexHomePaths +} async function defaultListWslHomeDirs(): Promise { const homes = await Promise.all( @@ -103,6 +110,7 @@ export function resetHostReadableTranscriptPathCacheForTests(): void { cachedWslHomeDirs = null cachedWslHomeDirsExpiresAt = 0 inflightWslHomeDirs = null + getAdditionalCodexHomePaths = undefined } /** @@ -189,10 +197,16 @@ export async function wslCodexSessionsDirs( return [] } const homeDirs = await wslHomeDirs(deps.listWslHomeDirs ?? defaultListWslHomeDirs) - return homeDirs.flatMap((home) => [ + const dirs = homeDirs.flatMap((home) => [ joinUnderWslHome(home, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS, 'sessions'), joinUnderWslHome(home, '.codex', 'sessions') ]) + for (const home of getAdditionalCodexHomePaths?.() ?? []) { + if (parseWslUncPath(home)) { + dirs.push(joinUnderWslHome(home, 'sessions')) + } + } + return dirs.filter((dir, index) => dirs.indexOf(dir) === index) } // Why: node:path.join is posix-flavoured off Windows and would mangle the diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 32373ee98cc..593a5f1db41 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -662,6 +662,7 @@ import { configureAiVaultSessionSources, listAiVaultSessions } from '../ai-vault/cached-session-list' +import { configureHostReadableTranscriptPathSources } from '../native-chat/host-readable-transcript-path' import { resolveLocalAiVaultSessionTitles } from '../ai-vault/session-title-resolver' import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' import type { @@ -3900,6 +3901,9 @@ export class OrcaRuntimeService { configureAiVaultSessionSources({ getAdditionalCodexHomePaths: deps.getAdditionalAiVaultCodexHomePaths }) + configureHostReadableTranscriptPathSources({ + getAdditionalCodexHomePaths: deps.getAdditionalAiVaultCodexHomePaths + }) } // Why: the daemon adapter is installed via `setLocalPtyProvider()` during // attachMainWindowServices, AFTER this service is constructed. Capturing From f6e16ba3f8852764f059173a21d5b59365fd3106 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:02:09 -0700 Subject: [PATCH 033/152] fix(native-chat): anchor an unmatched chat echo where it was sent (#16117) * fix(native-chat): anchor an unmatched chat echo where it was sent The reported symptom was old user messages replaying below every new turn, so the conversation read as scrambled. The cause was not that the echo failed to match a transcript row. Claude consumes a mid-turn send through a `queued_command` attachment and writes no `type:"user"` record for it, so some echoes can never match, and no amount of matching will change that. The cause was WHERE an unmatched echo rendered: buildMobileNativeChatTransientData appended every pending item after the entire transcript, so it re-read below each turn that landed afterwards. Render each echo directly after the transcript row it was sent against, using the baseline the send already captures. An unmatched echo is then at worst a duplicate in the right position rather than a scrambled one, and it stays visible. Echoes sharing an anchor keep send order; a send with no baseline, or one whose anchor folding dropped, still falls back to the tail. Deliberately NOT fixed by deleting the echo. Inferring from send ordering that an echo can never match, then removing it, loses the user's own text for a message the agent did receive, and it cannot fire in the common case anyway - measured drain groups are 1,017 of size 1 against 55 larger. It also escalates an existing gap: the count pass has no baseline-tail guard, unlike the glue pass, while `messages` is a 40-row window that head-trims, resets on reconnect and grows at the front on loadEarlier, so a false landing there would license deleting a DIFFERENT outstanding message. That count-pass gap is real and left for a separate change; anchoring makes its worst case a duplicate in place rather than a scrambled conversation. * fix(native-chat): preserve folded echo anchors * fix(native-chat): preserve forward-folded echo anchors * fix(native-chat): keep leading folded echoes in place --- mobile/src/session/MobileNativeChatView.tsx | 3 +- .../mobile-native-chat-render-data.test.ts | 161 ++++++++++++++++++ .../session/mobile-native-chat-render-data.ts | 128 +++++++++++--- ...e-native-chat-drafts-glued-pending.test.ts | 1 + 4 files changed, 267 insertions(+), 26 deletions(-) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 190001417a3..2bd0e92b22c 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -181,12 +181,13 @@ export function MobileNativeChatView({ const { data } = useMemo( () => buildMobileNativeChatTransientData({ + messages, folded, streaming, pending, imagePreviewsByMessageId }), - [folded, streaming, pending, imagePreviewsByMessageId] + [messages, folded, streaming, pending, imagePreviewsByMessageId] ) // Follow the tail as the conversation grows and keep the newest message above diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts index 60db9e818d5..3c0281e24b8 100644 --- a/mobile/src/session/mobile-native-chat-render-data.test.ts +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -65,6 +65,7 @@ function build( pending: Parameters[0]['pending'] ): NativeChatMessage[] { return buildMobileNativeChatTransientData({ + messages, folded: foldMobileNativeChatMessages(messages), streaming, pending @@ -143,6 +144,7 @@ describe('buildMobileNativeChatTransientData', () => { user('prompt', '[Image #1] look at this') ]) const result = buildMobileNativeChatTransientData({ + messages: folded, folded, streaming: null, pending: [], @@ -158,6 +160,7 @@ describe('buildMobileNativeChatTransientData', () => { it('restores the local preview onto a marker-only transcript turn', () => { const result = buildMobileNativeChatTransientData({ + messages: [user('prompt', '[Image #1]')], folded: foldMobileNativeChatMessages([user('prompt', '[Image #1]')]), streaming: null, pending: [], @@ -269,3 +272,161 @@ describe('foldMobileNativeChatMessages', () => { ]) }) }) + +// Claude consumes a mid-turn send through a `queued_command` attachment and writes +// no `type:"user"` record for it, so that echo has no row to match - ever. What +// made the conversation read as scrambled was not the unmatched echo itself but +// where it rendered: appended after every turn, so it re-read below each new one. +describe('buildMobileNativeChatTransientData anchoring', () => { + function row(id: string, role: 'user' | 'assistant', text: string): NativeChatMessage { + return { id, role, blocks: [{ type: 'text', text }], timestamp: 1, source: 'transcript' } + } + + it('keeps an unmatched echo where it was sent instead of below later turns', () => { + const folded = [row('m1', 'user', 'earlier'), row('m2', 'assistant', 'on it')] + const { data } = buildMobileNativeChatTransientData({ + messages: folded, + folded, + streaming: null, + pending: [{ id: 'p1', text: 'a mid-turn send', baselineTailMessageId: 'm2' }] + }) + expect(data.map((m) => m.id)).toEqual(['m1', 'm2', 'p1']) + + // The agent keeps working. The echo must NOT drift below the new turns. + const later = [ + ...folded, + row('m3', 'assistant', 'still working'), + row('m4', 'assistant', 'done') + ] + const { data: after } = buildMobileNativeChatTransientData({ + messages: later, + folded: later, + streaming: null, + pending: [{ id: 'p1', text: 'a mid-turn send', baselineTailMessageId: 'm2' }] + }) + expect(after.map((m) => m.id)).toEqual(['m1', 'm2', 'p1', 'm3', 'm4']) + }) + + it('reproduces the reported replay: stale echoes stay in place, not stacked at the tail', () => { + const folded = [ + row('m1', 'user', 'first question'), + row('m2', 'assistant', 'answering'), + row('m3', 'assistant', 'newest turn') + ] + const { data } = buildMobileNativeChatTransientData({ + messages: folded, + folded, + streaming: null, + pending: [ + { id: 'p1', text: 'sent against m1', baselineTailMessageId: 'm1' }, + { id: 'p2', text: 'sent against m2', baselineTailMessageId: 'm2' } + ] + }) + expect(data.map((m) => m.id)).toEqual(['m1', 'p1', 'm2', 'p2', 'm3']) + }) + + it('keeps send order among echoes sharing one anchor', () => { + const { data } = buildMobileNativeChatTransientData({ + messages: [row('m1', 'assistant', 'ready')], + folded: [row('m1', 'assistant', 'ready')], + streaming: null, + pending: [ + { id: 'p1', text: 'first', baselineTailMessageId: 'm1' }, + { id: 'p2', text: 'second', baselineTailMessageId: 'm1' } + ] + }) + expect(data.map((m) => m.id)).toEqual(['m1', 'p1', 'p2']) + }) + + it('falls back to the tail when the send captured no baseline', () => { + const { data } = buildMobileNativeChatTransientData({ + messages: [row('m1', 'assistant', 'ready')], + folded: [row('m1', 'assistant', 'ready')], + streaming: null, + pending: [{ id: 'p1', text: 'no baseline yet', baselineTailMessageId: null }] + }) + expect(data.map((m) => m.id)).toEqual(['m1', 'p1']) + }) + + it('falls back to the tail when the captured row left the raw window', () => { + const { data } = buildMobileNativeChatTransientData({ + messages: [row('m1', 'assistant', 'ready')], + folded: [row('m1', 'assistant', 'ready')], + streaming: null, + pending: [{ id: 'p1', text: 'anchored to a folded-away row', baselineTailMessageId: 'gone' }] + }) + expect(data.map((m) => m.id)).toEqual(['m1', 'p1']) + }) + + it('keeps an echo before later turns when its folded baseline leads the raw window', () => { + const messages = [ + row('noise', 'user', 'hidden boundary'), + row('a1', 'assistant', 'arrived later') + ] + const folded = foldMobileNativeChatMessages(messages) + expect(folded.map((message) => message.id)).toEqual(['a1']) + + const { data } = buildMobileNativeChatTransientData({ + messages, + folded, + streaming: null, + pending: [ + { id: 'p1', text: 'sent after the hidden boundary', baselineTailMessageId: 'noise' } + ] + }) + expect(data.map((message) => message.id)).toEqual(['p1', 'a1']) + }) + + it('still puts the streaming bubble after the transcript', () => { + const { data } = buildMobileNativeChatTransientData({ + messages: [row('m1', 'user', 'hi')], + folded: [row('m1', 'user', 'hi')], + streaming: 'thinking', + pending: [{ id: 'p1', text: 'echo', baselineTailMessageId: 'm1' }] + }) + expect(data.map((m) => m.id)).toEqual(['m1', 'p1', 'streaming']) + }) + + it('anchors a send captured against a tool row to its folded assistant', () => { + const messages: NativeChatMessage[] = [ + row('a1', 'assistant', 'working'), + { + id: 'tool', + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'Bash', input: { command: 'pnpm test' } }], + timestamp: 2, + source: 'transcript' + }, + row('a2', 'assistant', 'done') + ] + const folded = foldMobileNativeChatMessages(messages) + expect(folded.map((message) => message.id)).toEqual(['a1', 'a2']) + + const { data } = buildMobileNativeChatTransientData({ + messages, + folded, + streaming: null, + pending: [{ id: 'p1', text: 'sent during the tool', baselineTailMessageId: 'tool' }] + }) + expect(data.map((message) => message.id)).toEqual(['a1', 'p1', 'a2']) + }) + + it('anchors after the prompt that absorbed an earlier image-source row', () => { + const messages = [ + row('a1', 'assistant', 'ready'), + user('source', '[Image: source: /tmp/earlier.png]'), + user('prompt', '[Image #1] earlier image'), + row('a2', 'assistant', 'done') + ] + const folded = foldMobileNativeChatMessages(messages) + expect(folded.map((message) => message.id)).toEqual(['a1', 'prompt', 'a2']) + + const { data } = buildMobileNativeChatTransientData({ + messages, + folded, + streaming: null, + pending: [{ id: 'p1', text: 'sent after the image source', baselineTailMessageId: 'source' }] + }) + expect(data.map((message) => message.id)).toEqual(['a1', 'prompt', 'p1', 'a2']) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index 92b7e6b8338..da93efadbb8 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -6,7 +6,10 @@ import { import { stripNoiseMessages } from '../../../src/shared/native-chat-noise' import { foldToolMessages } from '../../../src/shared/native-chat-tool-fold' import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types' -import { normalizeImageTranscriptMessages } from './mobile-native-chat-image-transcript-markers' +import { + isImageSourceUserTurn, + normalizeImageTranscriptMessages +} from './mobile-native-chat-image-transcript-markers' import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' /** The centered empty-state copy for a chat with no messages, mirroring the @@ -42,6 +45,10 @@ export type MobileNativeChatPendingItem = { id: string text: string images?: string[] + /** Transcript tail when the send was issued. The echo renders directly after + * that row, so a send whose row never arrives stays where it was sent instead + * of trailing every turn that lands afterwards. */ + baselineTailMessageId?: string | null } export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] { @@ -50,15 +57,16 @@ export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): Nat return stripNoiseMessages(foldToolMessages(normalizeImageTranscriptMessages(messages))) } -/** Assemble the list data the chat renders: the folded transcript, then a - * synthetic bubble for the streaming text the gate let through, then the - * route-owned accepted optimistic messages at the tail. */ +/** Assemble the folded transcript, streaming text, and optimistic user echoes. */ export function buildMobileNativeChatTransientData({ + messages, folded, streaming, pending, imagePreviewsByMessageId }: { + /** Raw transcript rows, used to project folded-away send boundaries. */ + messages: NativeChatMessage[] folded: NativeChatMessage[] /** Streaming bubble text, already gated by `deriveMobileNativeChatStreaming`. */ streaming: string | null @@ -85,31 +93,101 @@ export function buildMobileNativeChatTransientData({ } return { ...message, blocks } }) - const data: NativeChatMessage[] = [ - ...renderedFolded, - ...(streaming - ? [ - { - id: 'streaming', - role: 'assistant' as const, - blocks: [{ type: 'text' as const, text: streaming }], - timestamp: null, - source: 'hook' as const - } - ] - : []), - ...pending.map((p) => ({ - id: p.id, - role: 'user' as const, + // Why anchored rather than appended: an echo whose transcript row never + // arrives — Claude consumes a mid-turn send without writing a user record — + // used to sit at the tail forever, re-reading below every turn that landed + // afterwards. That is what makes the conversation look re-ordered. Rendering it + // after the row it was sent against keeps it in place, so an unmatched echo is + // at worst a duplicate in the right position instead of a scrambled one. + const anchoredPending = new Map() + const leadingPending: NativeChatMessage[] = [] + const trailingPending: NativeChatMessage[] = [] + const foldedIds = new Set(renderedFolded.map((message) => message.id)) + const missingBaselineIds = new Set() + for (const item of pending) { + const baselineId = item.baselineTailMessageId + if (baselineId && !foldedIds.has(baselineId)) { + missingBaselineIds.add(baselineId) + } + } + const foldedAnchorByRawId = new Map() + const leadingBaselineIds = new Set() + if (missingBaselineIds.size > 0) { + let lastVisibleId: string | null = null + const forwardImageBaselineIds: string[] = [] + for (const message of messages) { + if (foldedIds.has(message.id)) { + for (const baselineId of forwardImageBaselineIds) { + foldedAnchorByRawId.set(baselineId, message.id) + } + forwardImageBaselineIds.length = 0 + lastVisibleId = message.id + } + if (!missingBaselineIds.has(message.id)) { + continue + } + if (isImageSourceUserTurn(message)) { + forwardImageBaselineIds.push(message.id) + } else if (lastVisibleId) { + foldedAnchorByRawId.set(message.id, lastVisibleId) + } else { + leadingBaselineIds.add(message.id) + } + } + } + for (const item of pending) { + const bubble: NativeChatMessage = { + id: item.id, + role: 'user', // Text first (when present), then a thumbnail per ridden-along image so the // sent photo shows immediately, before the transcript echo lands. blocks: [ - ...(p.text ? [{ type: 'text' as const, text: p.text }] : []), - ...(p.images ?? []).map((uri) => ({ type: 'image-ref' as const, url: uri })) + ...(item.text ? [{ type: 'text' as const, text: item.text }] : []), + ...(item.images ?? []).map((uri) => ({ type: 'image-ref' as const, url: uri })) ], timestamp: null, - source: 'transcript' as const - })) - ] + source: 'transcript' + } + // Tool/noise rows fold backward; image-source rows fold into their following prompt. + const baselineId = item.baselineTailMessageId + if (baselineId && leadingBaselineIds.has(baselineId)) { + leadingPending.push(bubble) + continue + } + const anchor = baselineId + ? foldedIds.has(baselineId) + ? baselineId + : foldedAnchorByRawId.get(baselineId) + : undefined + if (!anchor || !foldedIds.has(anchor)) { + trailingPending.push(bubble) + continue + } + const siblings = anchoredPending.get(anchor) + if (siblings) { + siblings.push(bubble) + } else { + anchoredPending.set(anchor, [bubble]) + } + } + + const data: NativeChatMessage[] = [...leadingPending] + for (const message of renderedFolded) { + data.push(message) + const attached = anchoredPending.get(message.id) + if (attached) { + data.push(...attached) + } + } + if (streaming) { + data.push({ + id: 'streaming', + role: 'assistant', + blocks: [{ type: 'text', text: streaming }], + timestamp: null, + source: 'hook' + }) + } + data.push(...trailingPending) return { folded: renderedFolded, streaming, data } } diff --git a/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts index b5d1f92ec82..008119dae5e 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts @@ -41,6 +41,7 @@ function renderedPendingTexts( state: DraftState | null ): (string | undefined)[] { const { data } = buildMobileNativeChatTransientData({ + messages, folded: messages, streaming: null, pending: state?.pending ?? [] From cd5be48a34031623a8b5d3cc210bd6b048c23052 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:23:04 -0700 Subject: [PATCH 034/152] fix(workspace-cleanup): show git status for every row (#16690) --- .../workspace-cleanup/WorkspaceCleanupDialog.tsx | 9 ++++----- .../use-workspace-cleanup-git-evidence.ts | 6 +++--- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx index 44c71f00b0f..ad14bc6995f 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx @@ -29,10 +29,7 @@ import { } from './workspace-cleanup-dialog-notices' import { WorkspaceCleanupRowList } from './workspace-cleanup-row-list' import { formatWorkspaceCleanupScanNotice } from './workspace-cleanup-scan-notice' -import { - applyWorkspaceCleanupGitEvidence, - needsWorkspaceCleanupGitEvidence -} from './workspace-cleanup-git-evidence' +import { applyWorkspaceCleanupGitEvidence } from './workspace-cleanup-git-evidence' import { useWorkspaceCleanupBrowseState } from './use-workspace-cleanup-browse-state' import { useWorkspaceCleanupDialogLifecycle, @@ -113,7 +110,9 @@ function WorkspaceCleanupDialogContent({ ) const candidates = useMemo(() => scan?.candidates ?? [], [scan?.candidates]) - const gitEvidenceNeeded = open && needsWorkspaceCleanupGitEvidence(browse.filters, browse.sort) + // The browse view promises Git facts for every row. The broad scan defers + // some reads for performance, so fill those gaps while the modal is open. + const gitEvidenceNeeded = open const gitEvidence = useWorkspaceCleanupGitEvidence({ enabled: gitEvidenceNeeded, candidates, diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.ts index 9aa9d53a6d7..ffd41665523 100644 --- a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.ts +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.ts @@ -23,9 +23,9 @@ const EMPTY_EVIDENCE: WorkspaceCleanupGitEvidenceState = { } /** - * Fills in git evidence the broad scan deferred with one targeted batch scan. - * Only runs while a git-dependent filter or sort is active — the initial list - * render never waits on it, so an unfiltered browse stays instant. + * Fills in git evidence the broad scan deferred with targeted batch scans. + * The initial list render never waits on these requests, so the browse stays + * responsive while each row transitions from an unknown status to its result. */ export function useWorkspaceCleanupGitEvidence({ enabled, From 3c79779ae0a884b8459245969230bbd8b3f439d3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:00:49 +0000 Subject: [PATCH 035/152] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 0f935f89aca..d604b83bf97 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 29m + + downloads: 30m @@ -15,7 +15,7 @@ downloads downloads - 29m - 29m + 30m + 30m From 80246238fcc73ee2e9365c18b9830b7b7fbf9280 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:17:31 -0700 Subject: [PATCH 036/152] fix(mobile): reset recycled paragraph layout before reuse (#16692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mobile): quantize chat pinch font scale so a zoom stops re-measuring the list every frame A user bubble on a 390pt iPhone painted five lines inside a frame that reserved six, with the last painted line cut through a glyph at the content edge and "no longer needed." gone. The paint is React Native's: a `` with no `numberOfLines` gets a text container whose `lineBreakMode` is `NSLineBreakByClipping` (RCTTextLayoutManager.mm). Measure lays out into `{width, CGFLOAT_MAX}`, paint lays out into the mounted content frame — so a frame one line short does not re-wrap, it dumps the remainder onto the last fitting line and clips it, with no ellipsis. Reproduced on-device against the real component with the message text held constant, so five painted lines can only be truncation. Two conditions are each necessary, and removing either makes it vanish over ~6000 measured bubble renders: a pooled `RCTParagraphComponentView` carrying a shorter row's content frame (`prepareForRecycle` clears `state` but not `_textView.layoutMetrics`), and whole-list re-measure churn while rows enter and leave that pool. The churn was ours. `renderItem` closes over `fontScale`, and the pinch handler committed a new scale on every gesture frame, so one zoom drove hundreds of full-list re-measures. The pinch is composed `Simultaneous` with the list's own scroll, so a stray second finger during a scroll started that storm at scales the user cannot see — matching the report, whose glyph metrics are `fontScale` 1.0 exactly. `quantizeFontScale` snaps commits to a 5% grid. React bails out of a same-value `setState`, so gesture noise now commits nothing and a full-range pinch commits at most ~20 times. Under the churn that produced 67 defects in 6636 bubble renders, the quantized build measured 0 in 6064 — with a forced-defect control bubble flagged in 100% of frames of both runs to prove the detector was live. This removes the trigger we own; it does not close the RN recycling window itself. That needs a one-line reset in `prepareForRecycle`, which cannot land here without refreshing the `patchedDependencies` hash under `mobile/`. * fix(mobile): reset recycled paragraph layout before reuse --- mobile/patches/react-native@0.83.9.patch | 12 +++ mobile/pnpm-lock.yaml | 2 +- .../mobile-native-chat-message-text.ts | 25 +++++ ...e-mobile-native-chat-pinch-gesture.test.ts | 91 +++++++++++++++++++ .../use-mobile-native-chat-pinch-gesture.ts | 8 +- 5 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 mobile/src/session/use-mobile-native-chat-pinch-gesture.test.ts diff --git a/mobile/patches/react-native@0.83.9.patch b/mobile/patches/react-native@0.83.9.patch index 102b052746b..9bda1b252fa 100644 --- a/mobile/patches/react-native@0.83.9.patch +++ b/mobile/patches/react-native@0.83.9.patch @@ -46,3 +46,15 @@ index a3f2e01c130c1a53aee0b39e72927d21f2a6973d..c9d34f8b42644800b5c85e5be45b6ebf AttributedString::Range selectionRange; // ScrollView-like metrics Size contentSize; +diff --git a/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm b/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +index 79ee7ffe43..956f189a1c 100644 +--- a/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm ++++ b/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +@@ -136,6 +136,7 @@ using namespace facebook::react; + - (void)prepareForRecycle + { + [super prepareForRecycle]; + _textView.state = nullptr; ++ _textView.layoutMetrics = EmptyLayoutMetrics; + _accessibilityProvider = nil; + } diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 56e64e1d442..01092f2acc9 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -9,7 +9,7 @@ overrides: patchedDependencies: react-native@0.83.9: - hash: a6502e7d63769bf1fd639cbbaab1cc0b8aebef31d22048e8f2186fb399d0c16e + hash: 44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d path: patches/react-native@0.83.9.patch importers: diff --git a/mobile/src/session/mobile-native-chat-message-text.ts b/mobile/src/session/mobile-native-chat-message-text.ts index 227488a56df..a94104835a9 100644 --- a/mobile/src/session/mobile-native-chat-message-text.ts +++ b/mobile/src/session/mobile-native-chat-message-text.ts @@ -21,3 +21,28 @@ export function clampFontScale(scale: number): number { } return Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, scale)) } + +/** Granularity a pinch is allowed to commit at. */ +export const FONT_SCALE_STEP = 0.05 + +/** Snap a proposed font scale to the nearest committed step. + * + * Why quantize: the chat's `renderItem` closes over `fontScale`, so every + * distinct value re-renders and re-measures every message in the list. A raw + * pinch proposes a new scale on each gesture frame, so a single zoom drives + * hundreds of whole-list re-measures — and because the pinch is composed + * `Simultaneous` with the list's own scroll, an incidental second finger during + * a scroll starts that storm at scales indistinguishable from 1. Those + * re-measures run while rows are mounting and unmounting, which is when a + * recycled iOS paragraph view can repaint with the previous row's content + * frame and silently drop the tail of a long message. Snapping to steps keeps a + * full-range pinch to at most 20 commits and makes a near-neutral pinch commit + * nothing at all. */ +export function quantizeFontScale(scale: number): number { + if (Number.isNaN(scale)) { + return 1 + } + const stepped = Math.round(clampFontScale(scale) / FONT_SCALE_STEP) * FONT_SCALE_STEP + // Re-clamp: rounding can push the outermost step past the bound. + return clampFontScale(Number(stepped.toFixed(2))) +} diff --git a/mobile/src/session/use-mobile-native-chat-pinch-gesture.test.ts b/mobile/src/session/use-mobile-native-chat-pinch-gesture.test.ts new file mode 100644 index 00000000000..8f34b6da9d5 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-pinch-gesture.test.ts @@ -0,0 +1,91 @@ +import { createElement } from 'react' +import { act, create } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { FONT_SCALE_STEP, quantizeFontScale } from './mobile-native-chat-message-text' + +const handlers: { start?: () => void; update?: (e: { scale: number }) => void } = {} + +vi.mock('react-native-gesture-handler', () => { + const pinch = { + runOnJS: () => pinch, + onStart: (cb: () => void) => { + handlers.start = cb + return pinch + }, + onUpdate: (cb: (e: { scale: number }) => void) => { + handlers.update = cb + return pinch + } + } + return { + Gesture: { Simultaneous: (...g: unknown[]) => g, Native: () => ({}), Pinch: () => pinch } + } +}) + +import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture' + +describe('quantizeFontScale', () => { + it('snaps to the commit step and stays inside the supported range', () => { + expect(quantizeFontScale(1)).toBe(1) + expect(quantizeFontScale(1.01)).toBe(1) + expect(quantizeFontScale(1.024)).toBe(1) + expect(quantizeFontScale(1.03)).toBe(1.05) + expect(quantizeFontScale(5)).toBe(1.8) + expect(quantizeFontScale(0.1)).toBe(0.8) + expect(quantizeFontScale(Number.NaN)).toBe(1) + }) + + it('collapses a full-range pinch to at most one commit per step', () => { + const committed = new Set() + for (let i = 0; i <= 600; i++) { + committed.add(quantizeFontScale(0.7 + i * 0.002)) + } + expect(committed.size).toBeLessThanOrEqual(Math.round(1 / FONT_SCALE_STEP) + 1) + }) +}) + +describe('useMobileNativeChatPinchGesture', () => { + let renderer: { unmount: () => void } | null = null + let latest = 1 + + function Probe(): null { + latest = useMobileNativeChatPinchGesture().fontScale + return null + } + + beforeEach(() => { + latest = 1 + act(() => { + renderer = create(createElement(Probe)) + }) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + // Why this matters: `renderItem` closes over `fontScale`, so each committed + // value re-measures every message in the list. Committing raw gesture scales + // drives hundreds of whole-list re-measures per pinch, and a stray second + // finger during an ordinary scroll starts that storm at scales the user cannot + // even see. Those re-measures are what expose the recycled-paragraph repaint + // that silently drops the tail of a long message. + it('commits nothing for gesture noise the user cannot see', () => { + act(() => handlers.start?.()) + for (const scale of [1.001, 0.995, 1.008, 1.02, 0.982]) { + act(() => handlers.update?.({ scale })) + } + expect(latest).toBe(1) + }) + + it('commits a real pinch, on the step grid', () => { + act(() => handlers.start?.()) + act(() => handlers.update?.({ scale: 1.34 })) + expect(latest).toBe(1.35) + act(() => handlers.update?.({ scale: 1.37 })) + expect(latest).toBe(1.35) + act(() => handlers.update?.({ scale: 1.42 })) + expect(latest).toBe(1.4) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-pinch-gesture.ts b/mobile/src/session/use-mobile-native-chat-pinch-gesture.ts index 3b3cdb2e8a7..1aebada4f4d 100644 --- a/mobile/src/session/use-mobile-native-chat-pinch-gesture.ts +++ b/mobile/src/session/use-mobile-native-chat-pinch-gesture.ts @@ -1,7 +1,7 @@ import { useMemo, useRef, useState } from 'react' import { Gesture } from 'react-native-gesture-handler' import type { ComposedGesture } from 'react-native-gesture-handler' -import { clampFontScale } from './mobile-native-chat-message-text' +import { quantizeFontScale } from './mobile-native-chat-message-text' /** Pinch-to-zoom chat font. `fontScale` is the committed size; `pinchBase` * anchors the live gesture so successive pinches compound rather than reset. */ @@ -14,8 +14,8 @@ export function useMobileNativeChatPinchGesture(): { fontScaleRef.current = fontScale const pinchBase = useRef(1) // Why: run the gesture callbacks on the JS thread (not a reanimated worklet) so - // they can touch React refs/state and clampFontScale directly — accessing those - // from the UI-thread worklet crashes the app. + // they can touch React refs/state and quantizeFontScale directly — accessing + // those from the UI-thread worklet crashes the app. // Compose the pinch with the list's native scroll as Simultaneous so a // two-finger pinch is recognized even while the scroll view is active — // otherwise the scroll grabs the gesture first and the pinch never fires. @@ -29,7 +29,7 @@ export function useMobileNativeChatPinchGesture(): { pinchBase.current = fontScaleRef.current }) .onUpdate((e) => { - setFontScale(clampFontScale(pinchBase.current * e.scale)) + setFontScale(quantizeFontScale(pinchBase.current * e.scale)) }) ), [] From d60a3c900b0cfe63611a6f0a2ab0e41cc438939d Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:49:26 -0700 Subject: [PATCH 037/152] Reset stale terminal modes after dead TUI replay (#16379) --- config/reliability-gates.jsonc | 123 ++++++ src/main/daemon/daemon-checkpoint-file.ts | 2 + ...mon-durable-history-ownership-seed.test.ts | 41 ++ .../daemon/daemon-durable-history-snapshot.ts | 34 ++ .../daemon-foreground-process-protocol.ts | 4 + .../daemon/daemon-pty-adapter-test-harness.ts | 2 + src/main/daemon/daemon-pty-adapter.test.ts | 31 ++ .../daemon/daemon-pty-buffer-snapshots.ts | 3 +- .../daemon/daemon-pty-process-inspection.ts | 11 + src/main/daemon/daemon-pty-router.ts | 4 + src/main/daemon/daemon-pty-spawn-result.ts | 3 + src/main/daemon/daemon-request-router.ts | 8 +- .../daemon-restore-scrollback-depth.test.ts | 33 ++ .../daemon/degraded-daemon-pty-provider.ts | 3 + .../foreground-process-tracker.ts | 6 +- .../pty-shell-foreground-confirmation.ts | 24 ++ .../pty-subprocess/subprocess-handle.ts | 1 + src/main/daemon/session-output-pipeline.ts | 33 ++ src/main/daemon/session-output-plane.ts | 39 +- .../daemon/session-shell-recovery.test.ts | 156 +++++++ src/main/daemon/session-subprocess-handle.ts | 2 + src/main/daemon/session.test.ts | 50 +++ src/main/daemon/session.ts | 68 +-- .../terminal-checkpoint-serializer.test.ts | 19 + .../daemon/terminal-checkpoint-serializer.ts | 8 +- .../terminal-history-checkpoint-reader.ts | 5 +- .../terminal-history-cold-restore-info.ts | 6 +- .../terminal-host-concurrent-create.test.ts | 50 ++- .../daemon/terminal-host-session-create.ts | 6 + src/main/daemon/terminal-host.ts | 26 ++ .../terminal-shell-clean-exit-confirmation.ts | 100 +++++ .../terminal-shell-lifecycle-scanner.test.ts | 285 ++++++++++++ .../terminal-shell-lifecycle-scanner.ts | 223 ++++++++++ .../terminal-shell-recovery-barrier.test.ts | 405 ++++++++++++++++++ .../daemon/terminal-shell-recovery-barrier.ts | 338 +++++++++++++++ ...ell-recovery-clean-exit-retirement.test.ts | 72 ++++ src/main/daemon/terminal-snapshot.ts | 3 + src/main/daemon/types.ts | 3 + src/main/ipc/pty/ipc/snapshot.ts | 1 + src/main/ipc/pty/ipc/spawn-commit.ts | 14 +- src/main/ipc/pty/runtime/controller.ts | 2 + src/main/ipc/pty/runtime/operations.ts | 8 + ...ound-process-windows-job-rejection.test.ts | 21 + .../agent-foreground-process.test.ts | 101 +++++ .../providers/agent-foreground-process.ts | 63 +++ src/main/providers/local-pty-provider.ts | 20 +- src/main/providers/pty-provider-contract.ts | 5 + src/main/providers/pty-spawn-result.ts | 3 + src/main/providers/ssh-pty-provider.test.ts | 7 + ...bile-session-tabs-churn-coalescing.test.ts | 179 ++++++++ .../mobile-subscribe-integration.test.ts | 108 +---- src/main/runtime/orca-runtime.test.ts | 48 ++- src/main/runtime/orca-runtime.ts | 54 ++- .../pty-shell-ownership-mirror.test.ts | 88 ++++ .../runtime/pty-shell-ownership-mirror.ts | 149 +++++++ .../terminal-legacy-subscribe-snapshot.ts | 2 + .../terminal-multiplex-flow-control.ts | 2 + .../terminal-multiplex-initial-snapshot.ts | 2 + .../terminal-multiplex-slot-frames.ts | 2 + .../terminal/terminal-snapshot-publication.ts | 11 + .../methods/terminal/terminal-stream-types.ts | 4 + .../terminal-multiplex-escape-tail.test.ts | 9 +- .../rpc/terminal-subscribe-buffer.test.ts | 8 +- src/preload/api/pty-api.ts | 2 + src/preload/index.ts | 2 + .../terminal-pane/ipc-pty-connect-result.ts | 3 + ...-connection-daemon-snapshot-replay.test.ts | 37 ++ ...ty-connection-hidden-codex-queries.test.ts | 185 +++++++- ...pty-connection-reattach-mode-reset.test.ts | 40 ++ .../pty-connection/apply-reattach-payload.ts | 6 +- .../deferred-cold-restore-and-snapshot.ts | 4 +- .../fresh-spawn-follow-reset.ts | 9 +- .../hidden-output-restore-snapshot.ts | 23 +- .../pty-connection/replay-data-drain.ts | 7 +- .../terminal-pane/pty-output-processor.ts | 8 +- .../terminal-pane/pty-transport-types.ts | 4 + ...e-runtime-pty-snapshot-escape-tail.test.ts | 10 +- .../remote-runtime-pty-transport.ts | 6 + .../terminal-replay-cursor-state.test.ts | 30 ++ .../remote-runtime-terminal-multiplexer.ts | 23 +- src/shared/terminal-mode-reset-profiles.ts | 4 + src/shared/terminal-owner.ts | 5 + .../cross-version-terminal-wire.unit.test.ts | 14 + .../host-terminal-runtime-stub.ts | 12 +- ...l-hidden-child-tui-kill-mode-reset.spec.ts | 220 ++++++++++ 85 files changed, 3612 insertions(+), 183 deletions(-) create mode 100644 src/main/daemon/daemon-durable-history-ownership-seed.test.ts create mode 100644 src/main/daemon/pty-subprocess/pty-shell-foreground-confirmation.ts create mode 100644 src/main/daemon/session-output-pipeline.ts create mode 100644 src/main/daemon/session-shell-recovery.test.ts create mode 100644 src/main/daemon/terminal-shell-clean-exit-confirmation.ts create mode 100644 src/main/daemon/terminal-shell-lifecycle-scanner.test.ts create mode 100644 src/main/daemon/terminal-shell-lifecycle-scanner.ts create mode 100644 src/main/daemon/terminal-shell-recovery-barrier.test.ts create mode 100644 src/main/daemon/terminal-shell-recovery-barrier.ts create mode 100644 src/main/daemon/terminal-shell-recovery-clean-exit-retirement.test.ts create mode 100644 src/main/providers/agent-foreground-process-windows-job-rejection.test.ts create mode 100644 src/main/runtime/mobile-session-tabs-churn-coalescing.test.ts create mode 100644 src/main/runtime/pty-shell-ownership-mirror.test.ts create mode 100644 src/main/runtime/pty-shell-ownership-mirror.ts create mode 100644 src/shared/terminal-owner.ts create mode 100644 tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 0826c5dea46..8a6dc2b0d96 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -5063,6 +5063,129 @@ ], "demotionRule": "Demote or quarantine if the gate flakes once without a product bug or harness bug filed to the owner." }, + { + "id": "terminal-session.shell-owned-mode-recovery", + "title": "Dead TUI mode recovery requires execution-host shell ownership", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "host-renderer-electron", + "surfaces": [ + "alternate-screen lifecycle", + "mouse input routing", + "hidden-output snapshot restore", + "daemon reattach", + "remote-runtime replay", + "durable terminal history" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "remote-runtime", "wsl"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "daemon", "remote-runtime"], + "coverageNotes": "Deterministic host/parser, process-classifier, snapshot-publication, mixed-version, renderer, and macOS Electron coverage proves the local and shared paired-runtime contracts. Direct SSH intentionally publishes no shell proof and immediately preserves incumbent behavior. WSL, Windows ConPTY, Linux desktop, and paired headed/headless journeys remain gaps.", + "motivatingLinks": ["https://github.com/stablyai/orca/pull/16379"], + "invariant": "After a TUI exits or is killed, reveal, reattach, snapshot replay, or renderer remount must not deliver terminal-owned mouse or alternate-screen protocol bytes to the surviving shell. Recovery is an ordered output barrier in the daemon session data path: an OSC 133;D completing while the alternate screen is still active pauses the stream at that exact byte boundary, a fresh execution-host process inspection proves shell ownership, and on proof a mode reset is injected as in-stream output so every consumer converges by parsing the same bytes and the queued post-boundary shell output (the prompt) lands on the normal buffer. Snapshots are pure reads. Any failure — refuted proof, timeout, queue overflow, session death, disposal — flushes the queue unmodified, preserving incumbent behavior; later command or mode bytes revoke proof. Clean alternate-screen exits prove ownership asynchronously without pausing.", + "oracle": "Run one fixed child-TUI journey for normal exit and cleanup-free SIGKILL. Assert renderer and host normal-buffer/non-mouse state, host snapshot terminalOwner metadata, exact PTY writes with no post-exit mouse report, unrelated-pane survival, post-boundary prompt output preserved (normal exit), ordered proof invalidation, bounded settlement and bail-out flush, one inspection per unclean episode with zero scans for ordinary output, split-escape safety at every chunk boundary, and old/new client-host fallback parity.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/terminal-shell-lifecycle-scanner.test.ts src/main/daemon/terminal-shell-recovery-barrier.test.ts src/main/daemon/session-shell-recovery.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host-concurrent-create.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-restore-scrollback-depth.test.ts src/main/daemon/terminal-checkpoint-serializer.test.ts src/main/providers/agent-foreground-process.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/mobile-subscribe-integration.test.ts src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts --reporter=dot", + "pnpm exec electron-vite build --mode e2e", + "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + ], + "testFiles": [ + "src/main/daemon/terminal-shell-lifecycle-scanner.test.ts", + "src/main/daemon/terminal-shell-recovery-barrier.test.ts", + "src/main/daemon/session-shell-recovery.test.ts", + "src/main/daemon/session.test.ts", + "src/main/daemon/terminal-host-concurrent-create.test.ts", + "src/main/daemon/daemon-pty-adapter.test.ts", + "src/main/daemon/daemon-restore-scrollback-depth.test.ts", + "src/main/daemon/terminal-checkpoint-serializer.test.ts", + "src/main/providers/agent-foreground-process.test.ts", + "src/main/runtime/orca-runtime.test.ts", + "src/main/runtime/mobile-subscribe-integration.test.ts", + "src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts", + "src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts", + "tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts", + "tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/main/daemon/terminal-shell-recovery-barrier.test.ts", + "assertions": [ + "an unclean-death boundary pauses the stream, injects the reset in order, and delivers the queued prompt after it", + "refuted proof, timeout, overflow, death, and disposal all flush the queue unmodified with no injection", + "clean alternate-screen exits prove ownership without pausing and later command starts revoke proof", + "a second unclean episode queued behind the first resolves in order", + "split triggers across emissions split the completing emission at the exact sequence boundary" + ] + }, + { + "file": "src/main/daemon/session-shell-recovery.test.ts", + "assertions": [ + "post-kill shell output survives into the normalized snapshot instead of the discarded alt buffer", + "a snapshot taken during a split escape keeps the pending tail intact and stale proof is revoked by the completing bytes" + ] + }, + { + "file": "tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts", + "assertions": [ + "normal exit and cleanup-free SIGKILL both restore a visible clean shell marker", + "renderer logical buffer is normal and mouse tracking is disabled after host-proven recovery", + "real pointer input writes no SGR mouse report to the surviving shell", + "an unrelated pane remains live" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 33.8, + "summary": "Both normal-exit and SIGKILL journeys passed on the ordered-output-barrier implementation; each asserted renderer and host normal modes, host ownership metadata, exact PTY input, no post-exit SGR mouse report under real pointer input, and unrelated-pane survival, with post-boundary prompt output additionally proven visible on normal exit." + } + ], + "runtimeBudget": { + "p95Seconds": 90, + "scope": "focused host/renderer contracts, mixed-version journey, and two-case Electron run" + }, + "flakeHistory": { + "status": "not-started", + "evidence": "The deterministic focused suites and two-case Electron journey pass locally; CI soak history is not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The byte-identical Electron journey reproduced screenshot-like numeric mouse-report residue and a real SGR mouse packet reaching the shell before the focused action. With only the renderer's host-proven reset disabled, normal cleanup failed in normal-buffer/any-event mouse state and SIGKILL failed in alternate-buffer/any-event mouse state. Restoring the action made both cases green." + }, + "performanceBudget": { + "required": true, + "evidence": "No reveal timer, polling, periodic scan, broad session inventory, or renderer remount is added, and snapshots perform no writes. The stream pauses only for an unclean alternate-screen death (never for clean TUI exits or ordinary output), bounded by a 750 ms bail timer and a 256 KiB queue cap that both flush unmodified. The event-triggered fresh host inspection measured 30 local macOS samples at 84.6 ms median, 103.7 ms p95, and 124.5 ms maximum; the paired runtime asks the daemon for its settled verdict instead of running a second inspection." + }, + "promotionCriteria": [ + "Collect 100 consecutive focused CI passes or 14 days of soak history.", + "Run the real journey on Linux and Windows, including ConPTY and WSL.", + "Add headed and headless paired-runtime journey evidence.", + "Keep missing-proof mixed-version behavior identical to the incumbent client path." + ], + "knownGaps": [ + "A zero-width injected reset does not advance outputSequence, so the mobile renderer-snapshot stability oracle cannot detect a reset landing inside its serialize round-trip; the resulting tear is transient and self-heals on the next repaint (same exposure as the pre-existing device-attributes release emission).", + "Bytes the dying command printed before its OSC 133;D (pre-boundary output into the still-active alternate screen, e.g. a final panic line) are discarded with the dead frame; only post-boundary shell output is preserved.", + "Direct local provider and degraded (daemon-less) sessions get ownership proof and the renderer-side reset, but no in-stream normalization — pre-reveal prompt output is not preserved there.", + "Visible Electron evidence is macOS-only.", + "Direct SSH intentionally fails closed to current-main behavior because foreground proof would inherit transport timeout and enumeration ambiguity.", + "WSL and Windows ConPTY process-membership behavior has no physical-host proof yet.", + "Any stopped (Ctrl-Z) descendant in the shell's tree refutes the proof, so a suspended unrelated job silently disables recovery for that pane (fails closed to incumbent behavior).", + "Attach is deliberately settle-free: a viewer connecting inside an in-flight proof (~100ms typical) receives the pre-reset snapshot as its first frame and self-heals when the in-stream reset arrives; snapshot RPC and remote serialize paths still inherit a bounded (750ms) settle wait while a proof is in flight.", + "Paired headed and headless remote-runtime topologies have not yet run the full pointer-input oracle." + ], + "demotionRule": "Keep experimental or demote if missing ownership proof changes incumbent behavior, a live TUI loses mouse/focus modes, foreground inspection enters ordinary output/reveal paths, or the focused Electron journey flakes without an identified product or harness fault." + }, { "id": "terminal-session.layout-pty-ownership", "title": "Restored terminal layouts retain one renderer owner per PTY", diff --git a/src/main/daemon/daemon-checkpoint-file.ts b/src/main/daemon/daemon-checkpoint-file.ts index 8031996a54c..a1dd53fc898 100644 --- a/src/main/daemon/daemon-checkpoint-file.ts +++ b/src/main/daemon/daemon-checkpoint-file.ts @@ -1,5 +1,6 @@ import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' import type { TerminalModes } from './types' +import type { TerminalOwner } from '../../shared/terminal-owner' /** On-disk shape of checkpoint.json. Written by history-manager, read by * history-reader — one type so the generation pairing with output.log's @@ -17,6 +18,7 @@ export type TerminalCheckpointFile = { modes: TerminalModes scrollbackLines: number lastTitle?: string + terminalOwner?: TerminalOwner /** Ties this checkpoint to the output.log whose header carries the same * generation. Absent on checkpoints written before incremental logs. */ generation?: number diff --git a/src/main/daemon/daemon-durable-history-ownership-seed.test.ts b/src/main/daemon/daemon-durable-history-ownership-seed.test.ts new file mode 100644 index 00000000000..0e5fc0929ae --- /dev/null +++ b/src/main/daemon/daemon-durable-history-ownership-seed.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { buildDurableCheckpointSnapshot } from './daemon-durable-history-snapshot' +import type { TerminalSnapshot } from './types' + +const liveSnapshot = { + snapshotAnsi: 'prompt', + scrollbackAnsi: '', + rehydrateSequences: '', + cwd: '/tmp', + cols: 80, + rows: 24, + scrollbackLines: 0, + modes: { + bracketedPaste: false, + alternateScreen: false, + applicationCursor: false, + mouseTracking: false + }, + terminalOwner: 'shell', + outputSequence: 7 +} as TerminalSnapshot + +describe('durable checkpoint ownership seeding without disk history', () => { + it('carries the live proof through a rebuild over benign pending records', async () => { + const durable = await buildDurableCheckpointSnapshot({ + liveSnapshot, + restoreInfo: null, + pendingRecords: [{ kind: 'output', data: 'plain shell prompt\r\n' }] + }) + expect(durable.terminalOwner).toBe('shell') + }) + + it('revokes the live proof when pending records re-enter a TUI', async () => { + const durable = await buildDurableCheckpointSnapshot({ + liveSnapshot, + restoreInfo: null, + pendingRecords: [{ kind: 'output', data: '\x1b[?1049hLIVE-TUI' }] + }) + expect(durable.terminalOwner).toBeUndefined() + }) +}) diff --git a/src/main/daemon/daemon-durable-history-snapshot.ts b/src/main/daemon/daemon-durable-history-snapshot.ts index c8d71736607..bc99fb566ce 100644 --- a/src/main/daemon/daemon-durable-history-snapshot.ts +++ b/src/main/daemon/daemon-durable-history-snapshot.ts @@ -1,6 +1,7 @@ import { ColdRestoreReplayWriter } from './cold-restore-replay-writer' import { DAEMON_RESTORE_SCROLLBACK_ROWS } from './daemon-restore-scrollback-depth' import { HeadlessEmulator } from './headless-emulator' +import { TerminalShellLifecycleScanner } from './terminal-shell-lifecycle-scanner' import { isValidTerminalHistorySize } from './terminal-history-dimensions' import type { ColdRestoreInfo } from './terminal-history-cold-restore-info' import type { PendingOutputRecord, TerminalSnapshot } from './types' @@ -12,6 +13,7 @@ type RestoreBase = { pendingEscapeTailAnsi?: string oscLinks?: TerminalSnapshot['oscLinks'] lastTitle?: string + terminalOwner?: TerminalSnapshot['terminalOwner'] cwd: string | null cols: number rows: number @@ -35,6 +37,7 @@ export function terminalSnapshotFromColdRestore( scrollbackLines: info.scrollbackLines ?? Math.max(0, countAnsiRows(info.scrollbackAnsi) - info.rows), ...(info.lastTitle ? { lastTitle: info.lastTitle } : {}), + ...(info.terminalOwner ? { terminalOwner: info.terminalOwner } : {}), ...(opts?.outputSequence !== undefined ? { outputSequence: opts.outputSequence } : {}) } } @@ -91,12 +94,29 @@ export async function buildDurableCheckpointSnapshot(opts: { } emulator.setCwd(base.cwd) } + // Why a scanner, not emulator state: ownership is lifecycle evidence bound + // to byte order — newer replayed output (a TUI starting) must revoke a + // persisted proof exactly as it would have live. + const ownershipScanner = new TerminalShellLifecycleScanner() + // Why the live fallback: with no disk history the live snapshot holds the + // only persisted proof for this boundary; the re-scan below can revoke it + // but never mint one, so the result is never less safe than dropping it. + ownershipScanner.seedOwner( + opts.restoreInfo ? opts.restoreInfo.terminalOwner : opts.liveSnapshot.terminalOwner + ) if (!(await replayPendingRecords(replay, pendingRecords))) { return opts.liveSnapshot } + for (const record of pendingRecords) { + if (record.kind === 'output') { + scanAllForOwnership(ownershipScanner, record.data) + } + } const snapshot = emulator.getSnapshot() + const terminalOwner = ownershipScanner.owner return { ...snapshot, + ...(terminalOwner ? { terminalOwner } : {}), ...(opts.liveSnapshot.outputSequence !== undefined ? { outputSequence: opts.liveSnapshot.outputSequence } : {}), @@ -122,12 +142,26 @@ function restoreBaseFrom(restoreInfo: ColdRestoreInfo): RestoreBase { : {}), oscLinks: restoreInfo.oscLinks, lastTitle: restoreInfo.lastTitle, + terminalOwner: restoreInfo.terminalOwner, cwd: restoreInfo.cwd, cols: restoreInfo.cols, rows: restoreInfo.rows } } +/** Replay can only carry or revoke a persisted proof, never mint one: the + * scanner has no confirmation source, so triggers are consumed as revocations. */ +function scanAllForOwnership(scanner: TerminalShellLifecycleScanner, data: string): void { + let rest = data + while (rest.length > 0) { + const events = scanner.scan(rest) + if (events.uncleanDeathTriggerEnd === undefined) { + return + } + rest = rest.slice(events.uncleanDeathTriggerEnd) + } +} + async function replayPendingRecords( replay: ColdRestoreReplayWriter, records: readonly PendingOutputRecord[] diff --git a/src/main/daemon/daemon-foreground-process-protocol.ts b/src/main/daemon/daemon-foreground-process-protocol.ts index c0d672ec159..a16d6464e88 100644 --- a/src/main/daemon/daemon-foreground-process-protocol.ts +++ b/src/main/daemon/daemon-foreground-process-protocol.ts @@ -10,6 +10,10 @@ export type ConfirmForegroundProcessRequest = Omit & { + type: 'confirmShellForeground' +} + export type InspectProcessRequest = Omit & { type: 'inspectProcess' } diff --git a/src/main/daemon/daemon-pty-adapter-test-harness.ts b/src/main/daemon/daemon-pty-adapter-test-harness.ts index 0e89847e1e5..f7676bbec2f 100644 --- a/src/main/daemon/daemon-pty-adapter-test-harness.ts +++ b/src/main/daemon/daemon-pty-adapter-test-harness.ts @@ -29,6 +29,7 @@ export function createMockSubprocess(dataOnSubscribe?: string): SubprocessHandle write: ReturnType void>> pause: ReturnType void>> resume: ReturnType void>> + confirmShellForeground: ReturnType Promise>> _simulateData: (data: string) => void _simulateExit: (code: number) => void } { @@ -38,6 +39,7 @@ export function createMockSubprocess(dataOnSubscribe?: string): SubprocessHandle // Why: getCwd falls back to OS pid lookup; an implausibly-high fake pid can't collide with a real process' cwd. pid: 999_999_999, getForegroundProcess: vi.fn(() => null), + confirmShellForeground: vi.fn(async () => false), write: vi.fn(), resize: vi.fn(), pause: vi.fn<() => void>(), diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index ef5fa687586..387036dd6c3 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -496,6 +496,37 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) describe('getBufferSnapshot', () => { + it('publishes shell ownership only after the daemon proves the live PTY tree', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + lastSubprocess.confirmShellForeground.mockResolvedValue(true) + + lastSubprocess._simulateData( + '\x1b[?1049h\x1b[?1003h\x1b[?1006hTUI\x1b]133;D;137\x07shell-marker' + ) + + await vi.waitFor(async () => { + await expect(adapter.getBufferSnapshot(id)).resolves.toMatchObject({ + alternateScreen: false, + terminalOwner: 'shell' + }) + }) + await expect(adapter.confirmShellForeground(id)).resolves.toBe(true) + expect(lastSubprocess.confirmShellForeground).toHaveBeenCalledTimes(1) + }) + + it('preserves live TUI modes when the daemon cannot prove shell ownership', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + + lastSubprocess._simulateData( + '\x1b[?1049h\x1b[?1003h\x1b[?1006hLIVE-TUI\x1b]133;D;0\x07nested-shell' + ) + + await vi.waitFor(() => expect(lastSubprocess.confirmShellForeground).toHaveBeenCalledTimes(1)) + const snapshot = await adapter.getBufferSnapshot(id) + expect(snapshot?.alternateScreen).toBe(true) + expect(snapshot?.terminalOwner).toBeUndefined() + }) + it('returns the daemon model with its absolute stream sequence', async () => { const { id } = await adapter.spawn({ cols: 80, rows: 24 }) lastSubprocess._simulateData('complete hidden output\r\n') diff --git a/src/main/daemon/daemon-pty-buffer-snapshots.ts b/src/main/daemon/daemon-pty-buffer-snapshots.ts index cf65d85fa61..9fc0d10711e 100644 --- a/src/main/daemon/daemon-pty-buffer-snapshots.ts +++ b/src/main/daemon/daemon-pty-buffer-snapshots.ts @@ -64,7 +64,8 @@ export abstract class DaemonPtyBufferSnapshots extends DaemonPtySessionControl { ...(kittyKeyboardFlags !== undefined ? { kittyKeyboardFlags } : {}), ...(snapshot.pendingEscapeTailAnsi ? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi } - : {}) + : {}), + ...(snapshot.terminalOwner ? { terminalOwner: snapshot.terminalOwner } : {}) } } diff --git a/src/main/daemon/daemon-pty-process-inspection.ts b/src/main/daemon/daemon-pty-process-inspection.ts index d1dd3ad4e36..bbc44568501 100644 --- a/src/main/daemon/daemon-pty-process-inspection.ts +++ b/src/main/daemon/daemon-pty-process-inspection.ts @@ -72,6 +72,17 @@ export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshot } } + async confirmShellForeground(id: string): Promise { + try { + const result = await this.client.request<{ confirmed: boolean }>('confirmShellForeground', { + sessionId: id + }) + return result.confirmed === true + } catch { + return false + } + } + async serialize(ids: string[]): Promise { const sessions: Record = {} for (const id of ids) { diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 0da991b6931..ab30878c9d8 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -185,6 +185,10 @@ export class DaemonPtyRouter implements IPtyProvider { return this.adapterFor(id).confirmForegroundProcess(id) } + async confirmShellForeground(id: string): Promise { + return (await this.adapterFor(id).confirmShellForeground?.(id)) ?? false + } + async serialize(ids: string[]): Promise { return this.current.serialize(ids) } diff --git a/src/main/daemon/daemon-pty-spawn-result.ts b/src/main/daemon/daemon-pty-spawn-result.ts index c40e8b6a9bd..f28750141f3 100644 --- a/src/main/daemon/daemon-pty-spawn-result.ts +++ b/src/main/daemon/daemon-pty-spawn-result.ts @@ -313,6 +313,9 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { ...(kittyKeyboardFlags !== undefined ? { snapshotKittyKeyboardFlags: kittyKeyboardFlags } : {}), + ...(reattachSnapshot.terminalOwner + ? { snapshotTerminalOwner: reattachSnapshot.terminalOwner } + : {}), isReattach: true, isAlternateScreen: isAltScreen, // Why: the snapshot ANSI has no title frame; carry lastTitle beside it so main can seed title records after a relaunch. diff --git a/src/main/daemon/daemon-request-router.ts b/src/main/daemon/daemon-request-router.ts index 826c73b4370..1d5b75f6657 100644 --- a/src/main/daemon/daemon-request-router.ts +++ b/src/main/daemon/daemon-request-router.ts @@ -113,6 +113,10 @@ export class DaemonRequestRouter { request.payload.sessionId ) } + case 'confirmShellForeground': + return { + confirmed: await this.options.host.confirmShellForeground(request.payload.sessionId) + } case 'clearScrollback': this.options.host.clearScrollback(request.payload.sessionId) return {} @@ -213,13 +217,13 @@ export class DaemonRequestRouter { return { retiring } } - private getSnapshot(sessionId: string, requestedRows: unknown): unknown { + private async getSnapshot(sessionId: string, requestedRows: unknown): Promise { const startedAt = performance.now() const scrollbackRows = typeof requestedRows === 'number' && Number.isFinite(requestedRows) ? Math.max(0, Math.min(50_000, Math.floor(requestedRows))) : undefined - const snapshot = this.options.host.getSnapshot(sessionId, { scrollbackRows }) + const snapshot = await this.options.host.getSettledSnapshot(sessionId, { scrollbackRows }) const snapshotMs = performance.now() - startedAt if (snapshotMs >= 25) { recordDaemonStreamBacklogEvent('slowGetSnapshot', { diff --git a/src/main/daemon/daemon-restore-scrollback-depth.test.ts b/src/main/daemon/daemon-restore-scrollback-depth.test.ts index e6fe7ca35ff..b2c2c7c5028 100644 --- a/src/main/daemon/daemon-restore-scrollback-depth.test.ts +++ b/src/main/daemon/daemon-restore-scrollback-depth.test.ts @@ -15,6 +15,7 @@ import { HistoryManager } from './history-manager' import { HistoryReader } from './history-reader' import { TerminalHost } from './terminal-host' import type { DaemonFileLog } from './daemon-file-log' +import type { ColdRestoreInfo } from './terminal-history-cold-restore-info' import type { PendingOutputRecord, TerminalSnapshot } from './types' import type { SubprocessHandle } from './session-subprocess-handle' @@ -211,6 +212,38 @@ describe('STA-4091 previously recoverable restore depth', () => { } }) + it('revokes persisted shell ownership when newer output starts a TUI', async () => { + const restoreInfo: ColdRestoreInfo = { + snapshotAnsi: 'stale-tui', + scrollbackAnsi: '', + rehydrateSequences: '\x1b[?1049h', + cwd: '/tmp', + cols: 80, + rows: 24, + modes: { + bracketedPaste: false, + mouseTracking: true, + applicationCursor: false, + alternateScreen: true + }, + terminalOwner: 'shell' + } + const liveSnapshot = { + ...restoreInfo, + scrollbackLines: 0, + outputSequence: 42 + } as TerminalSnapshot + + const durable = await buildDurableCheckpointSnapshot({ + liveSnapshot, + restoreInfo, + pendingRecords: [{ kind: 'output', data: '\x1b[?1049hLIVE-TUI' }] + }) + + expect(durable.modes.alternateScreen).toBe(true) + expect(durable.terminalOwner).toBeUndefined() + }) + it('falls back to the live window when pending resize records are invalid', async () => { const liveSnapshot = { snapshotAnsi: 'live-only', diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index afd603c3943..f0e183bcb20 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -194,6 +194,9 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { async confirmForegroundProcess(id: string): Promise { return this.providerFor(id).confirmForegroundProcess?.(id) ?? null } + async confirmShellForeground(id: string): Promise { + return (await this.providerFor(id).confirmShellForeground?.(id)) ?? false + } async serialize(ids: string[]): Promise { return this.fallback.serialize(ids) diff --git a/src/main/daemon/pty-subprocess/foreground-process-tracker.ts b/src/main/daemon/pty-subprocess/foreground-process-tracker.ts index 3cf04164d28..115b795202d 100644 --- a/src/main/daemon/pty-subprocess/foreground-process-tracker.ts +++ b/src/main/daemon/pty-subprocess/foreground-process-tracker.ts @@ -1,6 +1,7 @@ import type * as pty from 'node-pty' import { getAgentForegroundContextPaths } from '../../providers/agent-foreground-context-paths' import { resolveAgentForegroundProcessWithAvailability } from '../../providers/agent-foreground-process' +import { confirmPtyShellForeground } from './pty-shell-foreground-confirmation' import { judgeCachedAgentJobEvidence, WINDOWS_DETACHED_DESCENDANT_IDENTITY_MAX_AGE_MS @@ -37,6 +38,7 @@ export type PtyForegroundProcessTracker = { markDead(): void getForegroundProcess(): string | null confirmForegroundProcess(): Promise + confirmShellForeground(): Promise } export function createPtyForegroundProcessTracker(args: { @@ -306,6 +308,8 @@ export function createPtyForegroundProcessTracker(args: { } catch { return null } - } + }, + confirmShellForeground: () => + confirmPtyShellForeground({ process: proc, shellPath: args.shellPath, isDead: args.isDead }) } } diff --git a/src/main/daemon/pty-subprocess/pty-shell-foreground-confirmation.ts b/src/main/daemon/pty-subprocess/pty-shell-foreground-confirmation.ts new file mode 100644 index 00000000000..2fdd75e3518 --- /dev/null +++ b/src/main/daemon/pty-subprocess/pty-shell-foreground-confirmation.ts @@ -0,0 +1,24 @@ +import type * as pty from 'node-pty' +import { confirmShellForegroundProcess } from '../../providers/agent-foreground-process' +import { readWindowsPtyJobProcessIds } from '../../providers/windows-pty-job-membership' + +/** Fresh execution-host proof that the spawned shell owns the PTY foreground: + * a post-request process inspection (POSIX `ps`, Windows job membership), + * never cached state. */ +export async function confirmPtyShellForeground(args: { + process: pty.IPty + shellPath: string + isDead: () => boolean +}): Promise { + if (args.isDead() || !args.process.pid) { + return false + } + const confirmed = await confirmShellForegroundProcess( + args.process.pid, + args.shellPath, + process.platform === 'win32' + ? { readWindowsPtyJobProcessIds: () => readWindowsPtyJobProcessIds(args.process) } + : {} + ) + return !args.isDead() && confirmed +} diff --git a/src/main/daemon/pty-subprocess/subprocess-handle.ts b/src/main/daemon/pty-subprocess/subprocess-handle.ts index a56d0fc3ae2..e2abd59c6ea 100644 --- a/src/main/daemon/pty-subprocess/subprocess-handle.ts +++ b/src/main/daemon/pty-subprocess/subprocess-handle.ts @@ -71,6 +71,7 @@ export function createDaemonPtySubprocessHandle(args: { : {}), getForegroundProcess: foreground.getForegroundProcess, confirmForegroundProcess: foreground.confirmForegroundProcess, + confirmShellForeground: foreground.confirmShellForeground, write: (data) => { if (dead) { return diff --git a/src/main/daemon/session-output-pipeline.ts b/src/main/daemon/session-output-pipeline.ts new file mode 100644 index 00000000000..c249e4d1d31 --- /dev/null +++ b/src/main/daemon/session-output-pipeline.ts @@ -0,0 +1,33 @@ +import { SessionOutputPlane } from './session-output-plane' +import { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier' +import type { SubprocessHandle } from './session-subprocess-handle' + +/** The session's ordered output pipeline: the recovery barrier feeding the + * output plane. Built together because the barrier's owner is what the + * plane's snapshots publish, and the plane's emit is the barrier's sink. */ +export function createSessionOutputPipeline(opts: { + cols: number + rows: number + scrollback?: number | undefined + wslDistro?: string | undefined + historySeedChunks?: readonly string[] | undefined + subprocess: SubprocessHandle + isAlive: () => boolean +}): { output: SessionOutputPlane; recoveryBarrier: TerminalShellRecoveryBarrier } { + let barrier: TerminalShellRecoveryBarrier | null = null + const output = new SessionOutputPlane({ + cols: opts.cols, + rows: opts.rows, + scrollback: opts.scrollback, + wslDistro: opts.wslDistro, + historySeedChunks: opts.historySeedChunks, + getTerminalOwner: () => barrier?.getOwner() + }) + const recoveryBarrier = new TerminalShellRecoveryBarrier({ + confirmShellForeground: async () => (await opts.subprocess.confirmShellForeground?.()) ?? false, + release: (emission) => output.emit(emission), + isAlive: opts.isAlive + }) + barrier = recoveryBarrier + return { output, recoveryBarrier } +} diff --git a/src/main/daemon/session-output-plane.ts b/src/main/daemon/session-output-plane.ts index 408e95545b8..507ca01a927 100644 --- a/src/main/daemon/session-output-plane.ts +++ b/src/main/daemon/session-output-plane.ts @@ -4,6 +4,9 @@ import { StartupDeviceAttributesQueryFilter } from './startup-device-attributes- import { normalizePtySize } from './daemon-pty-size' import type { PtyIngressEmission } from '../../shared/pty-startup-ingress' import type { PendingOutputRecord, TakePendingOutputResult, TerminalSnapshot } from './types' +import type { TerminalOwner } from '../../shared/terminal-owner' +import type { SubprocessHandle } from './session-subprocess-handle' +import { nudgePowerShellPromptRepaint } from './session-powershell-prompt-repaint' // Why: bounds in-memory pending output when no client drains it; past the cap we drop records and flag // overflow so the next take falls back to one full snapshot. UTF-16 units; worst-case wire is ~6x, under NDJSON_MAX_LINE_BYTES (16MB). @@ -21,6 +24,9 @@ export type SessionOutputPlaneOptions = { scrollback?: number | undefined wslDistro?: string | undefined historySeedChunks?: readonly string[] | undefined + /** Read from the recovery barrier at snapshot time; the barrier scans bytes + * before this plane receives them, so its owner never lags the emulator. */ + getTerminalOwner?: (() => TerminalOwner | undefined) | undefined } /** Everything downstream of the PTY: the scrollback emulator, the pending-output record buffer that @@ -28,6 +34,7 @@ export type SessionOutputPlaneOptions = { export class SessionOutputPlane { readonly historySeeded: boolean | undefined private readonly emulator: HeadlessEmulator + private readonly readTerminalOwner: (() => TerminalOwner | undefined) | undefined private attachedClients: AttachedClient[] = [] private pendingOutputRecords: PendingOutputRecord[] = [] private pendingOutputBytes = 0 @@ -56,6 +63,7 @@ export class SessionOutputPlane { opts.historySeedChunks === undefined ? undefined : opts.historySeedChunks.every((chunk) => this.emulator.writeSync(chunk)) + this.readTerminalOwner = opts.getTerminalOwner } get responderParser(): HeadlessEmulator['responderParser'] { @@ -103,9 +111,18 @@ export class SessionOutputPlane { this.record({ kind: 'resize', cols, rows }) } - clearScrollback(): void { + clearScrollback(subprocess: SubprocessHandle, isGatingWrites: boolean): void { + if (this.disposed) { + return + } this.emulator.clearScrollback() this.record({ kind: 'clear' }) + subprocess.clear?.() + nudgePowerShellPromptRepaint({ + subprocess, + isGatingWrites, + isCursorOnEmptyPromptLine: () => this.isCursorOnEmptyPromptLine() + }) } isCursorOnEmptyPromptLine(): boolean { @@ -116,11 +133,29 @@ export class SessionOutputPlane { return this.emulator.getCwd() } + getTerminalOwner(): TerminalOwner | undefined { + return this.readTerminalOwner?.() + } + + /** FIFO fence over the emulator's async parse queue: resolves once every + * write issued before this call has been parsed into the model. */ + flushParsedWrites(): Promise { + if (this.disposed) { + return Promise.resolve() + } + return this.emulator.write('') + } + getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null { if (this.disposed) { return null } - return { ...this.emulator.getSnapshot(opts), outputSequence: this._outputSequence } + const terminalOwner = this.readTerminalOwner?.() + return { + ...this.emulator.getSnapshot(opts), + ...(terminalOwner ? { terminalOwner } : {}), + outputSequence: this._outputSequence + } } getPartialEscapeTailAnsi(): string { diff --git a/src/main/daemon/session-shell-recovery.test.ts b/src/main/daemon/session-shell-recovery.test.ts new file mode 100644 index 00000000000..7a5ae13663c --- /dev/null +++ b/src/main/daemon/session-shell-recovery.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from 'vitest' +import { Session } from './session' +import type { SubprocessHandle } from './session-subprocess-handle' + +function createSubprocess() { + let onData: ((data: string) => void) | null = null + let onExit: ((code: number) => void) | null = null + let resolveConfirm: ((confirmed: boolean) => void) | undefined + const confirmShellForeground = vi.fn( + () => new Promise((resolve) => void (resolveConfirm = resolve)) + ) + const handle = { + pid: 999, + getForegroundProcess: () => null, + confirmShellForeground, + write: () => {}, + resize: () => {}, + pause: () => {}, + resume: () => {}, + kill: () => {}, + forceKill: () => {}, + signal: () => {}, + terminateOwnedTree: () => 'unavailable' as const, + onData(cb: (data: string) => void) { + onData = cb + }, + onExit(cb: (code: number) => void) { + onExit = cb + }, + dispose: () => {} + } as unknown as SubprocessHandle + return { + handle, + confirmShellForeground, + emit: (data: string) => onData?.(data), + exit: (code: number) => onExit?.(code), + confirm: (v: boolean) => resolveConfirm?.(v) + } +} + +describe('Session shell-owned recovery through the output barrier', () => { + it('delivers post-kill shell output into the normalized snapshot instead of the discarded alt buffer', async () => { + const sub = createSubprocess() + const session = new Session({ + sessionId: 's1', + cols: 80, + rows: 24, + subprocess: sub.handle, + shellReadySupported: false + } as never) + + sub.emit('before-tui\r\n\x1b[?1049hTUI-FRAME\x1b]133;D;137\x07SHELL-PROMPT-MARKER') + await vi.waitFor(() => expect(sub.confirmShellForeground).toHaveBeenCalledTimes(1)) + sub.confirm(true) + await session.settleShellOwnershipConfirmation() + await new Promise((r) => setTimeout(r, 20)) + + const snap = session.getSnapshot()! + const content = (snap.scrollbackAnsi ?? '') + snap.snapshotAnsi + expect(snap.terminalOwner).toBe('shell') + expect(snap.modes.alternateScreen).toBe(false) + expect(content).toContain('SHELL-PROMPT-MARKER') + expect(content).toContain('before-tui') + session.dispose() + }) + + it('keeps split escapes intact and revokes stale proof when a snapshot lands mid-sequence', async () => { + const sub = createSubprocess() + const session = new Session({ + sessionId: 's2', + cols: 80, + rows: 24, + subprocess: sub.handle, + shellReadySupported: false + } as never) + + sub.emit('\x1b[?1049hTUI\x1b]133;D;137\x07') + await vi.waitFor(() => expect(sub.confirmShellForeground).toHaveBeenCalledTimes(1)) + sub.confirm(true) + await session.settleShellOwnershipConfirmation() + await new Promise((r) => setTimeout(r, 20)) + expect(session.getSnapshot()?.terminalOwner).toBe('shell') + + // Live stream continues with a split mode-enable; snapshot lands inside the window. + sub.emit('marker\x1b[?10') + await new Promise((r) => setTimeout(r, 10)) + const mid = session.getSnapshot()! + expect(mid.pendingEscapeTailAnsi).toBe('\x1b[?10') + + sub.emit('49h') + await new Promise((r) => setTimeout(r, 20)) + const after = session.getSnapshot()! + expect(after.modes.alternateScreen).toBe(true) + expect(after.terminalOwner).toBeUndefined() + expect(after.snapshotAnsi).not.toContain('49h') + session.dispose() + }) + + it('flushes queued bytes to clients when the shell exits mid-proof instead of dropping them', async () => { + const sub = createSubprocess() + // Models TerminalHost.reapSession: exit and disposal are one synchronous chain. + const session: Session = new Session({ + sessionId: 's3', + cols: 80, + rows: 24, + subprocess: sub.handle, + shellReadySupported: false, + onExit: () => session.dispose() + } as never) + const received: string[] = [] + const exits: number[] = [] + session.attachClient({ + onData: (data: string) => received.push(data), + onExit: (code: number) => exits.push(code) + }) + + sub.emit('\x1b[?1049hTUI\x1b]133;D;137\x07\r\nprompt-after-death') + await vi.waitFor(() => expect(sub.confirmShellForeground).toHaveBeenCalledTimes(1)) + // The shell exits before the proof settles — sub-millisecond in production, + // so it essentially always wins the race against the ps fork. + sub.exit(0) + + expect(received.join('')).toContain('prompt-after-death') + expect(exits).toEqual([0]) + // No injection either: an unproven episode flushes unmodified. + expect(received.join('')).not.toContain('\x1b[?1049l') + }) + + it('flushes nested episodes stacked inside one proof window when the shell exits', async () => { + const sub = createSubprocess() + const session: Session = new Session({ + sessionId: 's4', + cols: 80, + rows: 24, + subprocess: sub.handle, + shellReadySupported: false, + onExit: () => session.dispose() + } as never) + const received: string[] = [] + session.attachClient({ onData: (data: string) => received.push(data), onExit: () => {} }) + + // Three alt-screen death cycles inside one proof window: each flush pass can + // re-enter pending on the next trigger, so a single-level flush loses the tail. + sub.emit( + '\x1b[?1049hT1\x1b]133;D;1\x07AAA\x1b[?1049hT2\x1b]133;D;2\x07BBB\x1b[?1049hT3\x1b]133;D;3\x07CCC' + ) + await vi.waitFor(() => expect(sub.confirmShellForeground).toHaveBeenCalled()) + sub.exit(0) + + const joined = received.join('') + for (const marker of ['AAA', 'BBB', 'CCC']) { + expect(joined).toContain(marker) + } + expect(joined).not.toContain('\x1b[?1049l') + }) +}) diff --git a/src/main/daemon/session-subprocess-handle.ts b/src/main/daemon/session-subprocess-handle.ts index 42f2bd43dee..f14469afbb4 100644 --- a/src/main/daemon/session-subprocess-handle.ts +++ b/src/main/daemon/session-subprocess-handle.ts @@ -9,6 +9,8 @@ export type SubprocessHandle = { getForegroundProcess(): string | null /** Await process-table evidence captured after this confirmation request. */ confirmForegroundProcess?(): Promise + /** Proves a fresh post-boundary PTY process tree contains only the shell. */ + confirmShellForeground?(): Promise /** True when shell launch args already delivered the startup command, so the host skips its stdin fallback write. */ startupCommandDeliveredInShellArgs?: boolean /** Shell the subprocess actually spawned, after fallbacks. The host reconciles the caller's shell-ready diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index ea37c9237e5..191b8ee7eaa 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -41,6 +41,7 @@ function createMockSubprocess() { getForegroundProcess(): string | null { return this.foregroundProcess }, + confirmShellForeground: vi.fn(async () => true), write(data: string) { written.push(data) }, @@ -116,6 +117,7 @@ describe('Session', () => { ownerBackend?: 'posix-pty' | 'windows-conpty' | 'windows-wsl' wslDistro?: string reportReadinessEvent?: (event: string, details: Record) => void + historySeedChunks?: readonly string[] }): Session { session = new Session({ sessionId: 'test-session', @@ -125,6 +127,7 @@ describe('Session', () => { ...(opts?.launchAgent ? { launchAgent: opts.launchAgent } : {}), wslDistro: opts?.wslDistro, subprocess, + historySeedChunks: opts?.historySeedChunks, ...(opts?.ownerBackend ? { ownerBackend: opts.ownerBackend } : {}), shellReadySupported: opts?.shellReadySupported ?? false, ...(opts?.startupIngress ? { startupIngress: opts.startupIngress } : {}), @@ -163,6 +166,53 @@ describe('Session', () => { }) describe('data flow', () => { + it('does not confirm shell ownership from historical replay bytes', () => { + createSession({ + historySeedChunks: ['\x1b[?1049hOLD-TUI\x1b]133;D;137\x07old-shell-marker'] + }) + + expect(subprocess.confirmShellForeground).not.toHaveBeenCalled() + expect(session.getSnapshot()?.terminalOwner).toBeUndefined() + }) + + it('answers concurrent runtime confirmations from one episode inspection', async () => { + let resolveConfirmation: ((confirmed: boolean) => void) | undefined + subprocess.confirmShellForeground.mockImplementation( + () => new Promise((resolve) => void (resolveConfirmation = resolve)) + ) + createSession() + + // Why no inspection without a candidate: the RPC reads the barrier's + // settled verdict; it must never mint proof the byte stream didn't ask for. + await expect(session.confirmShellForeground()).resolves.toBe(false) + expect(subprocess.confirmShellForeground).not.toHaveBeenCalled() + + subprocess.simulateData('\x1b[?1049hTUI\x1b]133;D;137\x07') + const first = session.confirmShellForeground() + const second = session.confirmShellForeground() + expect(subprocess.confirmShellForeground).toHaveBeenCalledTimes(1) + resolveConfirmation?.(true) + + await expect(Promise.all([first, second])).resolves.toEqual([true, true]) + expect(subprocess.confirmShellForeground).toHaveBeenCalledTimes(1) + }) + + it('reuses the parser confirmation for a concurrent runtime request', async () => { + let resolveConfirmation: ((confirmed: boolean) => void) | undefined + subprocess.confirmShellForeground.mockImplementation( + () => new Promise((resolve) => void (resolveConfirmation = resolve)) + ) + createSession() + + subprocess.simulateData('\x1b[?1049hTUI\x1b]133;D;137\x07shell-marker') + const runtimeConfirmation = session.confirmShellForeground() + expect(subprocess.confirmShellForeground).toHaveBeenCalledTimes(1) + resolveConfirmation?.(true) + + await expect(runtimeConfirmation).resolves.toBe(true) + await vi.waitFor(() => expect(session.getSnapshot()?.terminalOwner).toBe('shell')) + }) + it('forwards subprocess data to attached clients', () => { createSession() const received: string[] = [] diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 2862a01e6f1..b0f1dfa538d 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -1,12 +1,13 @@ import { isValidPtySize } from './daemon-pty-size' -import { SessionOutputPlane, type AttachedClient } from './session-output-plane' +import type { SessionOutputPlane, AttachedClient } from './session-output-plane' +import { createSessionOutputPipeline } from './session-output-pipeline' import { SessionProducerPause } from './session-producer-pause' import { SessionShellReadyBarrier } from './session-shell-ready-barrier' +import type { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier' import { SessionTerminationController, IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS } from './session-termination-controller' -import { nudgePowerShellPromptRepaint } from './session-powershell-prompt-repaint' import type { SubprocessHandle } from './session-subprocess-handle' import type { JobTerminationOutcome } from '../windows/windows-pty-job' import type { SessionOptions } from './session-options' @@ -38,6 +39,7 @@ export class Session { private readonly shellReady: SessionShellReadyBarrier private readonly termination: SessionTerminationController private readonly startupIngress: PtyStartupIngress + private readonly recoveryBarrier: TerminalShellRecoveryBarrier constructor(opts: SessionOptions) { this.sessionId = opts.sessionId @@ -46,13 +48,17 @@ export class Session { this.wslDistro = opts.wslDistro ?? null this.subprocess = opts.subprocess this.onSessionExit = opts.onExit - this.output = new SessionOutputPlane({ + const pipeline = createSessionOutputPipeline({ cols: opts.cols, rows: opts.rows, scrollback: opts.scrollback, wslDistro: opts.wslDistro, - historySeedChunks: opts.historySeedChunks + historySeedChunks: opts.historySeedChunks, + subprocess: this.subprocess, + isAlive: () => !this._disposed && this._state !== 'exited' }) + this.output = pipeline.output + this.recoveryBarrier = pipeline.recoveryBarrier this.producerPause = new SessionProducerPause(this.subprocess) this.termination = new SessionTerminationController({ sessionId: this.sessionId, @@ -78,10 +84,14 @@ export class Session { ...(opts.startupIngress ? { intent: opts.startupIngress } : {}), ...(opts.ownerBackend ? { ownerBackend: opts.ownerBackend } : {}), write: (data) => this.subprocess.write(data), - onEmission: (emission) => this.output.emit(emission) + onEmission: (emission) => this.recoveryBarrier.accept(emission) }) this.shellReady.startPromptReadinessProbe() - this.subprocess.onData((data) => this.handleSubprocessData(data)) + this.subprocess.onData((data) => { + if (!this._disposed) { + this.shellReady.ingestSubprocessData(data) + } + }) this.subprocess.onExit((code, cause) => this.handleSubprocessExit(code, cause)) } @@ -150,10 +160,7 @@ export class Session { } resize(cols: number, rows: number): void { - if (this._state === 'exited' || this._disposed) { - return - } - if (!isValidPtySize(cols, rows)) { + if (this._state === 'exited' || this._disposed || !isValidPtySize(cols, rows)) { return } this.output.resize(cols, rows) @@ -253,22 +260,26 @@ export class Session { return this.subprocess.confirmForegroundProcess?.() ?? this.subprocess.getForegroundProcess() } + confirmShellForeground(): Promise { + return this.recoveryBarrier.confirmOwnerSettled() + } + + async settleShellOwnershipConfirmation(): Promise { + await this.recoveryBarrier.awaitProofSettled() + // Why the fence: a snapshot at settle-resolution must not race the drained prompt's async parse. + await this.output.flushParsedWrites() + } + clearScrollback(): void { - if (this._disposed) { - return - } - this.output.clearScrollback() - this.subprocess.clear?.() - nudgePowerShellPromptRepaint({ - subprocess: this.subprocess, - isGatingWrites: this.shellReady.isGatingWrites, - isCursorOnEmptyPromptLine: () => this.output.isCursorOnEmptyPromptLine() - }) + this.output.clearScrollback(this.subprocess, this.shellReady.isGatingWrites) } prepareForFinalSnapshot(): string { const held = this.shellReady.releaseHeldBytes() this.startupIngress.snapshotBarrier() + // Why last: snapshotBarrier can emit held spans into the barrier, and a + // teardown checkpoint mid-episode must not lose the barrier's queued bytes. + this.recoveryBarrier.flushPending() return held } @@ -282,6 +293,10 @@ export class Session { this.shellReady.releaseDeviceAttributes() this.shellReady.releaseHeldBytes() this.startupIngress.drainAndClose() + // Why after drainAndClose (and before clearClients below): a dispose + // mid-episode must deliver the barrier's queued bytes — drained ingress + // included — while clients are attached and the emulator accepts writes. + this.recoveryBarrier.flushPending() const wasTerminating = this.termination.isTerminating && this._state !== 'exited' const clientsToNotify = wasTerminating ? this.output.snapshotClients() : [] if (wasTerminating) { @@ -299,6 +314,7 @@ export class Session { this.output.clearClients() this.shellReady.clearPendingWrites() + this.recoveryBarrier.dispose() this.output.disposeEmulator() for (const client of clientsToNotify) { @@ -338,13 +354,6 @@ export class Session { this.termination.disposeSubprocessHandle() } - private handleSubprocessData(data: string): void { - if (this._disposed) { - return - } - this.shellReady.ingestSubprocessData(data) - } - private handleSubprocessExit(code: number, cause?: TerminalExitCause): void { this.termination.markPhysicalExit() if (this._disposed) { @@ -355,6 +364,11 @@ export class Session { this.shellReady.disposePromptReadinessProbe() this.shellReady.releaseHeldBytes() this.startupIngress.drainAndClose() + // Why after drainAndClose: drained ingress bytes re-enter the barrier and can + // open a fresh episode; flushing here delivers them too. A shell exiting + // mid-proof must not strand the queued post-133;D prompt — those bytes belong + // to clients, records, and history before broadcastExit below. + this.recoveryBarrier.flushPending() this._exitCode = code this._state = 'exited' this.termination.clearTerminating() diff --git a/src/main/daemon/terminal-checkpoint-serializer.test.ts b/src/main/daemon/terminal-checkpoint-serializer.test.ts index 00be8f39eee..b53e207aa52 100644 --- a/src/main/daemon/terminal-checkpoint-serializer.test.ts +++ b/src/main/daemon/terminal-checkpoint-serializer.test.ts @@ -121,6 +121,25 @@ describe('terminal checkpoint serializer', () => { } }) + it('preserves shell ownership when an oversized alternate-screen checkpoint is trimmed', async () => { + const input = snapshot({ + snapshotAnsi: `\x1b[?1049h${'row\r\n'.repeat(500)}visible`, + rehydrateSequences: '\x1b[?1049h', + terminalOwner: 'shell', + modes: { + bracketedPaste: false, + mouseTracking: false, + applicationCursor: false, + alternateScreen: true + }, + scrollbackLines: 500 + }) + + const serialized = await serializeTerminalCheckpointWithinLimit(input, metadata, 2_048) + + expect(JSON.parse(serialized)).toMatchObject({ terminalOwner: 'shell' }) + }) + it('rejects an oversized escaped candidate without materializing it', async () => { const oversized = String.fromCharCode(0).repeat(100_000) const stringify = vi.spyOn(JSON, 'stringify') diff --git a/src/main/daemon/terminal-checkpoint-serializer.ts b/src/main/daemon/terminal-checkpoint-serializer.ts index bf72f42cb70..8776fb55588 100644 --- a/src/main/daemon/terminal-checkpoint-serializer.ts +++ b/src/main/daemon/terminal-checkpoint-serializer.ts @@ -27,6 +27,7 @@ function checkpointFile( modes: snapshot.modes, scrollbackLines: snapshot.scrollbackLines, ...(snapshot.lastTitle ? { lastTitle: snapshot.lastTitle } : {}), + ...(snapshot.terminalOwner ? { terminalOwner: snapshot.terminalOwner } : {}), generation: metadata.generation, ...(metadata.pendingOutputSeq !== undefined ? { pendingOutputSeq: metadata.pendingOutputSeq } @@ -260,7 +261,10 @@ export async function serializeTerminalCheckpointWithinLimit( const emulator = await replaySnapshot(snapshot) try { - const visibleOnly = emulator.getSnapshot({ scrollbackRows: 0 }) + // Why carried, not re-derived: trimming rows cannot change who owned the + // terminal at this checkpoint's boundary. + const ownership = snapshot.terminalOwner ? { terminalOwner: snapshot.terminalOwner } : {} + const visibleOnly = { ...emulator.getSnapshot({ scrollbackRows: 0 }), ...ownership } let bestJson = stringifyWithinLimit(checkpointFile(visibleOnly, metadata), maxBytes) if (bestJson === null) { throw new Error('Terminal checkpoint metadata exceeds byte limit') @@ -270,7 +274,7 @@ export async function serializeTerminalCheckpointWithinLimit( let high = visibleOnly.scrollbackLines while (low <= high) { const rows = low + Math.floor((high - low) / 2) - const candidate = emulator.getSnapshot({ scrollbackRows: rows }) + const candidate = { ...emulator.getSnapshot({ scrollbackRows: rows }), ...ownership } const candidateJson = stringifyWithinLimit(checkpointFile(candidate, metadata), maxBytes) if (candidateJson === null) { high = rows - 1 diff --git a/src/main/daemon/terminal-history-checkpoint-reader.ts b/src/main/daemon/terminal-history-checkpoint-reader.ts index 3132290a512..13007fb60af 100644 --- a/src/main/daemon/terminal-history-checkpoint-reader.ts +++ b/src/main/daemon/terminal-history-checkpoint-reader.ts @@ -4,6 +4,7 @@ import { readTerminalHistoryJsonAsync } from './terminal-history-file-reader' import { TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES } from './terminal-history-file-limits' import { isValidTerminalHistorySize } from './terminal-history-dimensions' import type { TerminalCheckpointFile, TerminalModes } from './types' +import { parseTerminalOwner } from '../../shared/terminal-owner' export type TerminalHistoryCheckpointRead = | { status: 'missing' } @@ -49,7 +50,9 @@ function isTerminalCheckpointFile(value: unknown): value is TerminalCheckpointFi (checkpoint.oscLinks === undefined || isTerminalOscLinkRanges(checkpoint.oscLinks)) && (checkpoint.pendingEscapeTailAnsi === undefined || typeof checkpoint.pendingEscapeTailAnsi === 'string') && - (checkpoint.lastTitle === undefined || typeof checkpoint.lastTitle === 'string') + (checkpoint.lastTitle === undefined || typeof checkpoint.lastTitle === 'string') && + (checkpoint.terminalOwner === undefined || + parseTerminalOwner(checkpoint.terminalOwner) !== undefined) ) } diff --git a/src/main/daemon/terminal-history-cold-restore-info.ts b/src/main/daemon/terminal-history-cold-restore-info.ts index 71cbf1102ed..95b92d1cbdd 100644 --- a/src/main/daemon/terminal-history-cold-restore-info.ts +++ b/src/main/daemon/terminal-history-cold-restore-info.ts @@ -1,6 +1,7 @@ import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' import type { SessionMeta } from './terminal-history-metadata' import type { TerminalModes } from './types' +import type { TerminalOwner } from '../../shared/terminal-owner' export type ColdRestoreInfo = { snapshotAnsi: string @@ -15,6 +16,7 @@ export type ColdRestoreInfo = { modes: TerminalModes pendingEscapeTailAnsi?: string lastTitle?: string + terminalOwner?: TerminalOwner } type RestoredSnapshot = { @@ -29,6 +31,7 @@ type RestoredSnapshot = { modes: TerminalModes pendingEscapeTailAnsi?: string lastTitle?: string + terminalOwner?: TerminalOwner } export function coldRestoreInfoFromSnapshot( @@ -53,6 +56,7 @@ export function coldRestoreInfoFromSnapshot( ...(snapshot.pendingEscapeTailAnsi ? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi } : {}), - ...(snapshot.lastTitle ? { lastTitle: snapshot.lastTitle } : {}) + ...(snapshot.lastTitle ? { lastTitle: snapshot.lastTitle } : {}), + ...(snapshot.terminalOwner ? { terminalOwner: snapshot.terminalOwner } : {}) } } diff --git a/src/main/daemon/terminal-host-concurrent-create.test.ts b/src/main/daemon/terminal-host-concurrent-create.test.ts index 8e404147e76..aa7b9fda956 100644 --- a/src/main/daemon/terminal-host-concurrent-create.test.ts +++ b/src/main/daemon/terminal-host-concurrent-create.test.ts @@ -4,22 +4,42 @@ import { TerminalHost } from './terminal-host' vi.mock('../pty-descendant-termination', () => ({ killWithDescendantSweep: vi.fn() })) -function mockSubprocess(): SubprocessHandle { +type MockSubprocess = SubprocessHandle & { + emitData: (data: string) => void + emitExit: (code: number) => void +} + +function mockSubprocess( + options: { confirmShellForeground?: () => Promise } = {} +): MockSubprocess { + let onDataCb: ((data: string) => void) | null = null let onExitCb: ((code: number) => void) | null = null return { pid: 4242, getForegroundProcess: vi.fn(() => null), + ...(options.confirmShellForeground + ? { confirmShellForeground: options.confirmShellForeground } + : {}), write: vi.fn(), resize: vi.fn(), kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 1)), forceKill: vi.fn(() => onExitCb?.(137)), signal: vi.fn(), - onData: vi.fn(), + terminateOwnedTree: () => 'unavailable' as const, + onData(cb) { + onDataCb = cb + }, onExit(cb) { onExitCb = cb }, - dispose: vi.fn() - } as unknown as SubprocessHandle + dispose: vi.fn(), + emitData(data) { + onDataCb?.(data) + }, + emitExit(code) { + onExitCb?.(code) + } + } as MockSubprocess } const streamClient = { onData: vi.fn(), onExit: vi.fn() } @@ -53,6 +73,28 @@ describe('concurrent createOrAttach across the async spawn', () => { await host.dispose() }) + it('attaches immediately while an ownership proof is still in flight', async () => { + // The proof never resolves: attach must not wait on it. The viewer takes + // the pre-reset snapshot and the in-stream reset heals its first frame. + const first = mockSubprocess({ + confirmShellForeground: vi.fn(() => new Promise(() => {})) + }) + const spawnSubprocess = vi + .fn<() => Promise>() + .mockResolvedValueOnce(first) + .mockImplementation(async () => mockSubprocess()) + const host = new TerminalHost({ spawnSubprocess }) + await host.createOrAttach(createOptions('settle-free')) + first.emitData('\x1b[?1049hTUI\x1b]133;D;137\x07') + await vi.waitFor(() => expect(first.confirmShellForeground).toHaveBeenCalledOnce()) + + await expect(host.createOrAttach(createOptions('settle-free'))).resolves.toMatchObject({ + isNew: false + }) + expect(spawnSubprocess).toHaveBeenCalledOnce() + await host.dispose() + }) + it('lets the next caller spawn after a failed spawn releases the gate', async () => { const spawnSubprocess = vi .fn<() => Promise>() diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index 1cb2a75f232..24f6cc2e867 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -38,6 +38,12 @@ export async function createOrAttachTerminalSession( throw new SessionNotFoundError(opts.sessionId) } + // Why no ownership settle here: attach is synchronous by contract. A viewer + // connecting inside an in-flight recovery proof (~100ms) gets the pre-reset + // snapshot and the injected reset arrives in-order over its stream — a + // self-healing first frame. Waiting instead created a race window (cancel, + // exit, kill during the await) that produced repeated regressions. Checkpoint + // readers that need a settled owner use getSettledSnapshot. if (existing && existing.isAlive && !existing.isTerminating) { const snapshot = existing.getSnapshot() existing.detachAllClients() diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index c8a57715b91..f85980b5453 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -233,6 +233,17 @@ export class TerminalHost { return session.confirmForegroundProcess() } + async confirmShellForeground(sessionId: string): Promise { + const session = this.sessions.get(sessionId) + if (session?.isAlive !== true) { + return false + } + const confirmed = await session.confirmShellForeground() + // Why the recheck: proof for a session that exited or was replaced during + // the await is stale; the caller would bind it to the successor's stream. + return confirmed && this.sessions.get(sessionId) === session && session.isAlive + } + clearScrollback(sessionId: string): void { this.getAliveSession(sessionId).clearScrollback() } @@ -246,6 +257,21 @@ export class TerminalHost { return session.getSnapshot(opts) } + async getSettledSnapshot( + sessionId: string, + opts: { scrollbackRows?: number } = {} + ): Promise { + const session = this.sessions.get(sessionId) + if (!session || !session.isAlive) { + return null + } + await session.settleShellOwnershipConfirmation() + // Why no liveness recheck: the sync path returned the pre-exit snapshot when + // a session died a beat after the call; a disposal during the settle yields + // null naturally from the plane's own guard. + return session.getSnapshot(opts) + } + // Why: scan-authority handoff seed (null-not-throw like getSnapshot) — emulator's dangling incomplete escape at the stream position. getPartialEscapeTailAnsi(sessionId: string): string { const session = this.sessions.get(sessionId) diff --git a/src/main/daemon/terminal-shell-clean-exit-confirmation.ts b/src/main/daemon/terminal-shell-clean-exit-confirmation.ts new file mode 100644 index 00000000000..11b0fc76bfc --- /dev/null +++ b/src/main/daemon/terminal-shell-clean-exit-confirmation.ts @@ -0,0 +1,100 @@ +import type { TerminalShellLifecycleScanner } from './terminal-shell-lifecycle-scanner' + +/** + * Async ownership proof for clean alternate-screen exits: no pause, no + * injection — a confirmed proof only marks the scanner's owner, guarded by the + * generation captured at the candidate. Every attempt retires at the deadline + * so a proof that never settles cannot wedge later candidates, and a retired + * attempt's late verdict is inert. + */ +export class TerminalShellCleanExitConfirmation { + private inFlightState = false + private attempt = 0 + private waiters: (() => void)[] = [] + private latestCandidateGeneration: number | undefined + + constructor( + private readonly opts: { + scanner: TerminalShellLifecycleScanner + confirmShellForeground: () => Promise + deadlineMs: () => number + isDisposed: () => boolean + isAlive: () => boolean + } + ) {} + + get inFlight(): boolean { + return this.inFlightState + } + + waitSettled(): Promise { + return new Promise((resolve) => this.waiters.push(resolve)) + } + + drainWaiters(): void { + const waiters = this.waiters + this.waiters = [] + for (const waiter of waiters) { + waiter() + } + } + + start(generation: number): void { + this.latestCandidateGeneration = generation + if (this.inFlightState || this.opts.isDisposed()) { + return + } + this.inFlightState = true + const requested = generation + const attempt = ++this.attempt + // Why the deadline: a proof that never settles must not wedge the in-flight + // flag for the session's life, silently ignoring every later candidate. + const deadline = setTimeout(() => this.retire(attempt, requested), this.opts.deadlineMs()) + deadline.unref?.() + // Why the guard: the callback is injected; a synchronous throw must not + // strand the in-flight flag. + let proof: Promise + try { + proof = this.opts.confirmShellForeground() + } catch { + proof = Promise.resolve(false) + } + void proof + .then((confirmed) => { + // A retired attempt's late verdict is inert: an unsettled proof already + // read as no owner, and flipping it afterwards would race fresh bytes. + if ( + attempt === this.attempt && + confirmed && + !this.opts.isDisposed() && + this.opts.isAlive() + ) { + this.opts.scanner.trySetOwner(requested) + } + }) + .catch(() => {}) + .finally(() => { + clearTimeout(deadline) + this.retire(attempt, requested) + }) + } + + private retire(attempt: number, requested: number): void { + if (attempt !== this.attempt) { + return + } + this.attempt += 1 + this.inFlightState = false + const latest = this.latestCandidateGeneration + if ( + latest !== undefined && + latest !== requested && + latest === this.opts.scanner.generation && + !this.opts.isDisposed() + ) { + // One superseding candidate can still be current; stale ones are not retried. + this.start(latest) + } + this.drainWaiters() + } +} diff --git a/src/main/daemon/terminal-shell-lifecycle-scanner.test.ts b/src/main/daemon/terminal-shell-lifecycle-scanner.test.ts new file mode 100644 index 00000000000..6d5fa662187 --- /dev/null +++ b/src/main/daemon/terminal-shell-lifecycle-scanner.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, it } from 'vitest' +import { POST_REPLAY_DEAD_TUI_RESET } from '../../shared/terminal-mode-reset-profiles' +import { TerminalShellLifecycleScanner } from './terminal-shell-lifecycle-scanner' + +const ENTER_ALT = '\x1b[?1049h' +const LEAVE_ALT = '\x1b[?1049l' + +describe('TerminalShellLifecycleScanner', () => { + describe('unclean death trigger', () => { + it('stops at the OSC 133;D terminator while the alternate screen is up', () => { + const scanner = new TerminalShellLifecycleScanner() + const chunk = `${ENTER_ALT}TUI\x1b]133;D;137\x07shell-marker` + + const events = scanner.scan(chunk) + + expect(events.uncleanDeathTriggerEnd).toBe(chunk.indexOf('shell-marker')) + expect(chunk.slice(events.uncleanDeathTriggerEnd)).toBe('shell-marker') + expect(scanner.isAlternateScreenActive).toBe(true) + expect(scanner.owner).toBeUndefined() + expect(events.cleanExitCandidate).toBeUndefined() + }) + + it('does not consume bytes past the trigger', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.scan(`${ENTER_ALT}TUI\x1b]133;D;137\x07shell-marker`) + + expect(scanner.scan('shell-marker')).toEqual({}) + }) + + it('carries no partial sequence out of the trigger', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.scan(`${ENTER_ALT}TUI\x1b]133;D;137\x07\x1b[?10`) + const generation = scanner.generation + + // A leaked tail would compose with this into `\x1b[?1049h` and revoke again. + expect(scanner.scan('49h')).toEqual({}) + expect(scanner.generation).toBe(generation) + }) + + it('reports a chunk-local index when the sequence spans three chunks', () => { + const scanner = new TerminalShellLifecycleScanner() + + expect(scanner.scan('\x1b[?10')).toEqual({}) + expect(scanner.scan('49hTUI\x1b]133;D;1')).toEqual({}) + const events = scanner.scan('37\x07tail') + + expect(events.uncleanDeathTriggerEnd).toBe('37\x07'.length) + expect('37\x07tail'.slice(events.uncleanDeathTriggerEnd)).toBe('tail') + expect(scanner.owner).toBeUndefined() + }) + + it('accepts an ST-terminated OSC', () => { + const scanner = new TerminalShellLifecycleScanner() + const chunk = `${ENTER_ALT}TUI\x1b]133;D;137\x1b\\rest` + + const events = scanner.scan(chunk) + + expect(events.uncleanDeathTriggerEnd).toBe(chunk.indexOf('rest')) + }) + + it('accepts an ST split across chunks', () => { + const scanner = new TerminalShellLifecycleScanner() + + expect(scanner.scan(`${ENTER_ALT}TUI\x1b]133;D;137\x1b`)).toEqual({}) + const events = scanner.scan('\\rest') + + expect(events.uncleanDeathTriggerEnd).toBe(1) + }) + + it('fires again on the re-fed remainder', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.scan(`${ENTER_ALT}TUI\x1b]133;D;137\x07REMAINDER`) + + // The remainder starts a fresh TUI that dies the same way. + expect(scanner.scan(LEAVE_ALT)).toEqual({}) + const again = scanner.scan(`${ENTER_ALT}AGAIN\x1b]133;D;9\x07`) + + expect(again.uncleanDeathTriggerEnd).toBe(`${ENTER_ALT}AGAIN\x1b]133;D;9\x07`.length) + }) + }) + + describe('clean exit candidates', () => { + it('offers ownership after a TUI leaves the alternate screen and the command ends', () => { + const scanner = new TerminalShellLifecycleScanner() + + const events = scanner.scan(`${ENTER_ALT}TUI${LEAVE_ALT}\x1b]133;D;0\x07`) + + expect(events.uncleanDeathTriggerEnd).toBeUndefined() + const candidate = events.cleanExitCandidate + expect(candidate).toBeDefined() + expect(scanner.owner).toBeUndefined() + expect(scanner.trySetOwner(candidate!.generation)).toBe(true) + expect(scanner.owner).toBe('shell') + expect(scanner.isAlternateScreenActive).toBe(false) + }) + + it('invalidates the candidate when output arrives before the owner is set', () => { + const scanner = new TerminalShellLifecycleScanner() + const candidate = scanner.scan( + `${ENTER_ALT}TUI${LEAVE_ALT}\x1b]133;D;0\x07` + ).cleanExitCandidate + + scanner.scan('\x1b]133;C\x07') + + expect(scanner.trySetOwner(candidate!.generation)).toBe(false) + expect(scanner.owner).toBeUndefined() + }) + + it('does not let a candidate survive a TUI started later in the same chunk', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.scan(`${ENTER_ALT}TUI${LEAVE_ALT}`) + + const candidate = scanner.scan(`\x1b]133;D;0\x07${ENTER_ALT}LIVE`).cleanExitCandidate + + expect(candidate).toBeDefined() + expect(scanner.trySetOwner(candidate!.generation)).toBe(false) + expect(scanner.owner).toBeUndefined() + }) + + it('emits nothing for a command that never entered the alternate screen', () => { + const scanner = new TerminalShellLifecycleScanner() + + const events = scanner.scan(`\x1b]133;D;0\x07${ENTER_ALT}LIVE`) + + expect(events).toEqual({}) + }) + + it('forgets the alternate-screen visit once a new command starts', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.scan(`${ENTER_ALT}TUI${LEAVE_ALT}`) + + scanner.scan('\x1b]133;C\x07') + + expect(scanner.scan('\x1b]133;D;0\x07')).toEqual({}) + }) + + it('emits no candidate for an ordinary command completion', () => { + const scanner = new TerminalShellLifecycleScanner() + + expect(scanner.scan('\x1b]133;D;0\x07')).toEqual({}) + }) + + it('keeps only the last candidate of a chunk', () => { + const scanner = new TerminalShellLifecycleScanner() + + const events = scanner.scan( + `${ENTER_ALT}a${LEAVE_ALT}\x1b]133;D;0\x07${ENTER_ALT}b${LEAVE_ALT}\x1b]133;D;0\x07` + ) + + expect(events.cleanExitCandidate?.generation).toBe(scanner.generation) + expect(scanner.trySetOwner(events.cleanExitCandidate!.generation)).toBe(true) + }) + }) + + describe('revocation', () => { + it('revokes on a mouse-only DECSET', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.seedOwner('shell') + + scanner.scan('\x1b[?1003h') + + expect(scanner.owner).toBeUndefined() + expect(scanner.isAlternateScreenActive).toBe(false) + }) + + it('revokes on a kitty keyboard push', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.seedOwner('shell') + + scanner.scan('\x1b[>1u') + + expect(scanner.owner).toBeUndefined() + }) + + it('ignores the kitty pop and clear forms we inject ourselves', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.seedOwner('shell') + const generation = scanner.generation + + scanner.scan('\x1b[<99u\x1b[=0u') + + expect(scanner.owner).toBe('shell') + expect(scanner.generation).toBe(generation) + }) + + it('revokes on every command boundary, not just ones touching the alternate screen', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.seedOwner('shell') + const generation = scanner.generation + + scanner.scan('\x1b]133;D;0\x07') + + expect(scanner.owner).toBeUndefined() + expect(scanner.generation).toBe(generation + 1) + + scanner.trySetOwner(scanner.generation) + scanner.scan('\x1b]133;C\x07') + + expect(scanner.owner).toBeUndefined() + }) + + it('revokes and clears alternate-screen state on RIS', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.scan(ENTER_ALT) + scanner.seedOwner('shell') + + scanner.scan('\x1bc') + + expect(scanner.owner).toBeUndefined() + expect(scanner.isAlternateScreenActive).toBe(false) + // The cleared flag means a later D is a plain completion, not a clean exit. + expect(scanner.scan('\x1b]133;D;0\x07')).toEqual({}) + }) + + it('recognizes the 8-bit CSI introducer', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.seedOwner('shell') + + scanner.scan('\x9b?1049h') + + expect(scanner.owner).toBeUndefined() + expect(scanner.isAlternateScreenActive).toBe(true) + }) + + it('does not revoke on DECRST leaving the alternate screen', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.seedOwner('shell') + const generation = scanner.generation + + scanner.scan(LEAVE_ALT) + + expect(scanner.owner).toBe('shell') + expect(scanner.generation).toBe(generation) + }) + }) + + describe('inert input', () => { + it('ignores plain and colored output', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.seedOwner('shell') + const generation = scanner.generation + + expect(scanner.scan('hello world\r\n')).toEqual({}) + expect(scanner.scan('\x1b[31mred\x1b[0m')).toEqual({}) + expect(scanner.owner).toBe('shell') + expect(scanner.generation).toBe(generation) + }) + + it('leaves ownership intact across our own dead-TUI reset', () => { + const scanner = new TerminalShellLifecycleScanner() + scanner.seedOwner('shell') + const generation = scanner.generation + + scanner.scan(POST_REPLAY_DEAD_TUI_RESET) + + expect(scanner.owner).toBe('shell') + expect(scanner.generation).toBe(generation) + expect(scanner.isAlternateScreenActive).toBe(false) + }) + + it('drops an oversized carried tail instead of growing without bound', () => { + const scanner = new TerminalShellLifecycleScanner() + + expect(scanner.scan(`\x1b]133;${'x'.repeat(5000)}`)).toEqual({}) + expect(scanner.scan('\x07')).toEqual({}) + }) + }) +}) + +describe('unclean trigger arming', () => { + it('fires once per alternate-screen occupancy and re-arms only on a fresh entry', () => { + const scanner = new TerminalShellLifecycleScanner() + + expect(scanner.scan('\x1b[?1049hTUI\x1b]133;D;137\x07').uncleanDeathTriggerEnd).toBeDefined() + // Refuted path: no reset scanned, alt still active — a later prompt's D must not re-trigger. + const second = scanner.scan('\x1b]133;C\x07ls\r\n\x1b]133;D;0\x07') + expect(second.uncleanDeathTriggerEnd).toBeUndefined() + expect(second.cleanExitCandidate).toBeUndefined() + expect(scanner.isAlternateScreenActive).toBe(true) + + expect( + scanner.scan('\x1b]133;C\x07\x1b[?1049hAGAIN\x1b]133;D;9\x07').uncleanDeathTriggerEnd + ).toBeDefined() + }) +}) diff --git a/src/main/daemon/terminal-shell-lifecycle-scanner.ts b/src/main/daemon/terminal-shell-lifecycle-scanner.ts new file mode 100644 index 00000000000..97a4a121dce --- /dev/null +++ b/src/main/daemon/terminal-shell-lifecycle-scanner.ts @@ -0,0 +1,223 @@ +import type { TerminalOwner } from '../../shared/terminal-owner' + +// Why: PTY/SSH chunks can split a long combined DECSET or an OSC 133 payload +// before its terminator. Keep parser state far beyond normal sequence lengths +// while still bounding memory. +const SCAN_TAIL_LIMIT = 4096 + +// Any mode a full-screen or mouse-driven app arms: once one appears the shell +// is no longer the thing writing to this pane. +const TUI_MODE_ENABLES = new Set([47, 1000, 1002, 1003, 1004, 1005, 1006, 1015, 1016, 1047, 1049]) +const ALTERNATE_SCREEN_MODES = new Set([47, 1047, 1049]) + +// oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars +const LIFECYCLE_OSC = /\x1b\]133;([^\x07\x1b]*)(?:\x07|\x1b\\)/ +// oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars +const PRIVATE_MODE = /\x1b\[\?([0-9;]*)([hl])|\x9b\?([0-9;]*)([hl])/ +// oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars +const KITTY_KEYBOARD = /\x1b\[([>=])([0-9;]*)u|\x9b([>=])([0-9;]*)u/ +// oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars +const FULL_RESET = /\x1bc/ + +// Groups: 1 OSC 133 payload, 2/3 and 4/5 private-mode params + final, 6/7 and 8/9 kitty prefix + params. +const SEQUENCE_RE = new RegExp( + [LIFECYCLE_OSC, PRIVATE_MODE, KITTY_KEYBOARD, FULL_RESET].map((re) => re.source).join('|'), + 'g' +) + +export type ShellLifecycleScanEvents = { + /** + * Index into the chunk just handed to `scan()`, one past the terminator of an + * OSC 133;D that arrived while the alternate screen was still active. Bytes at + * and after this index were NOT consumed; the caller re-feeds them. + */ + uncleanDeathTriggerEnd?: number + /** An OSC 133;D closed a command that had entered the alternate screen and left it cleanly. */ + cleanExitCandidate?: { generation: number } +} + +/** + * Streaming, chunk-split-safe scanner of raw PTY output that tracks which side + * owns the stream. Callers must feed `scan()` the same bytes the terminal + * parsed, in order. + * + * Ownership is generation-guarded rather than a plain flag: an async consumer + * that decides "the shell owns this pane" from an event must prove no revoking + * sequence slipped in between, which `trySetOwner()` checks. + */ +export class TerminalShellLifecycleScanner { + private scanTail = '' + private ownerState: TerminalOwner | undefined + private generationState = 0 + private altActive = false + private commandEnteredAlternateScreen = false + // Why one-shot: a refuted proof leaves altActive true (no reset was ever + // scanned), and without disarming every later prompt's D would re-open a + // full pause-and-inspect episode. Only a fresh alternate-screen entry re-arms. + private uncleanTriggerArmed = false + + get owner(): TerminalOwner | undefined { + return this.ownerState + } + + get generation(): number { + return this.generationState + } + + get isAlternateScreenActive(): boolean { + return this.altActive + } + + trySetOwner(generation: number): boolean { + if (generation !== this.generationState) { + return false + } + this.ownerState = 'shell' + return true + } + + seedOwner(owner: TerminalOwner | undefined, opts: { alternateScreen?: boolean } = {}): void { + this.generationState += 1 + this.ownerState = owner + if (opts.alternateScreen !== undefined) { + // Why seeded mode state matters: restored bytes bypass scan(), so without + // this a mirror seeded mid-TUI never arms its unclean-death trigger and + // the whole occupancy loses recovery. + this.altActive = opts.alternateScreen + this.uncleanTriggerArmed = opts.alternateScreen + this.commandEnteredAlternateScreen = opts.alternateScreen + } + } + + scan(chunk: string): ShellLifecycleScanEvents { + const events: ShellLifecycleScanEvents = {} + // Why the pre-filter: this runs per chunk for every session; a flood chunk + // with no escape introducer must not pay the regex pass. Split sequences + // stay correct because a partial one always left a non-empty scanTail. + if (this.scanTail.length === 0 && !chunk.includes('\x1b') && !chunk.includes('\x9b')) { + return events + } + const previousTailLength = this.scanTail.length + const input = previousTailLength === 0 ? chunk : this.scanTail + chunk + this.scanTail = this.extractScanTail(input) + // The shared regex is safe because scan() is synchronous and never re-enters. + SEQUENCE_RE.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = SEQUENCE_RE.exec(input)) !== null) { + const oscPayload = match[1] + if (match[0] === '\x1bc') { + this.revoke() + this.altActive = false + this.commandEnteredAlternateScreen = false + this.uncleanTriggerArmed = false + continue + } + if (oscPayload !== undefined) { + const marker = oscPayload[0] + if (marker === 'C') { + this.revoke() + this.commandEnteredAlternateScreen = false + continue + } + if (marker !== 'D') { + continue + } + // An alternate screen still up at command-finished means the app died + // without its own teardown; the caller must repair before more bytes land. + const uncleanDeath = this.altActive && this.uncleanTriggerArmed + const cleanExit = !uncleanDeath && this.commandEnteredAlternateScreen && !this.altActive + this.revoke() + this.commandEnteredAlternateScreen = false + if (uncleanDeath) { + this.uncleanTriggerArmed = false + this.scanTail = '' + // Why the clamp: a complete OSC can never sit wholly inside the + // carried tail (extractScanTail keeps incomplete ones only), so this + // is > 0 by construction — the clamp only guards that invariant + // against a future loosening of the tail extractor. + events.uncleanDeathTriggerEnd = Math.max( + 0, + match.index + match[0].length - previousTailLength + ) + return events + } + if (cleanExit) { + events.cleanExitCandidate = { generation: this.generationState } + } + continue + } + const kittyPrefix = match[6] ?? match[8] + if (kittyPrefix !== undefined) { + // `CSI < n u` (pop) and `CSI = 0 u` (clear) appear in our own injected + // reset, so only a push of real flags counts as a new owner. + const first = Number((match[7] ?? match[9] ?? '').split(';')[0]) + if (Number.isInteger(first) && first > 0) { + this.revoke() + } + continue + } + const enabled = (match[3] ?? match[5]) === 'h' + for (const rawParam of (match[2] ?? match[4] ?? '').split(';')) { + if (rawParam === '') { + continue + } + const param = Number(rawParam) + if (!Number.isInteger(param)) { + continue + } + if (enabled && TUI_MODE_ENABLES.has(param)) { + this.revoke() + } + if (ALTERNATE_SCREEN_MODES.has(param)) { + this.altActive = enabled + if (enabled) { + this.commandEnteredAlternateScreen = true + this.uncleanTriggerArmed = true + } + } + } + } + return events + } + + private revoke(): void { + this.generationState += 1 + this.ownerState = undefined + } + + private extractScanTail(input: string): string { + const oscStart = input.lastIndexOf('\x1b]') + if (oscStart !== -1 && isIncompleteLifecycleOsc(input.slice(oscStart))) { + return boundTail(input.slice(oscStart)) + } + const start = Math.max(input.lastIndexOf('\x1b'), input.lastIndexOf('\x9b')) + if (start === -1) { + return '' + } + const tail = boundTail(input.slice(start)) + if (tail === '\x1b' || tail === '\x1b[' || tail === '\x9b' || tail === '') { + return tail + } + const params = tail.startsWith('\x1b[') + ? tail.slice(2) + : tail.startsWith('\x9b') + ? tail.slice(1) + : undefined + return params !== undefined && /^[?>=][0-9;]*$/.test(params) ? tail : '' + } +} + +function boundTail(tail: string): string { + return tail.length > SCAN_TAIL_LIMIT ? '' : tail +} + +/** `tail` starts with `\x1b]`; true only while it could still become a complete OSC 133. */ +function isIncompleteLifecycleOsc(tail: string): boolean { + const body = tail.slice(2) + if (body.length < 4) { + return '133;'.startsWith(body) + } + // Payload may end mid-ST: the trailing ESC is the first half of `\x1b\`. + // oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars + return body.startsWith('133;') && /^[^\x07\x1b]*\x1b?$/.test(body.slice(4)) +} diff --git a/src/main/daemon/terminal-shell-recovery-barrier.test.ts b/src/main/daemon/terminal-shell-recovery-barrier.test.ts new file mode 100644 index 00000000000..5c6346e8a67 --- /dev/null +++ b/src/main/daemon/terminal-shell-recovery-barrier.test.ts @@ -0,0 +1,405 @@ +import { describe, expect, it, vi } from 'vitest' +import { POST_REPLAY_DEAD_TUI_RESET } from '../../shared/terminal-mode-reset-profiles' +import { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier' +import type { PtyIngressEmission } from '../../shared/pty-startup-ingress' + +const TRIGGER = '\x1b[?1049hTUI\x1b]133;D;137\x07' + +function passthrough(data: string, rawStartSeq = 0): PtyIngressEmission { + return { data, rawStartSeq, rawEndSeq: rawStartSeq + data.length, transformed: false } +} + +function createBarrier(opts?: { + confirm?: () => Promise + isAlive?: () => boolean + maxQueuedBytes?: number + maxPendingMs?: number +}) { + const released: PtyIngressEmission[] = [] + const confirm = vi.fn(opts?.confirm ?? (async () => true)) + const barrier = new TerminalShellRecoveryBarrier({ + confirmShellForeground: confirm, + release: (emission) => released.push(emission), + isAlive: opts?.isAlive ?? (() => true), + ...(opts?.maxQueuedBytes !== undefined ? { maxQueuedBytes: opts.maxQueuedBytes } : {}), + ...(opts?.maxPendingMs !== undefined ? { maxPendingMs: opts.maxPendingMs } : {}) + }) + return { barrier, released, confirm } +} + +describe('TerminalShellRecoveryBarrier', () => { + it('releases ordinary output untouched without inspections', () => { + const { barrier, released, confirm } = createBarrier() + const emission = passthrough('plain \x1b[31mred\x1b[0m output\r\n') + barrier.accept(emission) + + expect(released).toEqual([emission]) + expect(confirm).not.toHaveBeenCalled() + expect(barrier.getOwner()).toBeUndefined() + }) + + it('pauses at the unclean-death boundary and delivers the queued prompt after the injected reset', async () => { + let resolveConfirm: ((confirmed: boolean) => void) | undefined + const { barrier, released, confirm } = createBarrier({ + confirm: () => new Promise((resolve) => void (resolveConfirm = resolve)) + }) + + barrier.accept(passthrough(`${TRIGGER}SHELL-PROMPT`, 100)) + expect(confirm).toHaveBeenCalledTimes(1) + expect(released).toEqual([ + { data: TRIGGER, rawStartSeq: 100, rawEndSeq: 100 + TRIGGER.length, transformed: false } + ]) + + resolveConfirm?.(true) + await vi.waitFor(() => expect(released).toHaveLength(3)) + expect(released[1]).toEqual({ + data: POST_REPLAY_DEAD_TUI_RESET, + rawStartSeq: 100 + TRIGGER.length, + rawEndSeq: 100 + TRIGGER.length, + transformed: true + }) + expect(released[2]).toEqual({ + data: 'SHELL-PROMPT', + rawStartSeq: 100 + TRIGGER.length, + rawEndSeq: 100 + TRIGGER.length + 'SHELL-PROMPT'.length, + transformed: false + }) + expect(barrier.getOwner()).toBe('shell') + }) + + it('queues later emissions during an episode and preserves order', async () => { + let resolveConfirm: ((confirmed: boolean) => void) | undefined + const { barrier, released } = createBarrier({ + confirm: () => new Promise((resolve) => void (resolveConfirm = resolve)) + }) + + barrier.accept(passthrough(TRIGGER, 0)) + barrier.accept(passthrough('late-1', TRIGGER.length)) + barrier.accept(passthrough('late-2', TRIGGER.length + 6)) + expect(released).toHaveLength(1) + + resolveConfirm?.(true) + await vi.waitFor(() => expect(released).toHaveLength(4)) + expect(released.map((emission) => emission.data)).toEqual([ + TRIGGER, + POST_REPLAY_DEAD_TUI_RESET, + 'late-1', + 'late-2' + ]) + }) + + it('flushes unmodified with no injection when the proof is refuted', async () => { + let resolveConfirm: ((confirmed: boolean) => void) | undefined + const { barrier, released } = createBarrier({ + confirm: () => new Promise((resolve) => void (resolveConfirm = resolve)) + }) + + barrier.accept(passthrough(`${TRIGGER}nested-shell`)) + resolveConfirm?.(false) + + await vi.waitFor(() => expect(released).toHaveLength(2)) + expect(released.map((emission) => emission.data)).toEqual([TRIGGER, 'nested-shell']) + expect(barrier.getOwner()).toBeUndefined() + }) + + it('bails out on timeout and ignores a late confirmation', async () => { + let resolveConfirm: ((confirmed: boolean) => void) | undefined + const { barrier, released } = createBarrier({ + confirm: () => new Promise((resolve) => void (resolveConfirm = resolve)), + maxPendingMs: 20 + }) + + barrier.accept(passthrough(`${TRIGGER}prompt`)) + await vi.waitFor(() => expect(released).toHaveLength(2)) + expect(released.map((emission) => emission.data)).toEqual([TRIGGER, 'prompt']) + + resolveConfirm?.(true) + await new Promise((resolve) => setTimeout(resolve, 5)) + expect(released).toHaveLength(2) + expect(barrier.getOwner()).toBeUndefined() + }) + + it('bails out when the queue exceeds its byte budget', () => { + const { barrier, released } = createBarrier({ + confirm: () => new Promise(() => {}), + maxQueuedBytes: 8 + }) + + barrier.accept(passthrough(TRIGGER)) + barrier.accept(passthrough('0123456789')) + + expect(released.map((emission) => emission.data)).toEqual([TRIGGER, '0123456789']) + expect(barrier.getOwner()).toBeUndefined() + }) + + it('does not inject when the session died while the proof settled', async () => { + let resolveConfirm: ((confirmed: boolean) => void) | undefined + let alive = true + const { barrier, released } = createBarrier({ + confirm: () => new Promise((resolve) => void (resolveConfirm = resolve)), + isAlive: () => alive + }) + + barrier.accept(passthrough(`${TRIGGER}prompt`)) + alive = false + resolveConfirm?.(true) + + await vi.waitFor(() => expect(released).toHaveLength(2)) + expect(released.map((emission) => emission.data)).toEqual([TRIGGER, 'prompt']) + expect(barrier.getOwner()).toBeUndefined() + }) + + it('handles a second unclean episode queued behind the first', async () => { + const confirms: ((confirmed: boolean) => void)[] = [] + const { barrier, released, confirm } = createBarrier({ + confirm: () => new Promise((resolve) => void confirms.push(resolve)) + }) + + barrier.accept(passthrough(`${TRIGGER}first-prompt`, 0)) + const second = `\x1b[?1049hAGAIN\x1b]133;D;9\x07second-prompt` + barrier.accept(passthrough(second, TRIGGER.length + 'first-prompt'.length)) + + confirms[0]?.(true) + await vi.waitFor(() => expect(confirm).toHaveBeenCalledTimes(2)) + confirms[1]?.(true) + + await vi.waitFor(() => + expect(released.map((emission) => emission.data)).toEqual([ + TRIGGER, + POST_REPLAY_DEAD_TUI_RESET, + 'first-prompt', + '\x1b[?1049hAGAIN\x1b]133;D;9\x07', + POST_REPLAY_DEAD_TUI_RESET, + 'second-prompt' + ]) + ) + expect(barrier.getOwner()).toBe('shell') + }) + + it('does not re-trigger on the next command-done after an injected recovery', async () => { + const { barrier, released, confirm } = createBarrier() + barrier.accept(passthrough(`${TRIGGER}prompt`)) + await vi.waitFor(() => expect(barrier.getOwner()).toBe('shell')) + expect(confirm).toHaveBeenCalledTimes(1) + + // Why: the injected reset's ?1049l must ground the scanner's alt state; + // a bare D at the next plain prompt would otherwise loop the repair. + barrier.accept(passthrough('\x1b]133;C\x07ls\r\n\x1b]133;D;0\x07')) + + expect(confirm).toHaveBeenCalledTimes(1) + expect(released.at(-1)?.data).toBe('\x1b]133;C\x07ls\r\n\x1b]133;D;0\x07') + }) + + it('proves clean alternate-screen exits without pausing the stream', async () => { + let resolveConfirm: ((confirmed: boolean) => void) | undefined + const { barrier, released, confirm } = createBarrier({ + confirm: () => new Promise((resolve) => void (resolveConfirm = resolve)) + }) + const emission = passthrough('\x1b[?1049hTUI\x1b[?1049l\x1b]133;D;0\x07PROMPT') + + barrier.accept(emission) + expect(released).toEqual([emission]) + expect(confirm).toHaveBeenCalledTimes(1) + expect(barrier.getOwner()).toBeUndefined() + + resolveConfirm?.(true) + await vi.waitFor(() => expect(barrier.getOwner()).toBe('shell')) + }) + + it('rejects a clean-exit proof superseded by a later command start', async () => { + let resolveConfirm: ((confirmed: boolean) => void) | undefined + const { barrier } = createBarrier({ + confirm: () => new Promise((resolve) => void (resolveConfirm = resolve)) + }) + + barrier.accept(passthrough('\x1b[?1049hTUI\x1b[?1049l\x1b]133;D;0\x07')) + barrier.accept(passthrough('\x1b]133;C\x07LIVE-TUI')) + resolveConfirm?.(true) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(barrier.getOwner()).toBeUndefined() + }) + + it('revokes proven ownership when a later TUI arms modes', async () => { + const { barrier } = createBarrier() + barrier.accept(passthrough(TRIGGER)) + await vi.waitFor(() => expect(barrier.getOwner()).toBe('shell')) + + barrier.accept(passthrough('\x1b[?1003hLIVE')) + expect(barrier.getOwner()).toBeUndefined() + }) + + it('resolves idle() at each episode boundary', async () => { + let resolveConfirm: ((confirmed: boolean) => void) | undefined + const { barrier } = createBarrier({ + confirm: () => new Promise((resolve) => void (resolveConfirm = resolve)) + }) + + await expect(barrier.idle()).resolves.toBeUndefined() + + barrier.accept(passthrough(TRIGGER)) + let idled = false + const idle = barrier.idle().then(() => { + idled = true + }) + await Promise.resolve() + expect(idled).toBe(false) + + resolveConfirm?.(true) + await idle + }) + + it('skips the episode for a transformed emission it cannot split', () => { + const { barrier, released, confirm } = createBarrier() + const emission: PtyIngressEmission = { + data: `${TRIGGER}prompt`, + rawStartSeq: 0, + rawEndSeq: 4, + transformed: true + } + barrier.accept(emission) + + expect(released).toEqual([emission]) + expect(confirm).not.toHaveBeenCalled() + expect(barrier.getOwner()).toBeUndefined() + }) + + it('recognizes a trigger split across emissions and splits the completing one', async () => { + const { barrier, released } = createBarrier() + const head = '\x1b[?1049hTUI\x1b]133;D;1' + const tail = '37\x07PROMPT' + barrier.accept(passthrough(head, 0)) + barrier.accept(passthrough(tail, head.length)) + + await vi.waitFor(() => + expect(released.map((emission) => emission.data)).toEqual([ + head, + '37\x07', + POST_REPLAY_DEAD_TUI_RESET, + 'PROMPT' + ]) + ) + expect(released[1]).toMatchObject({ rawStartSeq: head.length, rawEndSeq: head.length + 3 }) + expect(released[3]).toMatchObject({ + rawStartSeq: head.length + 3, + rawEndSeq: head.length + tail.length + }) + }) + + it('survives a throwing downstream client without losing the queue or wedging attach', async () => { + let resolveConfirm: ((confirmed: boolean) => void) | undefined + const released: PtyIngressEmission[] = [] + const barrier = new TerminalShellRecoveryBarrier({ + confirmShellForeground: () => new Promise((resolve) => void (resolveConfirm = resolve)), + release: (emission) => { + if (emission.data === 'poison') { + throw new Error('client transport died') + } + released.push(emission) + }, + isAlive: () => true + }) + + barrier.accept(passthrough(TRIGGER, 0)) + barrier.accept(passthrough('poison', TRIGGER.length)) + barrier.accept(passthrough('after-poison', TRIGGER.length + 6)) + const settled = barrier.awaitProofSettled() + resolveConfirm?.(true) + + await settled + await vi.waitFor(() => + expect(released.map((emission) => emission.data)).toEqual([ + TRIGGER, + POST_REPLAY_DEAD_TUI_RESET, + 'after-poison' + ]) + ) + await expect(barrier.idle()).resolves.toBeUndefined() + }) + + it('opens at most one episode per alternate-screen occupancy after a refuted proof', async () => { + const { barrier, released, confirm } = createBarrier({ confirm: async () => false }) + + barrier.accept(passthrough(`${TRIGGER}prompt`)) + await vi.waitFor(() => expect(released).toHaveLength(2)) + expect(confirm).toHaveBeenCalledTimes(1) + + // Why: the refuted path never scans a reset, so alt stays active — later + // ordinary prompts must not each re-open a pause-and-inspect episode. + for (let index = 0; index < 5; index += 1) { + const prompt = passthrough(`\x1b]133;C\x07ls\r\n\x1b]133;D;0\x07`) + barrier.accept(prompt) + expect(released.at(-1)).toBe(prompt) + } + expect(confirm).toHaveBeenCalledTimes(1) + + // A fresh alternate-screen entry re-arms recovery. + barrier.accept(passthrough(`\x1b]133;C\x07\x1b[?1049hAGAIN\x1b]133;D;9\x07`)) + expect(confirm).toHaveBeenCalledTimes(2) + }) + + it('bounds awaitProofSettled by its own deadline when a clean-exit proof hangs', async () => { + const { barrier } = createBarrier({ + confirm: () => new Promise(() => {}), + maxPendingMs: 20 + }) + + barrier.accept(passthrough('\x1b[?1049hTUI\x1b[?1049l\x1b]133;D;0\x07')) + const start = Date.now() + await barrier.awaitProofSettled() + expect(Date.now() - start).toBeLessThan(500) + expect(barrier.getOwner()).toBeUndefined() + }) + + it('still opens the episode when the trigger-carrying head release throws', async () => { + let resolveConfirm: ((confirmed: boolean) => void) | undefined + const released: PtyIngressEmission[] = [] + const confirm = vi.fn(() => new Promise((resolve) => void (resolveConfirm = resolve))) + let headThrown = false + const barrier = new TerminalShellRecoveryBarrier({ + confirmShellForeground: confirm, + release: (emission) => { + if (!headThrown && emission.data === TRIGGER) { + headThrown = true + throw new Error('client transport died mid-broadcast') + } + released.push(emission) + }, + isAlive: () => true + }) + + barrier.accept(passthrough(`${TRIGGER}SHELL-PROMPT`)) + expect(confirm).toHaveBeenCalledTimes(1) + resolveConfirm?.(true) + + await vi.waitFor(() => + expect(released.map((emission) => emission.data)).toEqual([ + POST_REPLAY_DEAD_TUI_RESET, + 'SHELL-PROMPT' + ]) + ) + expect(barrier.getOwner()).toBe('shell') + }) + + it('flushes a pending episode synchronously for a teardown snapshot', () => { + const { barrier, released } = createBarrier({ confirm: () => new Promise(() => {}) }) + + barrier.accept(passthrough(`${TRIGGER}prompt`)) + expect(released.map((emission) => emission.data)).toEqual([TRIGGER]) + + barrier.flushPending() + + expect(released.map((emission) => emission.data)).toEqual([TRIGGER, 'prompt']) + expect(barrier.getOwner()).toBeUndefined() + }) + + it('drops queued output only on disposal', () => { + const { barrier, released } = createBarrier({ confirm: () => new Promise(() => {}) }) + barrier.accept(passthrough(`${TRIGGER}prompt`)) + barrier.dispose() + + expect(released.map((emission) => emission.data)).toEqual([TRIGGER]) + barrier.accept(passthrough('after-dispose')) + expect(released).toHaveLength(1) + }) +}) diff --git a/src/main/daemon/terminal-shell-recovery-barrier.ts b/src/main/daemon/terminal-shell-recovery-barrier.ts new file mode 100644 index 00000000000..7f4a94e9861 --- /dev/null +++ b/src/main/daemon/terminal-shell-recovery-barrier.ts @@ -0,0 +1,338 @@ +import { POST_REPLAY_DEAD_TUI_RESET } from '../../shared/terminal-mode-reset-profiles' +import { TerminalShellCleanExitConfirmation } from './terminal-shell-clean-exit-confirmation' +import { TerminalShellLifecycleScanner } from './terminal-shell-lifecycle-scanner' +import type { PtyIngressEmission } from '../../shared/pty-startup-ingress' +import type { TerminalOwner } from '../../shared/terminal-owner' + +// Why bounded: a pause only bridges one process proof (~100ms measured p95); a +// flood or hang means the trigger misfired, so bail out and flush unmodified. +const MAX_QUEUED_BYTES = 262_144 +const MAX_PENDING_MS = 750 + +export type TerminalShellRecoveryBarrierOptions = { + /** Fresh execution-host proof that the spawned shell owns the PTY foreground. */ + confirmShellForeground: () => Promise + /** Ordered downstream sink (emulator write + record + client broadcast). */ + release: (emission: PtyIngressEmission) => void + isAlive: () => boolean + maxQueuedBytes?: number + maxPendingMs?: number +} + +/** + * Ordered output barrier for dead-TUI mode recovery. Sits between startup + * ingress and the output plane. When a shell-integration command-done marker + * (OSC 133;D) arrives while the alternate screen is still active, the stream + * pauses at that exact byte boundary, the execution host proves the shell owns + * the PTY foreground, and on proof a mode reset is injected as in-stream output + * so every downstream consumer (host emulator, mirrors, attached renderers, + * history) converges — and the queued shell prompt then paints onto the normal + * buffer instead of the discarded alternate screen. Any failure (refuted proof, + * timeout, overflow, death, disposal) flushes the queue unmodified, preserving + * incumbent behavior. Clean alternate-screen exits prove ownership without + * pausing: the model needs no correction, only snapshot metadata. + */ +export class TerminalShellRecoveryBarrier { + private readonly scanner = new TerminalShellLifecycleScanner() + private readonly confirmShellForeground: () => Promise + private readonly releaseDownstream: (emission: PtyIngressEmission) => void + private readonly isAlive: () => boolean + private readonly maxQueuedBytes: number + private readonly maxPendingMs: number + + private queue: PtyIngressEmission[] = [] + private queuedBytes = 0 + private pending = false + private pendingGeneration = 0 + private pendingRawSeq = 0 + private pendingEpisode = 0 + private bailTimer: ReturnType | null = null + private idleWaiters: (() => void)[] = [] + private readonly cleanExit: TerminalShellCleanExitConfirmation + private disposed = false + + constructor(opts: TerminalShellRecoveryBarrierOptions) { + this.confirmShellForeground = opts.confirmShellForeground + this.releaseDownstream = opts.release + this.isAlive = opts.isAlive + this.maxQueuedBytes = opts.maxQueuedBytes ?? MAX_QUEUED_BYTES + this.maxPendingMs = opts.maxPendingMs ?? MAX_PENDING_MS + this.cleanExit = new TerminalShellCleanExitConfirmation({ + scanner: this.scanner, + confirmShellForeground: opts.confirmShellForeground, + deadlineMs: () => this.maxPendingMs, + isDisposed: () => this.disposed, + isAlive: opts.isAlive + }) + } + + accept(emission: PtyIngressEmission): void { + if (this.disposed) { + return + } + if (this.pending) { + this.enqueue(emission) + return + } + this.scanAndRelease(emission) + } + + getOwner(): TerminalOwner | undefined { + return this.scanner.owner + } + + /** Answers a paired runtime's ownership question from the barrier's settled + * state. The barrier scans bytes before any consumer receives them, so its + * verdict is never behind the caller's parse position — a fresh process + * inspection here would prove nothing the barrier didn't already. */ + async confirmOwnerSettled(): Promise { + if (this.scanner.owner === 'shell') { + return true + } + await this.awaitProofSettled() + return this.scanner.owner === 'shell' + } + + /** Synchronous bail for teardown: resolves the current episode as refuted so + * the queued bytes reach the emulator and records before a final snapshot. + * Queue replay can re-enter pending on a nested trigger, so drain to + * completion — bounded, because a pathological stream must not spin teardown. */ + flushPending(): void { + for (let guard = 0; this.pending && guard < 16; guard += 1) { + this.finishPending(this.pendingEpisode, false) + } + if (!this.pending) { + return + } + // Pathological episode storm: stop rescanning but never drop bytes. The + // scanner's model is stale from here on — a checkpoint after this may + // publish a stale owner, acceptable only past a >16-nested-episode storm. + const queued = this.queue + this.queue = [] + this.queuedBytes = 0 + this.pending = false + if (this.bailTimer) { + clearTimeout(this.bailTimer) + this.bailTimer = null + } + for (const emission of queued) { + try { + this.releaseDownstream(emission) + } catch { + // One throwing client must not cost the rest of the queue its delivery. + } + } + this.resolveIdleWaiters() + } + + /** Resolves when the current recovery episode (if any) has settled. Bounded + * by the bail timer; resolves at each episode boundary so a hostile stream + * of back-to-back episodes cannot starve an attach. */ + idle(): Promise { + if (!this.pending) { + return Promise.resolve() + } + return new Promise((resolve) => this.idleWaiters.push(resolve)) + } + + /** Like idle(), but also waits out an in-flight clean-exit proof so snapshot + * and RPC readers see a settled owner. A generation advance past the entry + * point ends the wait (that proof is stale), and the wait itself is bounded + * by the barrier's own budget — attach latency must never inherit a slow + * out-of-process inspection; an unsettled proof just reads as no owner. */ + async awaitProofSettled(): Promise { + const target = this.scanner.generation + let deadlineHit = false + const deadline = setTimeout(() => { + deadlineHit = true + this.resolveIdleWaiters() + this.cleanExit.drainWaiters() + }, this.maxPendingMs) + deadline.unref?.() + try { + while ( + (this.pending || this.cleanExit.inFlight) && + this.scanner.generation <= target && + !this.disposed && + !deadlineHit + ) { + await (this.pending ? this.idle() : this.cleanExit.waitSettled()) + } + } finally { + clearTimeout(deadline) + } + } + + dispose(): void { + this.disposed = true + if (this.bailTimer) { + clearTimeout(this.bailTimer) + this.bailTimer = null + } + // Callers flush first (exit, dispose, prepareForFinalSnapshot); anything + // still queued here has no live downstream left to receive it. + this.queue = [] + this.queuedBytes = 0 + this.pending = false + this.resolveIdleWaiters() + this.cleanExit.drainWaiters() + } + + private scanAndRelease(emission: PtyIngressEmission): void { + const events = this.scanner.scan(emission.data) + if (events.cleanExitCandidate) { + this.cleanExit.start(events.cleanExitCandidate.generation) + } + const end = events.uncleanDeathTriggerEnd + if (end === undefined) { + this.releaseDownstream(emission) + return + } + const splittable = + !emission.transformed && emission.rawEndSeq - emission.rawStartSeq === emission.data.length + if (!splittable) { + // Why skip the episode: the raw-seq boundary inside a transformed emission + // cannot be reconstructed, so release everything and keep the scanner + // honest about the remainder — incumbent behavior for this rare corner. + try { + this.releaseDownstream(emission) + } catch { + // Why swallowed: emit already wrote and recorded before a client threw; + // the scanner must still advance past the remainder below. + } + this.consumeForStateOnly(emission.data.slice(end)) + return + } + const splitSeq = emission.rawStartSeq + end + try { + this.releaseDownstream({ + data: emission.data.slice(0, end), + rawStartSeq: emission.rawStartSeq, + rawEndSeq: splitSeq, + transformed: false + }) + } catch { + // Why swallowed: the emulator and records already took the head inside + // emit before a client's broadcast threw; aborting here would cost the + // post-boundary prompt its entire recovery episode. + } + this.enterPending(splitSeq) + if (end < emission.data.length) { + this.enqueue({ + data: emission.data.slice(end), + rawStartSeq: splitSeq, + rawEndSeq: emission.rawEndSeq, + transformed: false + }) + } + } + + private consumeForStateOnly(data: string): void { + let rest = data + while (rest.length > 0) { + const events = this.scanner.scan(rest) + if (events.cleanExitCandidate) { + this.cleanExit.start(events.cleanExitCandidate.generation) + } + if (events.uncleanDeathTriggerEnd === undefined) { + return + } + rest = rest.slice(events.uncleanDeathTriggerEnd) + } + } + + private enterPending(rawSeq: number): void { + this.pending = true + this.pendingEpisode += 1 + this.pendingGeneration = this.scanner.generation + this.pendingRawSeq = rawSeq + const episode = this.pendingEpisode + this.bailTimer = setTimeout(() => this.finishPending(episode, false), this.maxPendingMs) + this.bailTimer.unref?.() + // Why the guard: the callback is injected; a synchronous throw must not + // escape after pending flipped true and strand the episode until the bail. + let proof: Promise + try { + proof = this.confirmShellForeground() + } catch { + proof = Promise.resolve(false) + } + // Why the two-step then: a throw escaping finishPending must not re-enter + // it through a catch arm as a phantom refutation. + void proof + .then( + (confirmed) => confirmed, + () => false + ) + .then((confirmed) => this.finishPending(episode, confirmed)) + } + + private finishPending(episode: number, confirmed: boolean): void { + if (!this.pending || this.disposed || episode !== this.pendingEpisode) { + return + } + this.pending = false + if (this.bailTimer) { + clearTimeout(this.bailTimer) + this.bailTimer = null + } + const queued = this.queue + this.queue = [] + this.queuedBytes = 0 + try { + if (confirmed && this.isAlive()) { + // Scanned before release so alt-state stays honest; the reset bytes are + // deliberately inert for ownership (no OSC 133, no TUI mode enables). + this.scanner.scan(POST_REPLAY_DEAD_TUI_RESET) + try { + this.releaseDownstream({ + data: POST_REPLAY_DEAD_TUI_RESET, + rawStartSeq: this.pendingRawSeq, + rawEndSeq: this.pendingRawSeq, + transformed: true + }) + } catch { + // Why swallowed: a throwing downstream client must not strand the + // queued prompt bytes below. + } + this.scanner.trySetOwner(this.pendingGeneration) + } + for (let index = 0; index < queued.length; index += 1) { + if (this.disposed) { + break + } + if (this.pending) { + // A nested trigger inside the queue re-entered pending; requeue the rest. + this.enqueue(queued[index]!) + continue + } + try { + this.scanAndRelease(queued[index]!) + } catch { + // Why swallowed: one throwing downstream client must not cost the + // rest of the queue its delivery. + } + } + } finally { + // Why finally: an escaping throw must never orphan an attach waiting in + // settleShellOwnershipConfirmation — that wedges createOrAttach forever. + this.resolveIdleWaiters() + } + } + + private enqueue(emission: PtyIngressEmission): void { + this.queue.push(emission) + this.queuedBytes += emission.data.length + if (this.queuedBytes > this.maxQueuedBytes) { + this.finishPending(this.pendingEpisode, false) + } + } + + private resolveIdleWaiters(): void { + const waiters = this.idleWaiters + this.idleWaiters = [] + for (const waiter of waiters) { + waiter() + } + } +} diff --git a/src/main/daemon/terminal-shell-recovery-clean-exit-retirement.test.ts b/src/main/daemon/terminal-shell-recovery-clean-exit-retirement.test.ts new file mode 100644 index 00000000000..76349e202fd --- /dev/null +++ b/src/main/daemon/terminal-shell-recovery-clean-exit-retirement.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest' +import { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier' +import type { PtyIngressEmission } from '../../shared/pty-startup-ingress' + +const CLEAN_EXIT = '\x1b[?1049hTUI\x1b[?1049l\x1b]133;D;0\x07' + +function passthrough(data: string, rawStartSeq = 0): PtyIngressEmission { + return { data, rawStartSeq, rawEndSeq: rawStartSeq + data.length, transformed: false } +} + +function createBarrier(confirm: () => Promise, maxPendingMs?: number) { + const released: string[] = [] + const barrier = new TerminalShellRecoveryBarrier({ + confirmShellForeground: confirm, + release: (emission) => released.push(emission.data), + isAlive: () => true, + ...(maxPendingMs !== undefined ? { maxPendingMs } : {}) + }) + return Object.assign(barrier, { released }) +} + +describe('clean-exit confirmation retirement', () => { + it('retires a hung clean-exit proof so a later candidate can still prove ownership', async () => { + let calls = 0 + const barrier = createBarrier(() => { + calls += 1 + return calls === 1 ? new Promise(() => {}) : Promise.resolve(true) + }, 20) + + barrier.accept(passthrough(CLEAN_EXIT)) + expect(calls).toBe(1) + await barrier.awaitProofSettled() + expect(barrier.getOwner()).toBeUndefined() + + barrier.accept(passthrough(CLEAN_EXIT, CLEAN_EXIT.length)) + await vi.waitFor(() => expect(calls).toBe(2)) + await barrier.awaitProofSettled() + expect(barrier.getOwner()).toBe('shell') + }) + + it('contains a synchronously throwing confirm without wedging later candidates', async () => { + let calls = 0 + const barrier = createBarrier(() => { + calls += 1 + if (calls === 1) { + throw new Error('sync boom') + } + return Promise.resolve(true) + }) + + expect(() => barrier.accept(passthrough(CLEAN_EXIT))).not.toThrow() + await barrier.awaitProofSettled() + expect(barrier.getOwner()).toBeUndefined() + + barrier.accept(passthrough(CLEAN_EXIT, CLEAN_EXIT.length)) + await vi.waitFor(() => expect(calls).toBe(2)) + await barrier.awaitProofSettled() + expect(barrier.getOwner()).toBe('shell') + }) + + it('flushPending releases every byte of an episode storm past the rescan bound', () => { + const barrier = createBarrier(() => new Promise(() => {})) + const episode = '\x1b[?1049hT\x1b]133;D;1\x07x' + const storm = episode.repeat(40) + barrier.accept(passthrough(storm)) + barrier.flushPending() + + // Past the 16-pass rescan bound the remainder is released verbatim; the + // bound trades scanner-model fidelity at teardown, never bytes. + expect(barrier.released.join('')).toBe(storm) + }) +}) diff --git a/src/main/daemon/terminal-snapshot.ts b/src/main/daemon/terminal-snapshot.ts index 6d378a8d107..559d7f2ed08 100644 --- a/src/main/daemon/terminal-snapshot.ts +++ b/src/main/daemon/terminal-snapshot.ts @@ -1,5 +1,6 @@ import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' import type { TerminalModes } from './terminal-modes' +import type { TerminalOwner } from '../../shared/terminal-owner' export type TerminalSnapshot = { snapshotAnsi: string @@ -19,4 +20,6 @@ export type TerminalSnapshot = { lastTitle?: string /** Optional because persisted snapshots and older v19 daemons lack it. */ outputSequence?: number + /** Ordered shell lifecycle evidence captured at this snapshot boundary. */ + terminalOwner?: TerminalOwner } diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 2f1817d14c7..a90e9f5eee5 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -1,11 +1,13 @@ import type { ConfirmForegroundProcessRequest, + ConfirmShellForegroundRequest, GetForegroundProcessRequest, InspectProcessRequest } from './daemon-foreground-process-protocol' export type { ConfirmForegroundProcessRequest, + ConfirmShellForegroundRequest, GetForegroundProcessRequest, InspectProcessRequest } from './daemon-foreground-process-protocol' @@ -317,6 +319,7 @@ export type DaemonRequest = | GetForegroundProcessRequest | InspectProcessRequest | ConfirmForegroundProcessRequest + | ConfirmShellForegroundRequest | ClearScrollbackRequest | ShutdownRequest | PingRequest diff --git a/src/main/ipc/pty/ipc/snapshot.ts b/src/main/ipc/pty/ipc/snapshot.ts index 607b3bfe781..3724cf7ccfe 100644 --- a/src/main/ipc/pty/ipc/snapshot.ts +++ b/src/main/ipc/pty/ipc/snapshot.ts @@ -43,6 +43,7 @@ export function installPtySnapshotIpcHandlers(deps: { scrollbackAnsi?: string pendingEscapeTailAnsi?: string kittyKeyboardFlags?: number + terminalOwner?: 'shell' } | null> => { if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { return null diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 7e277c7c3f5..09e6fa8b8c8 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -35,14 +35,14 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise 0) { // Why kitty flags ride seed metadata: the snapshot omits them, but the re-seeded emulator must answer hidden `CSI ? u` with the running app's flags (terminal-query-authority.md). - ctx.deps.runtime.seedHeadlessTerminal( - ctx.result.id, - ctx.result.snapshot, - snapshotSeedSize, - typeof ctx.result.snapshotKittyKeyboardFlags === 'number' + ctx.deps.runtime.seedHeadlessTerminal(ctx.result.id, ctx.result.snapshot, snapshotSeedSize, { + ...(typeof ctx.result.snapshotKittyKeyboardFlags === 'number' ? { kittyKeyboardFlags: ctx.result.snapshotKittyKeyboardFlags } - : {} - ) + : {}), + ...(ctx.result.snapshotTerminalOwner + ? { terminalOwner: ctx.result.snapshotTerminalOwner } + : {}) + }) } else if ( ctx.result.coldRestore && typeof ctx.result.coldRestore.scrollback === 'string' && diff --git a/src/main/ipc/pty/runtime/controller.ts b/src/main/ipc/pty/runtime/controller.ts index 4fce4853bc4..ff5a0e5ccd0 100644 --- a/src/main/ipc/pty/runtime/controller.ts +++ b/src/main/ipc/pty/runtime/controller.ts @@ -12,6 +12,7 @@ import { attachPtyFromRuntimeController, clearBufferFromRuntimeController, confirmForegroundProcessFromRuntimeController, + confirmShellForegroundFromRuntimeController, getCwdFromRuntimeController, getForegroundProcessFromRuntimeController, getRendererSerializerGenerationFromRuntimeController, @@ -55,6 +56,7 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi getForegroundProcess: (ptyId) => getForegroundProcessFromRuntimeController(ptyId), inspectProcess: (ptyId) => inspectProcessFromRuntimeController(ptyId), confirmForegroundProcess: (ptyId) => confirmForegroundProcessFromRuntimeController(ptyId), + confirmShellForeground: (ptyId) => confirmShellForegroundFromRuntimeController(ptyId), getCwd: (ptyId) => getCwdFromRuntimeController(ptyId), hasChildProcesses: (ptyId) => hasChildProcessesFromRuntimeController(ptyId), clearBuffer: (ptyId) => clearBufferFromRuntimeController(deps, ptyId), diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts index 745f225312a..bf3ca74af25 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -114,6 +114,14 @@ export async function confirmForegroundProcessFromRuntimeController(ptyId: strin } } +export async function confirmShellForegroundFromRuntimeController(ptyId: string) { + try { + return (await getProviderForPty(ptyId).confirmShellForeground?.(ptyId)) ?? false + } catch { + return false + } +} + export async function getCwdFromRuntimeController(ptyId: string) { try { const cwd = await getProviderForPty(ptyId).getCwd(ptyId) diff --git a/src/main/providers/agent-foreground-process-windows-job-rejection.test.ts b/src/main/providers/agent-foreground-process-windows-job-rejection.test.ts new file mode 100644 index 00000000000..43fe6f2de53 --- /dev/null +++ b/src/main/providers/agent-foreground-process-windows-job-rejection.test.ts @@ -0,0 +1,21 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { confirmShellForegroundProcess } from './agent-foreground-process' + +const realPlatform = process.platform +afterEach(() => { + Object.defineProperty(process, 'platform', { value: realPlatform }) +}) + +describe('Windows shell confirmation with a rejecting job reader', () => { + it('fails closed instead of rejecting the confirmation', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + // A rejecting reader is missing proof, never a thrown confirmation. + await expect( + confirmShellForegroundProcess(100, 'powershell.exe', { + readWindowsPtyJobProcessIds: async () => { + throw new Error('job handle gone') + } + }) + ).resolves.toBe(false) + }) +}) diff --git a/src/main/providers/agent-foreground-process.test.ts b/src/main/providers/agent-foreground-process.test.ts index a1e02d02fc5..76de68fa0e8 100644 --- a/src/main/providers/agent-foreground-process.test.ts +++ b/src/main/providers/agent-foreground-process.test.ts @@ -13,6 +13,7 @@ const getAllProcessesMock = vi.fn() import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot' import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table' import { + confirmShellForegroundProcess, resolveAgentForegroundProcess, resolveAgentForegroundProcessWithAvailability } from './agent-foreground-process' @@ -219,6 +220,106 @@ describe('resolveAgentForegroundProcess', () => { await expect(resolveAgentForegroundProcess(100, 'zsh')).resolves.toBe('zsh') }) + it('confirms a quoted login shell only when its fresh PTY tree contains shells', async () => { + mockPs(['100 99 Ss+ "/bin/zsh" -l', '101 100 S+ /bin/bash'].join('\n')) + + await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(true) + }) + + it('uses spawned-shell identity instead of a lagging foreground child label', async () => { + mockPs(['100 99 Ss+ /bin/zsh -l'].join('\n')) + + await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(true) + }) + + it('confirms the spawned shell behind a login wrapper while prompt hooks run', async () => { + mockPs( + [ + '100 99 Ss /usr/bin/login -pfl developer /bin/zsh', + '101 100 S+ -zsh', + '102 101 S+ (zsh)', + '103 102 S+ (sed)', + '104 102 R+ (git)' + ].join('\n') + ) + + await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(true) + }) + + it('rejects a foreground nested shell while the spawned shell remains suspended', async () => { + mockPs( + [ + '100 99 Ss /usr/bin/login -pfl developer /bin/zsh', + '101 100 S -zsh', + '102 101 S+ agent-tui', + '103 102 S+ /bin/zsh -i' + ].join('\n') + ) + + await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(false) + }) + + it('rejects shell ownership while a TUI and its nested shell remain in the PTY tree', async () => { + mockPs( + [ + '100 99 Ss /bin/zsh -l', + '101 100 S+ /usr/local/bin/agent-tui', + '102 101 S+ /bin/bash -i' + ].join('\n') + ) + + await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false) + }) + + it('rejects shell ownership while a stopped TUI remains resumable', async () => { + mockPs(['100 99 Ss+ /bin/zsh -l', '101 100 T /usr/local/bin/agent-tui'].join('\n')) + + await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false) + }) + + it('refuses WSL shells: wsl.exe is not a provable shell identity', async () => { + // Why pinned: the WSL job object holds only wsl.exe, so a looser + // isShellProcess would confirm ownership regardless of distro-side state. + await expect(confirmShellForegroundProcess(100, 'wsl.exe')).resolves.toBe(false) + expect(execFileMock).not.toHaveBeenCalled() + }) + + it('fails open when fresh shell ownership inspection is unavailable', async () => { + execFileMock.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { + const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void + callback(new Error('ps unavailable'), { stdout: '', stderr: '' }) + } + ) + + await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false) + }) + + it('confirms a Windows shell from fresh root-only ConPTY membership', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + const readWindowsPtyJobProcessIds = vi.fn(async () => new Set([100])) + + await expect( + confirmShellForegroundProcess(100, 'powershell.exe', { readWindowsPtyJobProcessIds }) + ).resolves.toBe(true) + expect(getAllProcessesMock).not.toHaveBeenCalled() + }) + + it('rejects Windows shell ownership with a child or unavailable membership', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + + await expect( + confirmShellForegroundProcess(100, 'powershell.exe', { + readWindowsPtyJobProcessIds: async () => new Set([100, 101]) + }) + ).resolves.toBe(false) + await expect( + confirmShellForegroundProcess(100, 'powershell.exe', { + readWindowsPtyJobProcessIds: async () => null + }) + ).resolves.toBe(false) + }) + it('does not report Claude print-mode hook descendants as foreground agents', async () => { mockPs( [ diff --git a/src/main/providers/agent-foreground-process.ts b/src/main/providers/agent-foreground-process.ts index 5242bb2b911..ef5d6cb58f8 100644 --- a/src/main/providers/agent-foreground-process.ts +++ b/src/main/providers/agent-foreground-process.ts @@ -10,6 +10,7 @@ import { shouldInspectWindowsAgentForeground, type AgentForegroundResolutionOptions } from './windows-agent-foreground-process' +import { isShellProcess } from '../../shared/shell-process-detection' export type { AgentForegroundResolutionOptions } from './windows-agent-foreground-process' @@ -26,6 +27,13 @@ export type AgentForegroundProcessResolution = { anchorPidForeign?: boolean } +type ShellForegroundConfirmationOptions = { + readWindowsPtyJobProcessIds?: () => + | ReadonlySet + | null + | Promise | null> +} + function collectDescendants( rows: Row[], rootPid: number @@ -49,6 +57,61 @@ function collectDescendants( return descendants } +function commandExecutable(command: string): string { + const trimmed = command.trim().replace(/^[-]/, '') + if (trimmed.startsWith('"') || trimmed.startsWith("'")) { + const closingQuote = trimmed.indexOf(trimmed[0], 1) + return closingQuote === -1 ? trimmed.slice(1) : trimmed.slice(1, closingQuote) + } + return trimmed.split(/\s+/, 1)[0] ?? '' +} + +function executableBasename(command: string): string { + return commandExecutable(command).split(/[\\/]/).pop()?.toLowerCase() ?? '' +} + +export async function confirmShellForegroundProcess( + shellPid: number | null | undefined, + spawnedShellProcess: string | null | undefined, + options: ShellForegroundConfirmationOptions = {} +): Promise { + if (!shellPid || !spawnedShellProcess || !isShellProcess(spawnedShellProcess)) { + return false + } + if (process.platform === 'win32') { + try { + const processIds = await options.readWindowsPtyJobProcessIds?.() + return processIds?.size === 1 && processIds.has(shellPid) + } catch { + // Unavailable job inspection is missing proof, never a thrown confirmation. + return false + } + } + try { + const rows = await getFreshProcessTableSnapshot() + if (!rows.some((row) => row.pid === shellPid)) { + return false + } + const root = rows.find((row) => row.pid === shellPid)! + const tree = [{ ...root, depth: 0 }, ...collectDescendants(rows, shellPid)] + const spawnedShellBasename = executableBasename(spawnedShellProcess) + const foregroundShell = tree + .filter( + (row) => + executableBasename(row.command) === spawnedShellBasename && + isShellProcess(commandExecutable(row.command)) + ) + .sort((left, right) => left.depth - right.depth)[0] + if (tree.some((row) => row.depth > 0 && row.stat.includes('T'))) { + return false + } + const confirmed = foregroundShell?.stat.includes('+') === true + return confirmed + } catch { + return false + } +} + function candidateScore(row: ProcessTableRow & { depth: number }): number { // Why: foreground descendants carry `+` in `ps stat` on Unix PTYs. Prefer // them, then prefer leaf/deeper wrappers so `node /path/bin/codex` beats the diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index d8ddc7cd902..223168b65c0 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -59,7 +59,10 @@ import { resolveWindowsGitBashShellPath } from '../git-bash' import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell' -import { resolveAgentForegroundProcessWithAvailability } from './agent-foreground-process' +import { + confirmShellForegroundProcess, + resolveAgentForegroundProcessWithAvailability +} from './agent-foreground-process' import { resolveStableForegroundProcess } from './stable-foreground-process' import { getAgentForegroundContextPaths } from './agent-foreground-context-paths' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' @@ -1563,6 +1566,21 @@ export class LocalPtyProvider implements IPtyProvider { } } + async confirmShellForeground(id: string): Promise { + const proc = ptyProcesses.get(id) + if (!proc) { + return false + } + const confirmed = await confirmShellForegroundProcess( + proc.pid, + ptyShellName.get(id), + process.platform === 'win32' + ? { readWindowsPtyJobProcessIds: () => readWindowsPtyJobProcessIds(proc) } + : {} + ) + return ptyProcesses.get(id) === proc && confirmed + } + async serialize(_ids: string[]): Promise { return '{}' } diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 333d6fcc62c..d77d6aec1a5 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -11,6 +11,7 @@ import type { } from '../../shared/agent-session-host-authority' import type { PtyProcessInfo } from './pty-process-info' import type { TerminalExitCause } from '../../shared/terminal-exit-cause' +import type { TerminalOwner } from '../../shared/terminal-owner' export type { PtyBackgroundStreamEvent, @@ -37,6 +38,8 @@ export type PtyProviderBufferSnapshot = { * boundary. Absent means the source could not prove them; readers must not * rewrite that silence into a known `0`. */ kittyKeyboardFlags?: number + /** Ordered ownership evidence proven at this snapshot's `seq`. */ + terminalOwner?: TerminalOwner } export type PtySpawnOptions = { @@ -209,6 +212,8 @@ export type IPtyProvider = { getForegroundProcess(id: string): Promise /** Strong process evidence captured after the caller's command boundary. */ confirmForegroundProcess?: (id: string) => Promise + /** Fresh execution-host proof that the spawned shell owns the PTY foreground. */ + confirmShellForeground?: (id: string) => Promise serialize(ids: string[]): Promise revive(state: string): Promise // Why: deadlineMs bounds the underlying RPC exactly like shutdown's deadlineMs. diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 76bb4179069..01cf9193f7e 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -3,6 +3,7 @@ import type { TuiAgent } from '../../shared/tui-agent' import type { AgentSessionClaimedSpawnResult } from '../../shared/agent-session-host-authority' import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' +import type { TerminalOwner } from '../../shared/terminal-owner' export type PtySpawnResult = { agentSessionEnsure?: AgentSessionClaimedSpawnResult @@ -52,6 +53,8 @@ export type PtySpawnResult = { /** Renderer-domain sequence main reconciled for the attach boundary those * flags describe. Set by main, not the provider. */ snapshotSeq?: number + /** Ordered ownership evidence proven by the provider snapshot. */ + snapshotTerminalOwner?: TerminalOwner /** True when the spawn reattached to an existing daemon session. */ isReattach?: boolean /** Last OSC title tracked by the daemon session the snapshot came from. diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index 0fc8702750d..08a73109e5c 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -25,6 +25,13 @@ describe('SshPtyProvider', () => { expect(provider.canProvideAuthoritativeBufferSnapshot(scopedPty1)).toBe(false) }) + it('fails closed without foreground-shell proof on direct SSH', () => { + expect( + (provider as { confirmShellForeground?: unknown }).confirmShellForeground + ).toBeUndefined() + expect(mux.request).not.toHaveBeenCalled() + }) + it('keeps a shared claim probe alive when one waiter disconnects', async () => { let finishProbe!: (result: { agentSessionClaimVersion: number }) => void mux.request.mockReturnValueOnce( diff --git a/src/main/runtime/mobile-session-tabs-churn-coalescing.test.ts b/src/main/runtime/mobile-session-tabs-churn-coalescing.test.ts new file mode 100644 index 00000000000..7fe33b66ee1 --- /dev/null +++ b/src/main/runtime/mobile-session-tabs-churn-coalescing.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import type * as GitUsernameModule from '../git/git-username' +import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' +import { OrcaRuntimeService } from './orca-runtime' + +vi.mock('../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue([]), + listWorktreesStrict: vi.fn().mockResolvedValue([]) +})) + +vi.mock('../hooks', () => ({ + getEffectiveHooks: vi.fn().mockReturnValue(null), + runHook: vi.fn().mockResolvedValue({ success: true, output: '' }) +})) +vi.mock('../worktree-runner-script', () => ({ createSetupRunnerScript: vi.fn() })) + +vi.mock('../ipc/worktree-logic', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { ...actual, computeWorktreePath: vi.fn(), ensurePathWithinWorkspace: vi.fn() } +}) + +vi.mock('../ipc/registered-worktree-roots-cache', () => ({ + invalidateAuthorizedRootsCache: vi.fn() +})) + +vi.mock('../git/repo', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'), + getBranchConflictKind: vi.fn().mockResolvedValue(null) + } +}) + +vi.mock('../git/git-username', async () => { + const actual = await vi.importActual('../git/git-username') + return { ...actual, resolveLocalGitUsername: vi.fn(async () => '') } +}) + +const store = { + getRepo: () => ({ + id: 'repo-1', + path: '/tmp/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + }), + getRepos: () => [store.getRepo()], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + getGitHubCache: () => ({ pr: {}, issue: {} }), + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getRetiredWorktreeNameRegistry: () => ({ exhaustedTiers: 0, names: [] }), + addRetiredWorktreeName: () => {}, + mergeRetiredWorktreeNames: () => false, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '', + mobileAutoRestoreFitMs: null + }), + updateSettings: () => {} +} + +type SessionTabsPrivate = { + mobileSessionTabsByWorktree: Map + touchMobileSessionSnapshotsForPty: (ptyId: string) => void + notifyMobileSessionTabsChanged: (worktreeId?: string) => void +} + +function seedPtyBackedSnapshot(runtime: OrcaRuntimeService, ptyId: string): void { + const priv = runtime as unknown as SessionTabsPrivate + priv.mobileSessionTabsByWorktree.set('worktree-a', { + worktree: 'worktree-a', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'tab-1', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'tab-1', + title: 'agent', + parentTabId: 'group-1', + leafId: 'leaf-1', + ptyId, + isActive: true + } + ] + }) +} + +describe('session.tabs title/status churn coalescing', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('collapses a rapid title-flip burst into a single emit (repro)', () => { + const runtime = new OrcaRuntimeService(store) + seedPtyBackedSnapshot(runtime, 'pty-1') + const priv = runtime as unknown as SessionTabsPrivate + + const emits: number[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + emits.push(snapshot.snapshotVersion) + }) + + // A spinner-in-title agent flips the title ~20 times within a second. + const FLIPS = 20 + for (let i = 0; i < FLIPS; i++) { + priv.touchMobileSessionSnapshotsForPty('pty-1') + vi.advanceTimersByTime(10) + } + // Nothing has fired yet — all flips landed inside the trailing window. + expect(emits).toHaveLength(0) + + vi.advanceTimersByTime(50) + + // Pre-fix this fanned out one emit per flip; now it is a single emit. + expect(emits).toHaveLength(1) + // The emitted version is the freshest (monotonic, never a stale one). + const stored = priv.mobileSessionTabsByWorktree.get('worktree-a') + expect(emits[0]).toBe(stored?.snapshotVersion) + expect(emits[0]).toBe(1 + FLIPS) + + unsubscribe() + }) + + it('lets a structural change emit immediately and supersede the coalesced notify', () => { + const runtime = new OrcaRuntimeService(store) + seedPtyBackedSnapshot(runtime, 'pty-1') + const priv = runtime as unknown as SessionTabsPrivate + + const emits: number[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + emits.push(snapshot.snapshotVersion) + }) + + // Title churn schedules a coalesced notify... + priv.touchMobileSessionSnapshotsForPty('pty-1') + // ...then a structural change (e.g. tab activated) demands a prompt emit. + priv.notifyMobileSessionTabsChanged('worktree-a') + expect(emits).toHaveLength(1) + + // The immediate emit cancelled the pending coalesced notify — no duplicate trailing emit. + vi.advanceTimersByTime(50) + expect(emits).toHaveLength(1) + + unsubscribe() + }) + + it('flushes a pending notify when the last subscriber closes', () => { + const runtime = new OrcaRuntimeService(store) + seedPtyBackedSnapshot(runtime, 'pty-1') + const priv = runtime as unknown as SessionTabsPrivate + + const emits: number[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + emits.push(snapshot.snapshotVersion) + }) + + priv.touchMobileSessionSnapshotsForPty('pty-1') + expect(emits).toHaveLength(0) + + // Closing the subscription flushes the pending window so the final state reaches the listener. + unsubscribe() + expect(emits).toHaveLength(1) + }) +}) diff --git a/src/main/runtime/mobile-subscribe-integration.test.ts b/src/main/runtime/mobile-subscribe-integration.test.ts index 292cc13a816..4a7195e3963 100644 --- a/src/main/runtime/mobile-subscribe-integration.test.ts +++ b/src/main/runtime/mobile-subscribe-integration.test.ts @@ -3,7 +3,6 @@ */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import type * as GitUsernameModule from '../git/git-username' -import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' import { OrcaRuntimeService } from './orca-runtime' vi.mock('../git/worktree', () => ({ @@ -211,6 +210,7 @@ describe('mobile subscribe integration', () => { } outputSequence: number writeChain: Promise + ownership: { settle: () => Promise; owner: undefined } } const runtimePrivate = runtime as unknown as { headlessTerminals: Map @@ -221,7 +221,8 @@ describe('mobile subscribe integration', () => { getSnapshot: () => ({ rehydrateSequences: '', snapshotAnsi: '', cols: 90, rows: 30 }) }, outputSequence: 17, - writeChain: Promise.resolve() + writeChain: Promise.resolve(), + ownership: { settle: async () => {}, owner: undefined } }) await expect(runtime.serializeMainTerminalBuffer('pty-empty')).resolves.toEqual({ @@ -979,107 +980,4 @@ describe('mobile subscribe integration', () => { expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(false) }) }) - - describe('session.tabs title/status churn coalescing', () => { - type SessionTabsPrivate = { - mobileSessionTabsByWorktree: Map - touchMobileSessionSnapshotsForPty: (ptyId: string) => void - notifyMobileSessionTabsChanged: (worktreeId?: string) => void - } - - function seedPtyBackedSnapshot(runtime: OrcaRuntimeService, ptyId: string): void { - const priv = runtime as unknown as SessionTabsPrivate - priv.mobileSessionTabsByWorktree.set('worktree-a', { - worktree: 'worktree-a', - publicationEpoch: 'epoch-1', - snapshotVersion: 1, - activeGroupId: 'group-1', - activeTabId: 'tab-1', - activeTabType: 'terminal', - tabs: [ - { - type: 'terminal', - id: 'tab-1', - title: 'agent', - parentTabId: 'group-1', - leafId: 'leaf-1', - ptyId, - isActive: true - } - ] - }) - } - - it('collapses a rapid title-flip burst into a single emit (repro)', () => { - const { runtime } = createRuntime() - seedPtyBackedSnapshot(runtime, 'pty-1') - const priv = runtime as unknown as SessionTabsPrivate - - const emits: number[] = [] - const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { - emits.push(snapshot.snapshotVersion) - }) - - // A spinner-in-title agent flips the title ~20 times within a second. - const FLIPS = 20 - for (let i = 0; i < FLIPS; i++) { - priv.touchMobileSessionSnapshotsForPty('pty-1') - vi.advanceTimersByTime(10) - } - // Nothing has fired yet — all flips landed inside the trailing window. - expect(emits).toHaveLength(0) - - vi.advanceTimersByTime(50) - - // Pre-fix this fanned out one emit per flip; now it is a single emit. - expect(emits).toHaveLength(1) - // The emitted version is the freshest (monotonic, never a stale one). - const stored = priv.mobileSessionTabsByWorktree.get('worktree-a') - expect(emits[0]).toBe(stored?.snapshotVersion) - expect(emits[0]).toBe(1 + FLIPS) - - unsubscribe() - }) - - it('lets a structural change emit immediately and supersede the coalesced notify', () => { - const { runtime } = createRuntime() - seedPtyBackedSnapshot(runtime, 'pty-1') - const priv = runtime as unknown as SessionTabsPrivate - - const emits: number[] = [] - const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { - emits.push(snapshot.snapshotVersion) - }) - - // Title churn schedules a coalesced notify... - priv.touchMobileSessionSnapshotsForPty('pty-1') - // ...then a structural change (e.g. tab activated) demands a prompt emit. - priv.notifyMobileSessionTabsChanged('worktree-a') - expect(emits).toHaveLength(1) - - // The immediate emit cancelled the pending coalesced notify — no duplicate trailing emit. - vi.advanceTimersByTime(50) - expect(emits).toHaveLength(1) - - unsubscribe() - }) - - it('flushes a pending notify when the last subscriber closes', () => { - const { runtime } = createRuntime() - seedPtyBackedSnapshot(runtime, 'pty-1') - const priv = runtime as unknown as SessionTabsPrivate - - const emits: number[] = [] - const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { - emits.push(snapshot.snapshotVersion) - }) - - priv.touchMobileSessionSnapshotsForPty('pty-1') - expect(emits).toHaveLength(0) - - // Closing the subscription flushes the pending window so the final state reaches the listener. - unsubscribe() - expect(emits).toHaveLength(1) - }) - }) }) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index ea21fda6fff..60926fc873c 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -11813,6 +11813,50 @@ describe('OrcaRuntimeService', () => { }) }) + it('binds shell ownership evidence to the headless snapshot sequence', async () => { + const runtime = createRuntime() + let resolveConfirmation: ((confirmed: boolean) => void) | undefined + const confirmShellForeground = vi.fn( + () => new Promise((resolve) => void (resolveConfirmation = resolve)) + ) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + confirmShellForeground + }) + syncSinglePty(runtime, 'pty-1') + + runtime.onPtyData('pty-1', '\x1b[?1049hTUI\x1b]133;D;137\x07shell-marker', 100) + const shellSnapshotPromise = runtime.serializeHiddenOutputRecoveryBuffer('pty-1') + await vi.waitFor(() => expect(confirmShellForeground).toHaveBeenCalledTimes(1)) + let snapshotSettled = false + void shellSnapshotPromise.then(() => { + snapshotSettled = true + }) + await Promise.resolve() + expect(snapshotSettled).toBe(false) + + resolveConfirmation?.(true) + const shellSnapshot = await shellSnapshotPromise + // Why alternateScreen stays true here: the mirror never rewrites its own + // model — without a daemon barrier injecting the reset in-stream (direct + // provider path), the snapshot publishes the poisoned mode alongside the + // proof and the renderer's dead-TUI branch grounds the pane. + expect(shellSnapshot).toMatchObject({ + alternateScreen: true, + terminalOwner: 'shell', + seq: '\x1b[?1049hTUI\x1b]133;D;137\x07shell-marker'.length + }) + expect(confirmShellForeground).toHaveBeenCalledTimes(1) + + runtime.onPtyData('pty-1', '\x1b]133;C\x07\x1b[?1049hLIVE-TUI', 101) + const liveSnapshot = await runtime.serializeHiddenOutputRecoveryBuffer('pty-1') + + expect(liveSnapshot?.alternateScreen).toBe(true) + expect(liveSnapshot?.terminalOwner).toBeUndefined() + }) + it('keeps an empty headless snapshot authoritative for hidden-output recovery', async () => { const serializeBuffer = vi.fn().mockResolvedValue({ data: 'stale renderer content\r\n', @@ -11839,6 +11883,7 @@ describe('OrcaRuntimeService', () => { } outputSequence: number writeChain: Promise + ownership: { settle: () => Promise; owner: undefined } } const runtimePrivate = runtime as unknown as { headlessTerminals: Map @@ -11849,7 +11894,8 @@ describe('OrcaRuntimeService', () => { getSnapshot: () => ({ rehydrateSequences: '', snapshotAnsi: '', cols: 90, rows: 30 }) }, outputSequence: 17, - writeChain: Promise.resolve() + writeChain: Promise.resolve(), + ownership: { settle: async () => {}, owner: undefined } }) await expect(runtime.serializeHiddenOutputRecoveryBuffer('pty-empty')).resolves.toEqual({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 593a5f1db41..b8196482870 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1199,6 +1199,7 @@ import { } from '../ipc/watcher-removal-drain' import { withWorktreeSpan } from '../observability/instrumentation' import { HeadlessEmulator } from '../daemon/headless-emulator' +import { PtyShellOwnershipMirror } from './pty-shell-ownership-mirror' import { isNativeWindowsConptyPty, registerConptyDa1OverrideInstaller, @@ -1863,6 +1864,7 @@ type RuntimeHeadlessTerminal = { // the seq actually painted into this emulator, not the latest PTY seq. outputSequence: number writeChain: Promise + ownership: PtyShellOwnershipMirror } export type RuntimePtyDataAdmission = Readonly<{ @@ -1916,6 +1918,7 @@ type RuntimeTerminalBufferSnapshot = { /** Effective kitty flags proven at this snapshot's own `seq`. Absent means * the winning source could not prove them. */ kittyKeyboardFlags?: number + terminalOwner?: 'shell' } type HeadlessSeedMetadata = { @@ -1927,6 +1930,7 @@ type HeadlessSeedMetadata = { * emulator so hidden `CSI ? u` answers the real flags instead of ?0u * (terminal-query-authority.md §kitty). */ kittyKeyboardFlags?: number + terminalOwner?: 'shell' } type RuntimePtyController = { @@ -2022,6 +2026,7 @@ type RuntimePtyController = { ptyId: string ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean; unavailable?: true }> confirmForegroundProcess?(ptyId: string): Promise + confirmShellForeground?(ptyId: string): Promise hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise resize?(ptyId: string, cols: number, rows: number): boolean @@ -13150,6 +13155,7 @@ export class OrcaRuntimeService { oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean scrollbackAnsi?: string + terminalOwner?: 'shell' } | null> { return this.serializeHeadlessTerminalBuffer(ptyId, { ...opts, includeEmpty: true }) } @@ -13170,6 +13176,7 @@ export class OrcaRuntimeService { alternateScreen?: boolean scrollbackAnsi?: string pendingEscapeTailAnsi?: string + terminalOwner?: 'shell' } | null> { const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, { ...opts, @@ -13270,6 +13277,12 @@ export class OrcaRuntimeService { if (metadata.oscLinks !== undefined) { state.emulator.setRestoredOscLinks(metadata.oscLinks) } + // Why derived from the emulator: the seed bytes bypass ownership.scan, + // so the scanner must inherit the restored alternate-screen state or a + // pane seeded mid-TUI never arms its recovery trigger. + state.ownership.seedOwner(metadata.terminalOwner, { + alternateScreen: state.emulator.isAlternateScreen + }) this.providerSnapshotPreferredPtys.delete(ptyId) }) .catch(() => { @@ -13377,6 +13390,9 @@ export class OrcaRuntimeService { // (they feed main's tracker only), so its serializer lastTitle can be // stale here. Prefer main's tracked title; the renderer's is only the // seed when main has observed none (fresh relaunch, cold tracker). + state.ownership.seedOwner(undefined, { + alternateScreen: state.emulator.isAlternateScreen + }) const seedTitle = this.getTrackedRawTitleForPty(ptyId) ?? rendered.lastTitle if (seedTitle) { state.emulator.setLastTitle(seedTitle) @@ -13452,6 +13468,9 @@ export class OrcaRuntimeService { const completion = state.writeChain.then(async () => { // Why: the ingestion-time ownership decision is closed over this // chain link; async scheduling cannot retroactively change it. + // Why inside the chain: the ownership mirror must observe live bytes in + // the same total order as seeds (seedOwner also runs on this chain). + state.ownership.scan(data) await state.emulator.write(data, { forwardQueryReplies }) state.outputSequence = outputSequence }) @@ -13511,7 +13530,28 @@ export class OrcaRuntimeService { if (viewAttributes) { emulator.applyPushedViewAttributes(viewAttributes) } - state = { emulator, outputSequence: 0, writeChain: Promise.resolve() } + const constructed: RuntimeHeadlessTerminal = { + emulator, + outputSequence: 0, + writeChain: Promise.resolve(), + ownership: new PtyShellOwnershipMirror(async () => { + const controller = this.ptyController + const lifecycleGeneration = this.getPtyLifecycleGeneration(ptyId) + if ( + !controller?.confirmShellForeground || + this.headlessTerminals.get(ptyId) !== constructed + ) { + return false + } + const confirmed = await controller.confirmShellForeground(ptyId) + return ( + confirmed && + this.headlessTerminals.get(ptyId) === constructed && + this.getPtyLifecycleGeneration(ptyId) === lifecycleGeneration + ) + }) + } + state = constructed return state } @@ -13563,6 +13603,9 @@ export class OrcaRuntimeService { if (snapshot.oscLinks !== undefined) { state.emulator.setRestoredOscLinks(snapshot.oscLinks) } + state.ownership.seedOwner(snapshot.terminalOwner, { + alternateScreen: state.emulator.isAlternateScreen + }) state.outputSequence = snapshot.seq }) .catch(() => { @@ -13621,6 +13664,7 @@ export class OrcaRuntimeService { alternateScreen?: boolean pendingEscapeTailAnsi?: string kittyKeyboardFlags?: number + terminalOwner?: 'shell' } | null> { if (this.providerSnapshotPreferredPtys.has(ptyId)) { // Why: pre-attach stream bytes only form a suffix of restored state. A @@ -14101,6 +14145,7 @@ export class OrcaRuntimeService { alternateScreen?: boolean scrollbackAnsi?: string kittyKeyboardFlags?: number + terminalOwner?: 'shell' // Why: dangling mid-escape tail the restorer must write LAST, after any // reset, so the next live chunk completes it instead of rendering it // literally (Bug E / #7329). @@ -14111,11 +14156,12 @@ export class OrcaRuntimeService { return null } await state.writeChain + await state.ownership.settle() // Why: normal history is separated from an active alternate frame, so the // caller's scrollback policy can be honored without painting it into alt. - const isAlternateScreen = state.emulator.isAlternateScreen const scrollbackRows = opts.scrollbackRows ?? 0 const snapshot = state.emulator.getSnapshot({ scrollbackRows }) + const terminalOwner = state.ownership.owner const data = snapshot.rehydrateSequences + snapshot.snapshotAnsi return data.length > 0 || opts.includeEmpty === true ? this.preferTrackedLastTitle(ptyId, { @@ -14138,10 +14184,11 @@ export class OrcaRuntimeService { ...(snapshot.pendingEscapeTailAnsi ? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi } : {}), + ...(terminalOwner ? { terminalOwner } : {}), // Why: lets the renderer skip the destructive scrollback clear when // restoring an alt-screen snapshot — clearing wipes xterm's own // history that the TUI relies on for scroll-up after a tab return. - alternateScreen: isAlternateScreen, + alternateScreen: snapshot.modes?.alternateScreen ?? state.emulator.isAlternateScreen, // Why NOT folded into data: the renderer writes its post-replay // reset after data, and any ESC after a dangling partial aborts it. // The restorer writes this last (Bug E fix). @@ -14161,6 +14208,7 @@ export class OrcaRuntimeService { // sever the reply sink now so they cannot write to a respawned PTY that // reused this id (belt to the sink's state-identity check). state.emulator.disableQueryReplyForwarding() + state.ownership.dispose() state.writeChain.finally(() => state.emulator.dispose()).catch(() => state.emulator.dispose()) } diff --git a/src/main/runtime/pty-shell-ownership-mirror.test.ts b/src/main/runtime/pty-shell-ownership-mirror.test.ts new file mode 100644 index 00000000000..20468a194cd --- /dev/null +++ b/src/main/runtime/pty-shell-ownership-mirror.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from 'vitest' +import { PtyShellOwnershipMirror } from './pty-shell-ownership-mirror' + +describe('PtyShellOwnershipMirror', () => { + it('arms the recovery trigger from a seeded alternate-screen state', async () => { + const confirm = vi.fn(async () => true) + const mirror = new PtyShellOwnershipMirror(confirm) + + // Restored bytes bypass scan(); the seed must carry the alt state or a + // pane reattached mid-TUI never proves ownership after the TUI dies. + mirror.seedOwner(undefined, { alternateScreen: true }) + mirror.scan('\x1b]133;D;137\x07') + + await vi.waitFor(() => expect(confirm).toHaveBeenCalledTimes(1)) + await mirror.settle() + expect(mirror.owner).toBe('shell') + }) + + it('does not arm from a seed on the normal buffer', async () => { + const confirm = vi.fn(async () => true) + const mirror = new PtyShellOwnershipMirror(confirm) + + mirror.seedOwner(undefined, { alternateScreen: false }) + mirror.scan('\x1b]133;D;0\x07') + + expect(confirm).not.toHaveBeenCalled() + expect(mirror.owner).toBeUndefined() + }) + + it('revokes a seeded owner on later TUI bytes', () => { + const mirror = new PtyShellOwnershipMirror(async () => true) + mirror.seedOwner('shell', { alternateScreen: false }) + expect(mirror.owner).toBe('shell') + + mirror.scan('\x1b[?1003h') + expect(mirror.owner).toBeUndefined() + }) + + it('bounds settle() when a proof hangs', async () => { + const mirror = new PtyShellOwnershipMirror(() => new Promise(() => {})) + mirror.seedOwner(undefined, { alternateScreen: true }) + mirror.scan('\x1b]133;D;137\x07') + + const start = Date.now() + await mirror.settle() + expect(Date.now() - start).toBeLessThan(2000) + expect(mirror.owner).toBeUndefined() + }) + + it('retires a hung confirmation so a later candidate can still prove ownership', async () => { + let calls = 0 + const mirror = new PtyShellOwnershipMirror(() => { + calls += 1 + return calls === 1 ? new Promise(() => {}) : Promise.resolve(true) + }) + mirror.seedOwner(undefined, { alternateScreen: true }) + mirror.scan('\x1b]133;D;137\x07') + // The settle deadline outlives the attempt deadline, so this waits out the + // retirement of the hung attempt. + await mirror.settle() + expect(mirror.owner).toBeUndefined() + + mirror.scan('\x1b[?1049hTUI\x1b]133;D;137\x07') + await vi.waitFor(() => expect(calls).toBe(2)) + await mirror.settle() + expect(mirror.owner).toBe('shell') + }) + + it('contains a synchronously throwing confirm callback', async () => { + let calls = 0 + const mirror = new PtyShellOwnershipMirror(() => { + calls += 1 + if (calls === 1) { + throw new Error('sync boom') + } + return Promise.resolve(true) + }) + mirror.seedOwner(undefined, { alternateScreen: true }) + expect(() => mirror.scan('\x1b]133;D;137\x07')).not.toThrow() + await mirror.settle() + expect(mirror.owner).toBeUndefined() + + mirror.scan('\x1b[?1049hTUI\x1b]133;D;137\x07') + await vi.waitFor(() => expect(calls).toBe(2)) + await mirror.settle() + expect(mirror.owner).toBe('shell') + }) +}) diff --git a/src/main/runtime/pty-shell-ownership-mirror.ts b/src/main/runtime/pty-shell-ownership-mirror.ts new file mode 100644 index 00000000000..5c54465be75 --- /dev/null +++ b/src/main/runtime/pty-shell-ownership-mirror.ts @@ -0,0 +1,149 @@ +import { TerminalShellLifecycleScanner } from '../daemon/terminal-shell-lifecycle-scanner' +import type { TerminalOwner } from '../../shared/terminal-owner' + +// Why bounded: a serialize awaiting settle() must not inherit a slow daemon RPC +// or ps fork; a proof that lands later is still generation-guarded. +const SETTLE_DEADLINE_MS = 750 + +/** + * Mirror-side shell ownership for the runtime's per-PTY headless emulator. + * Never pauses or injects: for daemon-backed PTYs the host barrier already + * ordered the stream (any recovery reset arrives as ordinary bytes), so a + * trigger here is only a proof candidate answered by the host's settled + * verdict; for direct local PTYs the answer is a fresh process inspection. + * Fed the same bytes the emulator parses, in the same order. + */ +export class PtyShellOwnershipMirror { + private readonly scanner = new TerminalShellLifecycleScanner() + private confirmInFlight = false + private confirmAttempt = 0 + private latestCandidateGeneration: number | undefined + private settleWaiters: (() => void)[] = [] + private disposed = false + + constructor(private readonly confirm: () => Promise) {} + + scan(data: string): void { + if (this.disposed) { + return + } + let rest = data + while (rest.length > 0) { + const events = this.scanner.scan(rest) + if (events.cleanExitCandidate) { + this.startConfirmation(events.cleanExitCandidate.generation) + } + if (events.uncleanDeathTriggerEnd === undefined) { + return + } + // The stream owner already resolved this boundary; ask it, keep scanning. + this.startConfirmation(this.scanner.generation) + rest = rest.slice(events.uncleanDeathTriggerEnd) + } + } + + get owner(): TerminalOwner | undefined { + return this.scanner.owner + } + + seedOwner(owner: TerminalOwner | undefined, opts: { alternateScreen?: boolean } = {}): void { + this.scanner.seedOwner(owner, opts) + } + + /** Waits out an in-flight proof for the current generation; a generation + * advance past the entry point ends the wait (that proof is stale), and the + * wait is bounded — serialize latency must never inherit a slow RPC or + * process inspection; an unsettled proof just reads as no owner. */ + async settle(): Promise { + const target = this.scanner.generation + let deadlineHit = false + const deadline = setTimeout(() => { + deadlineHit = true + this.resolveSettleWaiters() + }, SETTLE_DEADLINE_MS) + deadline.unref?.() + try { + while ( + this.confirmInFlight && + this.scanner.generation <= target && + !this.disposed && + !deadlineHit + ) { + await new Promise((resolve) => this.settleWaiters.push(resolve)) + } + } finally { + clearTimeout(deadline) + } + } + + dispose(): void { + this.disposed = true + this.resolveSettleWaiters() + } + + private startConfirmation(generation: number): void { + this.latestCandidateGeneration = generation + if (this.confirmInFlight || this.disposed) { + return + } + this.confirmInFlight = true + const requested = generation + const attempt = ++this.confirmAttempt + // Why the deadline: a confirm that never settles (hung RPC) must not wedge + // ownership confirmation for the rest of the PTY's life; retire the attempt + // so a later candidate can start a fresh one. + const deadline = setTimeout( + () => this.retireConfirmAttempt(attempt, requested), + SETTLE_DEADLINE_MS + ) + deadline.unref?.() + // Why the guard: the callback is injected; a synchronous throw must not + // strand confirmInFlight. + let proof: Promise + try { + proof = this.confirm() + } catch { + proof = Promise.resolve(false) + } + void proof + .then((confirmed) => { + // A retired attempt's late verdict is inert: an unsettled proof already + // read as no owner, and flipping it afterwards would race fresh bytes. + if (attempt === this.confirmAttempt && confirmed && !this.disposed) { + this.scanner.trySetOwner(requested) + } + }) + .catch(() => {}) + .finally(() => { + clearTimeout(deadline) + this.retireConfirmAttempt(attempt, requested) + }) + } + + private retireConfirmAttempt(attempt: number, requested: number): void { + if (attempt !== this.confirmAttempt) { + return + } + this.confirmAttempt += 1 + this.confirmInFlight = false + const latest = this.latestCandidateGeneration + if ( + latest !== undefined && + latest !== requested && + latest === this.scanner.generation && + !this.disposed + ) { + // One superseding candidate can still be current; stale ones are not retried. + this.startConfirmation(latest) + } + this.resolveSettleWaiters() + } + + private resolveSettleWaiters(): void { + const waiters = this.settleWaiters + this.settleWaiters = [] + for (const waiter of waiters) { + waiter() + } + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts index fcae3d02f6f..72c8939cb6c 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts @@ -162,6 +162,8 @@ export async function publishLegacyBinaryInitialSnapshot( truncated: initialOutputOverflowed, truncatedByByteBudget: serialized?.truncatedByByteBudget, oscLinks: serialized?.oscLinks, + alternateScreen: serialized?.alternateScreen, + terminalOwner: serialized?.terminalOwner, data: serialized?.data ?? '' }) console.log('[mobile-terminal-stream] snapshot', { diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts index a469b2d7a00..ac9e69dd0fc 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts @@ -76,6 +76,8 @@ export function installMultiplexFlowControl( seq: serialized.seq, source: serialized.source, kittyKeyboardFlags: serialized.kittyKeyboardFlags, + alternateScreen: serialized.alternateScreen, + terminalOwner: serialized.terminalOwner, truncatedByByteBudget: serialized.truncatedByByteBudget, data: serialized.data } diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts index 724e14f2cc5..6f1c2f8fc47 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts @@ -95,6 +95,8 @@ export async function publishMultiplexInitialSnapshot( truncatedByByteBudget: serialized?.truncatedByByteBudget, source: serialized?.source, kittyKeyboardFlags: serialized?.kittyKeyboardFlags, + alternateScreen: serialized?.alternateScreen, + terminalOwner: serialized?.terminalOwner, oscLinks: serialized?.oscLinks, pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, data: serialized?.data ?? (read.tail.length > 0 ? `${read.tail.join('\r\n')}\r\n` : '') diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts index 7a4ae50e701..1020b2542d4 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts @@ -235,6 +235,8 @@ export function installMultiplexSlotFrames( cwd: serialized?.cwd, source: serialized?.source, kittyKeyboardFlags: serialized?.kittyKeyboardFlags, + alternateScreen: serialized?.alternateScreen, + terminalOwner: serialized?.terminalOwner, oscLinks: serialized?.oscLinks, pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, truncated: false, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts index 33b09a0ead9..39f4465b8e3 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts @@ -79,6 +79,17 @@ export function sendSnapshotFrames( ...(typeof options.seq === 'number' && options.kittyKeyboardFlags !== undefined ? { kittyKeyboardFlags: options.kittyKeyboardFlags } : {}), + ...(typeof options.seq === 'number' && options.terminalOwner + ? { terminalOwner: options.terminalOwner } + : {}), + // The terminalOwner conjunct is load-bearing, not redundant: no consumer + // re-checks it, and an un-gated alternateScreen would flip the renderer's + // mouse-reset selection on every alt-screen reattach of a live TUI. + ...(typeof options.seq === 'number' && + options.terminalOwner && + options.alternateScreen !== undefined + ? { alternateScreen: options.alternateScreen } + : {}), truncated: options.truncated === true, truncatedByByteBudget: options.truncatedByByteBudget === true }) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts b/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts index 484a9f0f933..121cf9a7adb 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts @@ -27,6 +27,8 @@ export type SnapshotFrameOptions = { pendingEscapeTailAnsi?: string /** Effective kitty flags proven at this frame's own `seq`. */ kittyKeyboardFlags?: number + alternateScreen?: boolean + terminalOwner?: 'shell' } export type SerializedSnapshot = { @@ -42,6 +44,8 @@ export type SerializedSnapshot = { truncatedByByteBudget: boolean pendingEscapeTailAnsi?: string kittyKeyboardFlags?: number + alternateScreen?: boolean + terminalOwner?: 'shell' } | null export type TerminalViewportClient = { diff --git a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts index 88125c840b3..4b9afed46c6 100644 --- a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts @@ -47,8 +47,11 @@ describe('terminal.multiplex pending-escape-tail threading (#7329)', () => { data: 'user@host:~$ ', cols: 80, rows: 24, + seq: 7, // The dangling partial the emulator could not serialize. - pendingEscapeTailAnsi: '\x1b[3' + pendingEscapeTailAnsi: '\x1b[3', + alternateScreen: false, + terminalOwner: 'shell' }), getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), getMobileDisplayMode: vi.fn().mockReturnValue('auto'), @@ -119,7 +122,9 @@ describe('terminal.multiplex pending-escape-tail threading (#7329)', () => { .map((frame) => decodeTerminalStreamFrame(frame)) .find((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart)! expect(decodeTerminalStreamJson(snapshotStart.payload)).toMatchObject({ - pendingEscapeTailAnsi: '\x1b[3' + pendingEscapeTailAnsi: '\x1b[3', + alternateScreen: false, + terminalOwner: 'shell' }) runtime.cleanupSubscription('terminal-multiplex:conn-1') diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index f7381c5665c..b6c53668f2d 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -595,6 +595,7 @@ describe('terminal subscribe buffering', () => { endCol: number uri: string }[] + terminalOwner?: 'shell' }) => void)[] = [] const serializeTerminalBuffer = vi .fn() @@ -611,6 +612,7 @@ describe('terminal subscribe buffering', () => { endCol: number uri: string }[] + terminalOwner?: 'shell' }>((resolve) => { restreamResolves.push(resolve) }) @@ -682,7 +684,8 @@ describe('terminal subscribe buffering', () => { data: 'newer', cols: 100, rows: 24, - oscLinks: newerOscLinks + oscLinks: newerOscLinks, + terminalOwner: 'shell' }) await vi.waitFor(() => expect( @@ -711,6 +714,9 @@ describe('terminal subscribe buffering', () => { kind: 'resized', oscLinks: newerOscLinks }) + expect(snapshotStart && decodeTerminalStreamJson(snapshotStart.payload)).not.toHaveProperty( + 'terminalOwner' + ) runtime.cleanupSubscription('terminal-1:phone-1') await dispatchPromise diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index d69d906047d..fc558b60d74 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -57,6 +57,7 @@ export type PtyApi = { snapshotFrameAnsi?: string snapshotFrameRestoreAnsi?: string snapshotKittyKeyboardFlags?: number + snapshotTerminalOwner?: 'shell' snapshotSeq?: number isReattach?: boolean isAlternateScreen?: boolean @@ -143,6 +144,7 @@ export type PtyApi = { /** Effective kitty flags the snapshot owner proved at `seq`. Absent means * unknown; consumers must not turn that into a known `0`. */ kittyKeyboardFlags?: number + terminalOwner?: 'shell' } | null> getRendererDeliveryDebugSnapshot: () => Promise<{ pendingPtyCount: number diff --git a/src/preload/index.ts b/src/preload/index.ts index 9efc4716019..d36b385d10a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1057,6 +1057,7 @@ const api = { snapshotFrameAnsi?: string snapshotFrameRestoreAnsi?: string snapshotKittyKeyboardFlags?: number + snapshotTerminalOwner?: 'shell' snapshotSeq?: number isReattach?: boolean isAlternateScreen?: boolean @@ -1179,6 +1180,7 @@ const api = { scrollbackAnsi?: string pendingEscapeTailAnsi?: string kittyKeyboardFlags?: number + terminalOwner?: 'shell' } | null> => ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }), getRendererDeliveryDebugSnapshot: (): Promise<{ diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts index 3f3756c0716..3dda2f83fec 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts @@ -25,6 +25,9 @@ export function projectIpcPtyConnectResult( ...(spawnResult.snapshotFrameRestoreAnsi !== undefined ? { snapshotFrameRestoreAnsi: spawnResult.snapshotFrameRestoreAnsi } : {}), + ...(spawnResult.snapshotTerminalOwner + ? { snapshotTerminalOwner: spawnResult.snapshotTerminalOwner } + : {}), isAlternateScreen: spawnResult.isAlternateScreen, sessionExpired: spawnResult.sessionExpired, coldRestore: spawnResult.coldRestore, diff --git a/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts index 0e130d0b00e..2a20be95575 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts @@ -1,6 +1,7 @@ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + POST_REPLAY_DEAD_TUI_RESET, POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, RESET_GRAPHIC_RENDITION @@ -220,6 +221,42 @@ describe('connectPanePty', () => { } }) + it('returns a shell-owned daemon snapshot to the normal buffer on reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => + sessionId + ? { + id: sessionId, + snapshot: '\x1b[?1049h\x1b[?1003hSTALE-TUI', + isAlternateScreen: true, + snapshotTerminalOwner: 'shell' + } + : null + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + + const pane = createPane(1) + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) as never + ) + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_DEAD_TUI_RESET, + expect.any(Function) + ) + }) + it('drops a too-wide daemon alt frame and keeps the scrollback prefix', async () => { // Why: fixed-grid alt rows clip at a narrower viewport until the owner repaints. const { connectPanePty } = await import('./pty-connection') diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts index 905ec2bf687..fe18699c20d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts @@ -1,8 +1,10 @@ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + POST_REPLAY_DEAD_TUI_RESET, POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET, - POST_REPLAY_LIVE_SNAPSHOT_RESET + POST_REPLAY_LIVE_SNAPSHOT_RESET, + POST_REPLAY_REATTACH_RESET } from '../../../../shared/terminal-mode-reset-profiles' import { makePaneKey } from '../../../../shared/stable-pane-id' import { flushAsyncTicks } from './pty-connection-test-async' @@ -481,7 +483,8 @@ describe('connectPanePty', () => { getMainBufferSnapshot.mockResolvedValue({ data: 'agent-frame\x1b[?25l', cols: 100, - rows: 30 + rows: 30, + alternateScreen: true }) const paneKey = makePaneKey('tab-1', LEAF_1) const now = Date.now() @@ -524,12 +527,75 @@ describe('connectPanePty', () => { POST_REPLAY_LIVE_SNAPSHOT_RESET, expect.any(Function) ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_DEAD_TUI_RESET, + expect.any(Function) + ) } finally { binding.dispose() } }) - it('resets cursor and focus modes on hidden-to-visible snapshot restore without a live agent', async () => { + it('lets fresh host shell proof outrank stale live-agent metadata on reveal', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'stale-agent-frame\x1b[?25l', + cols: 100, + rows: 30, + alternateScreen: true, + terminalOwner: 'shell' + }) + const paneKey = makePaneKey('tab-1', LEAF_1) + const now = Date.now() + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: '', + agentType: 'codex', + paneKey, + terminalTitle: 'codex', + updatedAt: now, + stateStartedAt: now, + stateHistory: [] + } + + const isVisibleRef = { current: false } + const pane = createPane(1) + const binding = connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ isVisibleRef, startup: { command: 'codex' } }) as never + ) + try { + await flushAsyncTicks(6) + capturedDataCallback.current?.('\x1b[6') + isVisibleRef.current = true + capturedDataCallback.current?.('n') + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_DEAD_TUI_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET, + expect.any(Function) + ) + } finally { + binding.dispose() + } + }) + + it('preserves current behavior when a snapshot has no host ownership proof', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } @@ -544,7 +610,8 @@ describe('connectPanePty', () => { getMainBufferSnapshot.mockResolvedValue({ data: 'shell-frame\x1b[?25l', cols: 100, - rows: 30 + rows: 30, + alternateScreen: false }) const isVisibleRef = { current: false } @@ -570,6 +637,116 @@ describe('connectPanePty', () => { POST_REPLAY_LIVE_SNAPSHOT_RESET, expect.any(Function) ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_REATTACH_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_DEAD_TUI_RESET, + expect.any(Function) + ) + } finally { + binding.dispose() + } + }) + + it('keeps a live alternate-screen pane interactive when a legacy snapshot omits its mode flag', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'legacy-tui-frame', + cols: 100, + rows: 30 + }) + + const isVisibleRef = { current: false } + const pane = createPane(1) + pane.terminal.buffer.active.type = 'alternate' + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef, + startup: { command: 'codex' } + }) as never + ) + try { + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[6') + isVisibleRef.current = true + capturedDataCallback.current?.('n') + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_LIVE_SNAPSHOT_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_REATTACH_RESET, + expect.any(Function) + ) + } finally { + binding.dispose() + } + }) + + it('grounds a stale alternate-screen snapshot when the host proves shell ownership', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'stale-tui-frame', + cols: 100, + rows: 30, + alternateScreen: true, + terminalOwner: 'shell' + }) + + const isVisibleRef = { current: false } + const pane = createPane(1) + pane.terminal.buffer.active.type = 'alternate' + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ isVisibleRef, startup: { command: 'codex' } }) as never + ) + try { + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[6') + isVisibleRef.current = true + capturedDataCallback.current?.('n') + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_DEAD_TUI_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_LIVE_SNAPSHOT_RESET, + expect.any(Function) + ) + expect(window.api.pty.inspectProcess).not.toHaveBeenCalled() } finally { binding.dispose() } diff --git a/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts index bf4d4a34feb..99c54dad2eb 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts @@ -2,6 +2,7 @@ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + POST_REPLAY_DEAD_TUI_RESET, POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, POST_REPLAY_REATTACH_RESET_KEEP_MOUSE, @@ -380,6 +381,45 @@ describe('connectPanePty', () => { }) }) + it('lets fresh host shell proof outrank stale live-agent metadata on reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: sessionId, + snapshot: '\x1b[?1049h\x1b[?1003hstale agent snapshot', + isAlternateScreen: true, + snapshotTerminalOwner: 'shell' as const + } + } + return null + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('Cursor Agent') + + const pane = createPane(1) + const manager = createManager(1) + connectPanePty( + pane as never, + manager as never, + createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) as never + ) + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_DEAD_TUI_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + }) + it('ignores the stale agent signal on a cold restore and applies the fresh-shell reset', async () => { // Why: pane status and title are persisted, so after a cold restore they still // describe the process that died and make the pane look agent-owned. Preserving diff --git a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts index ff7f5662b47..cb0806900af 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts @@ -92,7 +92,8 @@ export function createReattachPayloadHandlers( session.reattachReplayResetSequence( daemonSnapshotReplay, Boolean(ctx.connectResult.coldRestore), - ctx.connectResult.isAlternateScreen + ctx.connectResult.isAlternateScreen, + ctx.connectResult.snapshotTerminalOwner ) ) if (ctx.connectResult.pendingEscapeTailAnsi) { @@ -184,7 +185,8 @@ export function createReattachPayloadHandlers( session.reattachReplayResetSequence( modelData, Boolean(ctx.connectResult?.coldRestore), - modelSnapshot.alternateScreen ?? ctx.connectResult?.isAlternateScreen + modelSnapshot.alternateScreen ?? ctx.connectResult?.isAlternateScreen, + modelSnapshot.terminalOwner ) ) if (modelSnapshot.pendingEscapeTailAnsi) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts index 192dd042a84..28e0f95b9f7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts @@ -154,7 +154,9 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio kittyKeyboardFlags: meta.kittyKeyboardFlags, snapshotSeq: meta.snapshotSeq } - : {}) + : {}), + ...(meta.terminalOwner ? { terminalOwner: meta.terminalOwner } : {}), + ...(meta.alternateScreen !== undefined ? { alternateScreen: meta.alternateScreen } : {}) } session.scheduleReplayDataDrain() } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts index e29842991c4..9784df28161 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts @@ -2,6 +2,7 @@ import { replayIntoTerminal, replayIntoTerminalAsync } from '../replay-guard' import { terminalOutputPrefersRenderRefresh } from '@/lib/pane-manager/terminal-complex-script' import { buildPostReplayLiveAgentReattachReset, + POST_REPLAY_DEAD_TUI_RESET, POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, POST_REPLAY_REATTACH_RESET_KEEP_MOUSE @@ -133,7 +134,8 @@ export function bindFreshSpawnFollowReset(session: ConnectPanePtySession): void session.reattachReplayResetSequence = ( payload: string, ownerProcessEnded = false, - isAlternateScreen?: boolean + isAlternateScreen?: boolean, + terminalOwner?: 'shell' ): string => { // Why a cold restore overrides the agent signal: liveness is read from the // pane's status and title, both of which are persisted, so after a cold @@ -143,6 +145,11 @@ export function bindFreshSpawnFollowReset(session: ConnectPanePtySession): void if (ownerProcessEnded) { return POST_REPLAY_MODE_RESET } + if (terminalOwner === 'shell') { + return (isAlternateScreen ?? session.kittyKeyboardModes.isAlternateScreen) + ? POST_REPLAY_DEAD_TUI_RESET + : POST_REPLAY_REATTACH_RESET + } if (session.shouldPreserveAgentReattachModes()) { return buildPostReplayLiveAgentReattachReset(payload) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts index 076c9194934..3d88cc34fa7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts @@ -4,6 +4,8 @@ import { waitForTerminalReplayWritesParsed } from '../replay-guard' import { POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET, POST_REPLAY_LIVE_SNAPSHOT_RESET, + POST_REPLAY_DEAD_TUI_RESET, + POST_REPLAY_REATTACH_RESET, RESET_AFTER_BYTE_GAP } from '../../../../../shared/terminal-mode-reset-profiles' import { @@ -51,6 +53,7 @@ export function bindHiddenOutputRestoreSnapshot(session: ConnectPanePtySession): scrollbackAnsi?: string pendingEscapeTailAnsi?: string kittyKeyboardFlags?: number + terminalOwner?: 'shell' }): Promise { const restorePtyId = session.transport.getPtyId() const restoreGeneration = session.hiddenOutputRestoreGeneration @@ -140,12 +143,20 @@ export function bindHiddenOutputRestoreSnapshot(session: ConnectPanePtySession): session.writeReplayData(replayChunk) } } - // Why: live agents own ?25l/?1004h; a forced ?1004l here would silence focus events until restart (agents enable focus reporting only at startup). - session.writeReplayData( - session.hasLiveAgentReattachStatusOrTitleSignal() - ? POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET - : POST_REPLAY_LIVE_SNAPSHOT_RESET - ) + const hasLiveAgent = session.hasLiveAgentReattachStatusOrTitleSignal() + // Why the pane-local fallback: every owner-publishing host also fills + // alternateScreen, but the reattach site falls back to pane state for + // an absent flag and this site must not drift from it (?1049l on a + // normal-buffer pane still runs cursor restore). + const postReplayReset = + snapshot.terminalOwner === 'shell' + ? (snapshot.alternateScreen ?? session.isPaneOnAlternateScreen()) + ? POST_REPLAY_DEAD_TUI_RESET + : POST_REPLAY_REATTACH_RESET + : hasLiveAgent + ? POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET + : POST_REPLAY_LIVE_SNAPSHOT_RESET + session.writeReplayData(postReplayReset) if (snapshot.pendingEscapeTailAnsi) { // Why last: snapshot taken mid-escape; re-arm as the FINAL replay write (any later ESC aborts it) so the live tail completes it, not render literally (Bug E / #7329). session.writeReplayData(snapshot.pendingEscapeTailAnsi) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts b/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts index 33ec09d79f6..87a2b6039ce 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts @@ -86,7 +86,8 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { return false } const payload = session.pendingReplayData - const { data, clearBeforeReplay, pendingEscapeTailAnsi } = payload + const { data, clearBeforeReplay, pendingEscapeTailAnsi, alternateScreen, terminalOwner } = + payload session.pendingReplayData = null const isCurrentPayload = (): boolean => !session.disposed && @@ -120,7 +121,9 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { continue } if (clearBeforeReplay || data.length > 0) { - await session.writeReplayDataAsync(session.reattachReplayResetSequence(data)) + await session.writeReplayDataAsync( + session.reattachReplayResetSequence(data, false, alternateScreen, terminalOwner) + ) if (!isCurrentPayload()) { continue } diff --git a/src/renderer/src/components/terminal-pane/pty-output-processor.ts b/src/renderer/src/components/terminal-pane/pty-output-processor.ts index 378e72a56c9..ac611e8ed0c 100644 --- a/src/renderer/src/components/terminal-pane/pty-output-processor.ts +++ b/src/renderer/src/components/terminal-pane/pty-output-processor.ts @@ -35,6 +35,8 @@ export type ProcessPtyOutputOptions = { pendingEscapeTailAnsi?: string kittyKeyboardFlags?: number snapshotSeq?: number + alternateScreen?: boolean + terminalOwner?: 'shell' } function removeSuppressedCursorNativeTitles( @@ -211,7 +213,11 @@ export function createPtyOutputProcessor({ ...(options.kittyKeyboardFlags !== undefined ? { kittyKeyboardFlags: options.kittyKeyboardFlags } : {}), - ...(options.snapshotSeq !== undefined ? { snapshotSeq: options.snapshotSeq } : {}) + ...(options.snapshotSeq !== undefined ? { snapshotSeq: options.snapshotSeq } : {}), + ...(options.alternateScreen !== undefined + ? { alternateScreen: options.alternateScreen } + : {}), + ...(options.terminalOwner ? { terminalOwner: options.terminalOwner } : {}) } if (Object.keys(replayMeta).length > 0) { callbacks.onReplayData(data, replayMeta) diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 17f390f4ec4..7db2a6e01f0 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -44,6 +44,7 @@ export type PtyBufferSnapshot = { /** Effective kitty flags the owner of this image proved at `seq`. Absent * means unknown; never rewrite that silence into a known `0`. */ kittyKeyboardFlags?: number + terminalOwner?: 'shell' } /** Metadata for one authoritative replay payload. */ @@ -56,6 +57,8 @@ export type PtyReplayDataMeta = { /** The boundary `kittyKeyboardFlags` describes, recorded as the renderer's * ordered high-water so a quiet pane can still publish a coherent snapshot. */ snapshotSeq?: number + alternateScreen?: boolean + terminalOwner?: 'shell' } export type LocalPtySessionMetadata = { @@ -86,6 +89,7 @@ export type PtyConnectResult = { * domain `snapshotSeq` main reconciled for the same attach boundary. Absent * means unknown, never a proven inactive protocol. */ snapshotKittyKeyboardFlags?: number + snapshotTerminalOwner?: 'shell' snapshotSeq?: number isAlternateScreen?: boolean sessionExpired?: boolean diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts index 76f234c909c..709e1aa4875 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts @@ -97,7 +97,9 @@ describe('remote transport snapshot escape-tail threading (#7329)', () => { rows: 24, seq: 7, source: 'headless', - pendingEscapeTailAnsi: '\x1b[3' + pendingEscapeTailAnsi: '\x1b[3', + alternateScreen: false, + terminalOwner: 'shell' }) }), encodeTerminalStreamFrame({ @@ -120,7 +122,11 @@ describe('remote transport snapshot escape-tail threading (#7329)', () => { await expect.poll(() => onReplayData.mock.calls.length, { timeout: 5000 }).toBeGreaterThan(0) expect(onReplayData).toHaveBeenCalledWith( 'user@host:~$ ', - expect.objectContaining({ pendingEscapeTailAnsi: '\x1b[3' }) + expect.objectContaining({ + pendingEscapeTailAnsi: '\x1b[3', + alternateScreen: false, + terminalOwner: 'shell' + }) ) }) }) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 5e0e42715ca..16c6b235eac 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -1823,6 +1823,12 @@ export function createRemoteRuntimePtyTransport( kittyKeyboardFlags: meta.kittyKeyboardFlags, snapshotSeq: meta.seq } + : {}), + ...(meta?.terminalOwner && meta.seq !== undefined + ? { terminalOwner: meta.terminalOwner } + : {}), + ...(meta?.alternateScreen !== undefined && meta.seq !== undefined + ? { alternateScreen: meta.alternateScreen } : {}) }) } diff --git a/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts b/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts index 3072b98dad2..ce6a3db257c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts @@ -311,4 +311,34 @@ describe('terminal replay state reset', () => { term.dispose() } }) + + it('disarms mouse input when a hidden replay lands on the normal shell buffer', async () => { + const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }) + try { + await writeTerminal(term, '\x1b[?1003h\x1b[?1006h\x1b[?1049hTUI FRAME\x1b[?1049lSHELL MARKER') + expect(term.buffer.active.type).toBe('normal') + expect(term.modes.mouseTrackingMode).toBe('any') + + await writeTerminal(term, POST_REPLAY_REATTACH_RESET) + + expect(term.buffer.active.type).toBe('normal') + expect(term.modes.mouseTrackingMode).toBe('none') + expect(term.buffer.active.getLine(0)?.translateToString()).toContain('SHELL MARKER') + } finally { + term.dispose() + } + }) + + it('preserves mouse input for a live alternate-screen replay', async () => { + const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }) + try { + await writeTerminal(term, '\x1b[?1003h\x1b[?1006h\x1b[?1049hLIVE TUI') + await writeTerminal(term, POST_REPLAY_LIVE_SNAPSHOT_RESET) + + expect(term.buffer.active.type).toBe('alternate') + expect(term.modes.mouseTrackingMode).toBe('any') + } finally { + term.dispose() + } + }) }) diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts index d9783f85d40..b02f5b63870 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -67,6 +67,8 @@ export type RemoteRuntimeMultiplexedTerminalCallbacks = { pendingEscapeTailAnsi?: string seq?: number kittyKeyboardFlags?: number + alternateScreen?: boolean + terminalOwner?: 'shell' } ) => void onSubscribed?: () => void @@ -96,6 +98,8 @@ export type RemoteRuntimeSnapshotImage = { * from any host that predates the field — the pane tracker then stays * unproven and commits raw text instead of guessing zero. */ kittyKeyboardFlags?: number + alternateScreen?: boolean + terminalOwner?: 'shell' } /** Transient causes the host itself reported: a request reached it and it declined to serialize now. */ @@ -210,6 +214,8 @@ type RemoteRuntimeSnapshotInfo = { seq?: number source?: 'headless' | 'renderer' kittyKeyboardFlags?: number + alternateScreen?: boolean + terminalOwner?: 'shell' requestId?: number truncated?: boolean unavailable?: TerminalSnapshotUnavailableReason @@ -925,6 +931,8 @@ class RemoteRuntimeTerminalMultiplexer { seq: info?.seq, source: info?.source, kittyKeyboardFlags: info?.kittyKeyboardFlags, + alternateScreen: info?.alternateScreen, + terminalOwner: info?.terminalOwner, pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi } }) @@ -933,7 +941,9 @@ class RemoteRuntimeTerminalMultiplexer { stream.callbacks.onSnapshot(data ?? '', { pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi, seq: info?.seq, - kittyKeyboardFlags: info?.kittyKeyboardFlags + kittyKeyboardFlags: info?.kittyKeyboardFlags, + alternateScreen: info?.alternateScreen, + terminalOwner: info?.terminalOwner }) } else if (target === 'recovery') { // Why: a server-pushed recovery snapshot replaces terminal state @@ -943,7 +953,9 @@ class RemoteRuntimeTerminalMultiplexer { stream.callbacks.onSnapshot(`\x1b[2J\x1b[3J\x1b[H${data ?? ''}`, { pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi, seq: info?.seq, - kittyKeyboardFlags: info?.kittyKeyboardFlags + kittyKeyboardFlags: info?.kittyKeyboardFlags, + alternateScreen: info?.alternateScreen, + terminalOwner: info?.terminalOwner }) } } else if (matchesPendingRequest) { @@ -1600,6 +1612,8 @@ function decodeSnapshotInfo( unavailable?: unknown pendingEscapeTailAnsi?: unknown kittyKeyboardFlags?: unknown + alternateScreen?: unknown + terminalOwner?: unknown }>(payload) if (!raw) { return null @@ -1611,6 +1625,11 @@ function decodeSnapshotInfo( source: raw.source === 'headless' || raw.source === 'renderer' ? raw.source : undefined, // Negative, fractional, and unsafe values are treated as absent, never clamped. kittyKeyboardFlags: parseTerminalKittyKeyboardFlags(raw.kittyKeyboardFlags), + alternateScreen: + raw.terminalOwner === 'shell' && typeof raw.alternateScreen === 'boolean' + ? raw.alternateScreen + : undefined, + terminalOwner: raw.terminalOwner === 'shell' ? raw.terminalOwner : undefined, requestId: typeof raw.requestId === 'number' ? raw.requestId : undefined, truncated: raw.truncated === true, unavailable: parseTerminalSnapshotUnavailableReason(raw.unavailable), diff --git a/src/shared/terminal-mode-reset-profiles.ts b/src/shared/terminal-mode-reset-profiles.ts index 01a7f62acc3..69f176b9d80 100644 --- a/src/shared/terminal-mode-reset-profiles.ts +++ b/src/shared/terminal-mode-reset-profiles.ts @@ -23,6 +23,10 @@ export const POST_REPLAY_LIVE_SNAPSHOT_RESET = `${RESET_TERMINAL_CURSOR_STYLE}\x // Why: the normal-buffer fallback can follow a dead TUI, so its stale pen and saved pen must not reach the surviving shell. export const POST_REPLAY_REATTACH_RESET = `${RESET_GRAPHIC_RENDITION}${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h${RESET_MOUSE_REPORTING}\x1b[?1004l${SAVE_GROUNDED_CURSOR}` +// Why: a foreground shell proves an alternate-screen owner died without its +// ?1049l cleanup; leave the renderer on the shell's normal buffer as well. +export const POST_REPLAY_DEAD_TUI_RESET = `\x1b[?1049l${POST_REPLAY_REATTACH_RESET}` + // Why: an alt-screen reattach replays the daemon's rehydrateSequences, which re-arm the live TUI's // mouse modes; wiping them one write later hands drags back to xterm's row selection (#8291). // Normal-buffer panes keep RESET_MOUSE_REPORTING so a dead TUI's stale modes never reach a shell (#7893). diff --git a/src/shared/terminal-owner.ts b/src/shared/terminal-owner.ts new file mode 100644 index 00000000000..8797e0dec3f --- /dev/null +++ b/src/shared/terminal-owner.ts @@ -0,0 +1,5 @@ +export type TerminalOwner = 'shell' + +export function parseTerminalOwner(value: unknown): TerminalOwner | undefined { + return value === 'shell' ? value : undefined +} diff --git a/tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts index 1eba41b7fef..54d303ac871 100644 --- a/tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts @@ -120,6 +120,11 @@ describe('cross-version remote terminal wire', () => { const record = await runTerminalSkewJourney({ hostBuild: current, clientBuild: current }) expectJourneyActuallyRan(record) expectWireCompatible(record) + expect(record.snapshotStarts).toEqual([ + expect.objectContaining({ alternateScreen: false, terminalOwner: 'shell' }), + expect.objectContaining({ alternateScreen: false, terminalOwner: 'shell' }), + expect.objectContaining({ alternateScreen: false, terminalOwner: 'shell' }) + ]) }, SUITE_TIMEOUT_MS ) @@ -131,6 +136,11 @@ describe('cross-version remote terminal wire', () => { expect(record.clientRevision).toBe(baseline.revision) expectJourneyActuallyRan(record) expectWireCompatible(record) + expect(record.snapshotStarts).toEqual([ + expect.objectContaining({ alternateScreen: false, terminalOwner: 'shell' }), + expect.objectContaining({ alternateScreen: false, terminalOwner: 'shell' }), + expect.objectContaining({ alternateScreen: false, terminalOwner: 'shell' }) + ]) }, SUITE_TIMEOUT_MS ) @@ -142,6 +152,10 @@ describe('cross-version remote terminal wire', () => { expect(record.hostRevision).toBe(baseline.revision) expectJourneyActuallyRan(record) expectWireCompatible(record) + for (const start of record.snapshotStarts) { + expect(start).not.toHaveProperty('terminalOwner') + expect(start).not.toHaveProperty('alternateScreen') + } }, SUITE_TIMEOUT_MS ) diff --git a/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts b/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts index 1fc471b0de7..528e1a85874 100644 --- a/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts +++ b/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts @@ -89,9 +89,19 @@ export function createHostTerminalRuntimeStub( rows: number seq: number source: 'headless' + alternateScreen: false + terminalOwner: 'shell' }> => { stub.serializeCount++ - return { data: stub.buffer, cols, rows, seq: outputSequence, source: 'headless' } + return { + data: stub.buffer, + cols, + rows, + seq: outputSequence, + source: 'headless', + alternateScreen: false, + terminalOwner: 'shell' + } } const runtime: Record = { diff --git a/tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts b/tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts new file mode 100644 index 00000000000..c4cfc10a8ec --- /dev/null +++ b/tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts @@ -0,0 +1,220 @@ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveTabId, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + execInTerminal, + getTerminalContent, + readPaneIdentitySnapshot, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { + clearTerminalPtyWriteLog, + installTerminalPtyWriteSpy, + readTerminalPtyWriteEntries +} from './helpers/terminal-pty-write-spy' +import { stageNodeScriptForTerminal } from './helpers/run-node-script-in-terminal' + +async function activateTerminalTab(page: Page, tabId: string): Promise { + await page.evaluate((id) => { + const state = window.__store?.getState() + state?.setActiveTab(id) + state?.setActiveTabType('terminal') + }, tabId) + await expect + .poll(() => + page.locator('[data-testid="sortable-tab"][data-active="true"]').getAttribute('data-tab-id') + ) + .toBe(tabId) +} + +for (const exitMode of ['normal', 'sigkill'] as const) { + test(`clears modes when a child TUI exits via ${exitMode} while its shell survives hidden`, async ({ + electronApp, + orcaPage + }) => { + await installTerminalPtyWriteSpy(electronApp) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const shellTabId = (await getActiveTabId(orcaPage))! + const child = [ + "process.stdin.setRawMode?.(true); process.stdin.resume(); process.stdout.write('\\x1b[?1049h\\x1b[?1003h\\x1b[?1006h\\x1b[?25lCHILD_TUI_STARTED\\r\\n')", + ...(exitMode === 'normal' + ? [ + "process.on('SIGTERM', () => { process.stdout.write('\\x1b[?1006l\\x1b[?1003l\\x1b[?1049l\\x1b[?25h'); process.exit(0) })" + ] + : []), + "setInterval(() => process.stdout.write('\\x1b[2J\\x1b[HCHILD_TUI_FRAME\\r\\n'), 80)" + ].join(';') + const parent = [ + `const {spawn}=require('node:child_process'); const child=spawn(process.execPath,['-e',${JSON.stringify(child)}],{stdio:'inherit'});`, + "process.stdout.write('CHILD_TUI_PID_' + child.pid + '\\r\\n');", + "child.on('exit', () => process.stdout.write('\\r\\nCHILD_TUI_KILLED\\r\\n'))" + ].join(' ') + const command = stageNodeScriptForTerminal(parent, { prefix: 'orca-child-tui-kill' }).command + const tuiTabId = await orcaPage.evaluate( + ({ command }) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + if (!state || !worktreeId) { + throw new Error('store/worktree unavailable') + } + const tab = state.createTab(worktreeId) + state.queueTabStartupCommand(tab.id, { command }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, + { command } + ) + + await expect.poll(() => getActiveTabId(orcaPage), { timeout: 5_000 }).toBe(tuiTabId) + const tuiPtyId = await waitForActivePanePtyId(orcaPage, 30_000) + const tuiIdentity = await readPaneIdentitySnapshot(orcaPage) + expect(tuiIdentity?.activeLeafId).not.toBeNull() + await expect + .poll(() => getTerminalContent(orcaPage, 6_000), { timeout: 20_000 }) + .toContain('CHILD_TUI_FRAME') + await expect + .poll(async () => { + const match = (await getTerminalContent(orcaPage, 6_000)).match(/CHILD_TUI_PID_(\d+)/) + return match?.[1] ?? null + }) + .not.toBeNull() + const childPid = (await getTerminalContent(orcaPage, 6_000)).match(/CHILD_TUI_PID_(\d+)/)?.[1] + expect(childPid).toBeDefined() + await activateTerminalTab(orcaPage, shellTabId) + const shellPtyId = await waitForActivePanePtyId(orcaPage, 30_000) + const exitSignal = exitMode === 'normal' ? 'SIGTERM' : 'SIGKILL' + const killCommand = stageNodeScriptForTerminal(`process.kill(${childPid!}, '${exitSignal}')`, { + prefix: 'orca-child-tui-external-kill' + }).command + await execInTerminal(orcaPage, shellPtyId, killCommand) + await expect + .poll( + () => + orcaPage.evaluate(async (ptyId) => { + const processName = await window.api.pty.getForegroundProcess(ptyId) + return processName?.toLowerCase() ?? null + }, tuiPtyId), + { timeout: 8_000 } + ) + .toMatch(/^(bash|zsh|sh|fish)(\.exe)?$/) + await orcaPage.evaluate( + ({ paneKey, tabId }) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + if (!state || !worktreeId) { + throw new Error('store/worktree unavailable') + } + state.setAgentStatus( + paneKey, + { state: 'working', prompt: 'stale hidden TUI status', agentType: 'codex' }, + 'Codex', + undefined, + { tabId, worktreeId } + ) + }, + { paneKey: `${tuiTabId}:${tuiIdentity!.activeLeafId!}`, tabId: tuiTabId } + ) + await clearTerminalPtyWriteLog(electronApp) + await activateTerminalTab(orcaPage, tuiTabId) + + const revealedPtyId = await orcaPage.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + return manager?.getActivePane?.()?.container?.dataset?.ptyId ?? null + }, tuiTabId) + expect(revealedPtyId).not.toBeNull() + await expect + .poll(async () => { + const snapshot = await orcaPage.evaluate( + (ptyId) => window.api.pty.getMainBufferSnapshot(ptyId, { scrollbackRows: 5000 }), + revealedPtyId! + ) + return snapshot?.terminalOwner === 'shell' && snapshot.alternateScreen === false + }) + .toBe(true) + await expect + .poll(() => + orcaPage.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + return { + buffer: pane?.terminal.buffer.active.type, + mouse: pane?.terminal.modes.mouseTrackingMode + } + }, tuiTabId) + ) + .toEqual({ buffer: 'normal', mouse: 'none' }) + + // Why normal-exit only: the barrier preserves shell output from the 133;D + // boundary onward (the prompt), but bytes the dying command printed while + // the alternate screen was still up are part of the discarded dead frame. + // On SIGKILL the parent's exit line lands pre-D in the alt buffer, so it is + // unrecoverable by design; on normal exit the child's own ?1049l precedes + // it and it survives on the normal buffer. + if (exitMode === 'normal') { + await expect + .poll(() => getTerminalContent(orcaPage, 6_000), { timeout: 8_000 }) + .toContain('CHILD_TUI_KILLED') + } + + const shellInputMarker = 'SHELL_INPUT_AFTER_TUI_KILL' + await execInTerminal(orcaPage, revealedPtyId!, `printf ${shellInputMarker}`) + await expect + .poll(async () => { + const writes = await readTerminalPtyWriteEntries(electronApp) + return writes.some( + (entry) => entry.id === revealedPtyId && entry.data.includes(shellInputMarker) + ) + }) + .toBe(true) + await expect + .poll(() => getTerminalContent(orcaPage, 6_000), { timeout: 8_000 }) + .toContain(shellInputMarker) + + const terminalScreen = orcaPage.locator(`[data-pty-id="${revealedPtyId}"] .xterm-screen`) + await terminalScreen.hover({ position: { x: 20, y: 20 } }) + await orcaPage.mouse.wheel(0, 120) + await orcaPage.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + ) + ) + + const ptyWrites = (await readTerminalPtyWriteEntries(electronApp)) + .filter((entry) => entry.id === revealedPtyId) + .map((entry) => entry.data) + const escape = String.fromCharCode(27) + expect(ptyWrites.some((data) => data.includes(`${escape}[<`))).toBe(false) + expect(ptyWrites.some((data) => data.includes(`${escape}[M`))).toBe(false) + + const terminalState = await orcaPage.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + return { + buffer: pane?.terminal.buffer.active.type, + mouse: pane?.terminal.modes.mouseTrackingMode + } + }, tuiTabId) + expect(terminalState).toEqual({ buffer: 'normal', mouse: 'none' }) + expect(await getTerminalContent(orcaPage, 6_000)).not.toMatch(/\[<\d+;\d+;\d+[Mm]/) + + await activateTerminalTab(orcaPage, shellTabId) + expect(await waitForActivePanePtyId(orcaPage, 30_000)).toBe(shellPtyId) + const unrelatedMarker = 'UNRELATED_SHELL_STILL_LIVE' + await execInTerminal(orcaPage, shellPtyId, `printf ${unrelatedMarker}`) + await expect + .poll(() => getTerminalContent(orcaPage, 6_000), { timeout: 8_000 }) + .toContain(unrelatedMarker) + }) +} From ebcd637db981b0ce272c8e183794a8fc7caaa7be Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:22:05 -0700 Subject: [PATCH 038/152] fix(codex): launch WSL accounts from direct homes (#16504) * fix(codex): launch WSL accounts from direct homes * fix(codex): coalesce WSL auth drains and validate distro homes * fix(codex): preserve legacy WSL account home metadata * fix(codex): retain marked WSL home compatibility * fix(codex): verify the bytes the WSL drain promotes, not an earlier read The apply script validated the source hash and then re-read it with cp, so a legacy pane rotating in that window put bytes freshness never judged over a valid account home. Codex rewrites auth.json in place, so that read can be torn. Covers it by running the real guest script under sh with a sha256sum shim that rotates the source between the two reads; without the guard it exits 0. * fix(codex): harden WSL auth drain races --- ...sl-runtime-auth-drain-apply-script.test.ts | 170 ++++++++++ .../legacy-wsl-runtime-auth-drain.test.ts | 212 ++++++++++++ .../legacy-wsl-runtime-auth-drain.ts | 315 ++++++++++++++++++ .../codex-accounts/runtime-home-service.ts | 197 +++++++++-- .../runtime-home-wsl-managed-accounts.test.ts | 71 ++-- .../runtime-home-wsl-session-bridge.test.ts | 62 ++-- .../runtime-home-wsl-system-default.test.ts | 37 +- .../wsl-codex-auth-batch-reader.ts | 70 ++++ src/main/codex/codex-pane-account-registry.ts | 9 + .../codex/codex-pane-launch-account.test.ts | 2 +- src/main/codex/codex-pane-launch-account.ts | 17 +- .../codex/codex-stale-pane-accounts.test.ts | 31 ++ 12 files changed, 1043 insertions(+), 150 deletions(-) create mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts create mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts create mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts create mode 100644 src/main/codex-accounts/wsl-codex-auth-batch-reader.ts diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts new file mode 100644 index 00000000000..44ce6a6e00f --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts @@ -0,0 +1,170 @@ +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { _internals } from './legacy-wsl-runtime-auth-drain' + +const isWindows = process.platform === 'win32' + +const SOURCE_AUTH = '{"tokens":{"expires_at":2000}}\n' +const TARGET_AUTH = '{"tokens":{"expires_at":1000}}\n' +const NEWER_AUTH = '{"tokens":{"expires_at":3000}}\n' +// Codex truncates before it writes, so a read landing mid-rotation sees this. +const TORN_AUTH = '{"tokens":{"exp' +const SOURCE_CREDENTIALS = '{"server":{"access_token":"source"}}\n' +const TORN_CREDENTIALS = '{"server":' + +function sha256(contents: string): string { + return createHash('sha256').update(contents).digest('hex') +} + +/** + * Runs the real guest script under `sh`, with `sha256sum` shimmed so a chosen + * hash call can rewrite the source underneath the script. That is the only way + * to land Codex's in-place rotation inside the window the script itself opens. + */ +function runApplyScript( + options: { + rewriteSourceAfterHashCall?: number + rewriteBytes?: string + rewriteTarget?: 'source-auth' | 'source-credentials' | 'target-auth' + sourceCredentials?: string + } = {} +): { + legacyAuth: string + status: number + targetAuth: string + targetCredentials: string | null +} { + const root = mkdtempSync(join(tmpdir(), 'orca-drain-apply-')) + const legacyHome = join(root, 'legacy') + const targetHome = join(root, 'account') + const binDir = join(root, 'bin') + for (const dir of [legacyHome, targetHome, binDir]) { + mkdirSync(dir, { recursive: true }) + } + const legacyAuthPath = join(legacyHome, 'auth.json') + const targetAuthPath = join(targetHome, 'auth.json') + const legacyCredentialsPath = join(legacyHome, '.credentials.json') + const targetCredentialsPath = join(targetHome, '.credentials.json') + writeFileSync(legacyAuthPath, SOURCE_AUTH) + writeFileSync(targetAuthPath, TARGET_AUTH) + if (options.sourceCredentials !== undefined) { + writeFileSync(legacyCredentialsPath, options.sourceCredentials) + } + + const counterPath = join(root, 'hash-calls') + writeFileSync(counterPath, '0') + const shimPath = join(binDir, 'sha256sum') + writeFileSync( + shimPath, + `#!/usr/bin/env node +const { createHash } = require('node:crypto') +const fs = require('node:fs') +const file = process.argv[process.argv.length - 1] +process.stdout.write( + createHash('sha256').update(fs.readFileSync(file)).digest('hex') + ' ' + file + '\\n' +) +const calls = Number(fs.readFileSync(process.env.HASH_COUNTER, 'utf8')) + 1 +fs.writeFileSync(process.env.HASH_COUNTER, String(calls)) +if (process.env.REWRITE_AFTER && calls === Number(process.env.REWRITE_AFTER)) { + fs.writeFileSync(process.env.REWRITE_TARGET, process.env.REWRITE_BYTES) +} +` + ) + chmodSync(shimPath, 0o755) + + let status = 0 + try { + execFileSync( + '/bin/sh', + [ + '-c', + _internals.applyLegacyAuthScript, + 'sh', + legacyHome, + join(root, 'absent-active-home'), + join(root, 'absent-marker.json'), + targetHome, + sha256(SOURCE_AUTH), + sha256(TARGET_AUTH), + '1', + '0', + options.sourceCredentials === undefined ? 'missing' : sha256(options.sourceCredentials) + ], + { + encoding: 'utf8', + env: { + ...process.env, + HASH_COUNTER: counterPath, + PATH: `${binDir}:${process.env.PATH ?? ''}`, + REWRITE_AFTER: options.rewriteSourceAfterHashCall + ? String(options.rewriteSourceAfterHashCall) + : '', + REWRITE_BYTES: options.rewriteBytes ?? TORN_AUTH, + REWRITE_TARGET: + options.rewriteTarget === 'source-credentials' + ? legacyCredentialsPath + : options.rewriteTarget === 'target-auth' + ? targetAuthPath + : legacyAuthPath + }, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 20_000 + } + ) + } catch (error) { + status = (error as { status?: number }).status ?? -1 + } + return { + legacyAuth: readFileSync(legacyAuthPath, 'utf8'), + status, + targetAuth: readFileSync(targetAuthPath, 'utf8'), + targetCredentials: existsSync(targetCredentialsPath) + ? readFileSync(targetCredentialsPath, 'utf8') + : null + } +} + +describe.skipIf(isWindows)('legacy WSL auth drain apply script', () => { + it('promotes the validated source into the account home', () => { + const outcome = runApplyScript() + expect(outcome.targetAuth).toBe(SOURCE_AUTH) + }) + + it('leaves the legacy home untouched while promoting', () => { + // One-directional: the promote step must never write back to the old home. + expect(runApplyScript().legacyAuth).toBe(SOURCE_AUTH) + }) + + it('refuses torn bytes and leaves the account home intact', () => { + // Hash call 1 is the source pre-check; rotating right after it means `cp` + // reads bytes freshness never judged. Pre-guard, those reached the target. + const outcome = runApplyScript({ rewriteSourceAfterHashCall: 1 }) + expect(outcome.status).toBe(42) + expect(outcome.targetAuth).toBe(TARGET_AUTH) + }) + + it('refuses MCP credentials that changed after host validation', () => { + const outcome = runApplyScript({ + rewriteSourceAfterHashCall: 2, + rewriteBytes: TORN_CREDENTIALS, + rewriteTarget: 'source-credentials', + sourceCredentials: SOURCE_CREDENTIALS + }) + expect(outcome.status).toBe(43) + expect(outcome.targetCredentials).toBeNull() + }) + + it('does not overwrite auth changed after the destination hash check', () => { + const outcome = runApplyScript({ + rewriteBytes: NEWER_AUTH, + rewriteSourceAfterHashCall: 4, + rewriteTarget: 'target-auth' + }) + expect(outcome.status).toBe(39) + expect(outcome.targetAuth).toBe(NEWER_AUTH) + }) +}) diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts new file mode 100644 index 00000000000..0c0caa1b669 --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts @@ -0,0 +1,212 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { runWslProcessMock } = vi.hoisted(() => ({ + runWslProcessMock: vi.fn() +})) + +vi.mock('../wsl/wsl-runner', () => ({ + runWslProcess: runWslProcessMock +})) + +import { + _internals, + drainLegacyWslRuntimeAuth, + startLegacyWslRuntimeAuthDrain +} from './legacy-wsl-runtime-auth-drain' +import { readWslCodexAuths } from './wsl-codex-auth-batch-reader' + +const SOURCE_AUTH = '{"tokens":{"expires_at":2000}}\n' +const STALE_AUTH = '{"tokens":{"expires_at":1000}}\n' +const NEWER_AUTH = '{"tokens":{"expires_at":3000}}\n' + +function inspection(auth: string, credentials?: string): string { + return [ + Buffer.from(auth).toString('base64'), + credentials === undefined ? 'missing' : 'present', + credentials === undefined ? '' : Buffer.from(credentials).toString('base64') + ].join('\n') +} + +function result(code: number, stdout = '') { + return { + code, + stdout, + stderr: '', + timedOut: false, + environmentResolved: true + } +} + +describe('legacy WSL runtime auth drain', () => { + beforeEach(() => { + runWslProcessMock.mockReset() + _internals.resetDrainQueue() + }) + + it('promotes fresher auth guest-side while a legacy pane remains', async () => { + runWslProcessMock + .mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH))) + .mockResolvedValueOnce(result(0)) + const resolveDestination = vi.fn(() => ({ + authContents: STALE_AUTH, + linuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home' + })) + + await drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: true, + resolveDestination + }) + + expect(resolveDestination).toHaveBeenCalledWith(SOURCE_AUTH) + expect(runWslProcessMock).toHaveBeenCalledTimes(2) + expect(runWslProcessMock.mock.calls[1]?.[0].args.slice(3)).toEqual([ + '/home/alice/.local/share/orca/codex-accounts/account-1/home', + expect.any(String), + expect.any(String), + '1', + '0', + 'missing' + ]) + expect(runWslProcessMock.mock.calls[1]?.[0].script).toContain('readlink -f') + expect(runWslProcessMock.mock.calls[1]?.[0].script).toContain('source_credentials=') + expect(runWslProcessMock.mock.calls[1]?.[0].script).toContain('chmod 600') + }) + + it('does not write when freshness cannot be proven', async () => { + runWslProcessMock.mockResolvedValueOnce(result(0, inspection('{"tokens":{}}\n'))) + + await drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: false, + resolveDestination: () => ({ + authContents: '{"tokens":{}}\n', + linuxHomePath: '/home/alice/.codex' + }) + }) + + expect(runWslProcessMock).toHaveBeenCalledTimes(1) + }) + + it('refuses a source with no unique destination', async () => { + runWslProcessMock.mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH))) + + await drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: false, + resolveDestination: () => null + }) + + expect(runWslProcessMock).toHaveBeenCalledTimes(1) + }) + + it('retires stale legacy auth only after the last recorded pane exits', async () => { + runWslProcessMock + .mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH))) + .mockResolvedValueOnce(result(0)) + + await drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: false, + resolveDestination: () => ({ + authContents: NEWER_AUTH, + linuxHomePath: '/home/alice/.codex' + }) + }) + + expect(runWslProcessMock.mock.calls[1]?.[0].args.slice(-3)).toEqual(['0', '1', 'missing']) + }) + + it('keeps an absent source retryable while a legacy pane remains', async () => { + runWslProcessMock.mockResolvedValueOnce(result(21)) + + await drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: true, + resolveDestination: vi.fn() + }) + + expect(runWslProcessMock).toHaveBeenCalledTimes(1) + }) + + it('does nothing after the guest-side completion marker is present', async () => { + runWslProcessMock.mockResolvedValueOnce(result(20)) + const resolveDestination = vi.fn() + + await drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: false, + resolveDestination + }) + + expect(resolveDestination).not.toHaveBeenCalled() + expect(runWslProcessMock).toHaveBeenCalledTimes(1) + }) + + it('marks an absent source complete after every legacy pane exits', async () => { + runWslProcessMock.mockResolvedValueOnce(result(21)).mockResolvedValueOnce(result(0)) + + await drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: false, + resolveDestination: vi.fn() + }) + + expect(runWslProcessMock).toHaveBeenCalledTimes(2) + expect(runWslProcessMock.mock.calls[1]?.[0].args).toEqual([ + '/home/alice/.local/share/orca/codex-runtime-home/home', + '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home', + '/home/alice/.local/share/orca/codex-runtime-home/direct-home-auth-drain-v1.json' + ]) + }) + + it('coalesces concurrent drain triggers instead of queueing every poll', async () => { + runWslProcessMock.mockImplementation(() => new Promise(() => {})) + const options = { + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: true, + resolveDestination: () => null + } + + startLegacyWslRuntimeAuthDrain(options) + startLegacyWslRuntimeAuthDrain(options) + await Promise.resolve() + + expect(runWslProcessMock).toHaveBeenCalledTimes(1) + }) + + it('reads every candidate home in one bounded guest process', async () => { + runWslProcessMock.mockResolvedValueOnce( + result( + 0, + [`present:${Buffer.from(SOURCE_AUTH).toString('base64')}`, 'missing', 'unreadable'].join( + '\n' + ) + ) + ) + + await expect( + readWslCodexAuths('Ubuntu', ['/home/alice/.codex-a', '/home/alice/.codex-b', '/bad']) + ).resolves.toEqual([ + { kind: 'present', contents: SOURCE_AUTH }, + { kind: 'missing' }, + { kind: 'unreadable' } + ]) + expect(runWslProcessMock).toHaveBeenCalledTimes(1) + expect(runWslProcessMock).toHaveBeenCalledWith( + expect.objectContaining({ + args: ['/home/alice/.codex-a', '/home/alice/.codex-b', '/bad'], + maxOutputBytes: 2 * 1024 * 1024, + timeoutMs: 5_000 + }) + ) + }) +}) diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts new file mode 100644 index 00000000000..cc50be185af --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts @@ -0,0 +1,315 @@ +import { createHash } from 'node:crypto' +import { posix as pathPosix } from 'node:path' +import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env' +import { runWslProcess } from '../wsl/wsl-runner' +import { compareCodexAuthFreshness, codexAuthIsFresher } from './codex-auth-identity' +import { decodeWslBase64Payload } from './wsl-codex-auth-batch-reader' + +const DRAIN_MARKER_NAME = 'direct-home-auth-drain-v1.json' +const MARKER_PRESENT_EXIT = 20 +const SOURCE_AUTH_ABSENT_EXIT = 21 + +export type LegacyWslRuntimeAuthDestination = { + authContents: string + linuxHomePath: string +} + +type LegacyWslRuntimeInspection = { + authContents: string + credentials: { kind: 'missing' } | { kind: 'present'; contents: string } +} + +type LegacyWslRuntimeAuthDrainOptions = { + distro: string + guestHomeLinuxPath: string + legacyPanePresent: boolean + resolveDestination: ( + runtimeAuthContents: string + ) => LegacyWslRuntimeAuthDestination | null | Promise +} + +const drainQueueByDistro = new Map>() +const completedDistroKeys = new Set() + +export function startLegacyWslRuntimeAuthDrain(options: LegacyWslRuntimeAuthDrainOptions): void { + const key = options.distro.trim().toLowerCase() + if (completedDistroKeys.has(key)) { + return + } + // Coalesce launch/rate-limit callers while a drain is in flight. Queuing a + // new pass for every poll can otherwise build an unbounded promise chain + // while a legacy pane keeps the migration pending. + if (drainQueueByDistro.has(key)) { + return + } + const next = drainLegacyWslRuntimeAuth(options) + .then((status) => { + if (status === 'complete') { + completedDistroKeys.add(key) + } + }) + .catch((error) => { + console.warn('[codex-wsl-auth-drain] Failed to drain legacy runtime auth:', error) + }) + drainQueueByDistro.set(key, next) + void next.finally(() => { + if (drainQueueByDistro.get(key) === next) { + drainQueueByDistro.delete(key) + } + }) +} + +export async function drainLegacyWslRuntimeAuth( + options: LegacyWslRuntimeAuthDrainOptions +): Promise<'complete' | 'pending'> { + const paths = resolveLegacyRuntimePaths(options.guestHomeLinuxPath) + const inspection = await runWslProcess({ + distro: options.distro, + loginPath: 'none', + script: INSPECT_LEGACY_AUTH_SCRIPT, + args: [paths.runtimeHome, paths.activeHome, paths.marker], + timeoutMs: 5_000, + maxOutputBytes: 2 * 1024 * 1024 + }) + if (inspection.code === MARKER_PRESENT_EXIT) { + return 'complete' + } + if (inspection.code === SOURCE_AUTH_ABSENT_EXIT) { + if (!options.legacyPanePresent) { + await finalizeAbsentLegacyAuth(options.distro, paths) + return 'complete' + } + return 'pending' + } + assertSuccessfulDrainStep('inspect', inspection) + + const inspected = parseLegacyRuntimeInspection(inspection.stdout) + if (!inspected) { + return 'pending' + } + const destination = await options.resolveDestination(inspected.authContents) + if (!destination) { + return 'pending' + } + const freshness = compareCodexAuthFreshness(inspected.authContents, destination.authContents) + if (freshness === null) { + return 'pending' + } + const promoteAuth = codexAuthIsFresher(inspected.authContents, destination.authContents) + const result = await runWslProcess({ + distro: options.distro, + loginPath: 'none', + script: APPLY_LEGACY_AUTH_SCRIPT, + args: [ + paths.runtimeHome, + paths.activeHome, + paths.marker, + destination.linuxHomePath, + sha256(inspected.authContents), + sha256(destination.authContents), + promoteAuth ? '1' : '0', + options.legacyPanePresent ? '0' : '1', + inspected.credentials.kind === 'present' ? sha256(inspected.credentials.contents) : 'missing' + ], + timeoutMs: 5_000, + maxOutputBytes: 16 * 1024 + }) + assertSuccessfulDrainStep('apply', result) + return options.legacyPanePresent ? 'pending' : 'complete' +} + +function parseLegacyRuntimeInspection(stdout: string): LegacyWslRuntimeInspection | null { + const [authBase64, credentialsKind, credentialsBase64] = stdout.split('\n') + const authContents = decodeWslBase64Payload(authBase64 ?? '') + if (authContents === null) { + return null + } + if (credentialsKind === 'missing') { + return { authContents, credentials: { kind: 'missing' } } + } + if (credentialsKind !== 'present') { + return null + } + const credentialsContents = decodeWslBase64Payload(credentialsBase64 ?? '') + if (!credentialsContents || !isJsonObject(credentialsContents)) { + return null + } + return { authContents, credentials: { kind: 'present', contents: credentialsContents } } +} + +function isJsonObject(contents: string): boolean { + try { + const value = JSON.parse(contents) as unknown + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) + } catch { + return false + } +} + +function resolveLegacyRuntimePaths(guestHomeLinuxPath: string): { + activeHome: string + marker: string + runtimeHome: string +} { + const runtimeHome = pathPosix.join(guestHomeLinuxPath, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS) + const runtimeRoot = pathPosix.dirname(runtimeHome) + return { + activeHome: pathPosix.join(runtimeRoot, 'active', 'wsl', 'home'), + marker: pathPosix.join(runtimeRoot, DRAIN_MARKER_NAME), + runtimeHome + } +} + +async function finalizeAbsentLegacyAuth( + distro: string, + paths: ReturnType +): Promise { + const result = await runWslProcess({ + distro, + loginPath: 'none', + script: FINALIZE_ABSENT_AUTH_SCRIPT, + args: [paths.runtimeHome, paths.activeHome, paths.marker], + timeoutMs: 5_000, + maxOutputBytes: 16 * 1024 + }) + assertSuccessfulDrainStep('finalize', result) +} + +function assertSuccessfulDrainStep( + step: string, + result: { code: number | null; stderr: string; timedOut: boolean } +): void { + if (result.code === 0 && !result.timedOut) { + return + } + const detail = result.stderr.trim() + throw new Error( + `Legacy WSL auth drain ${step} failed (${result.timedOut ? 'timeout' : `exit ${result.code}`})${detail ? `: ${detail}` : ''}` + ) +} + +function sha256(contents: string): string { + return createHash('sha256').update(contents).digest('hex') +} + +const RESOLVE_LEGACY_HOME_SCRIPT = ` +legacy_home="$1" +legacy_home_resolved=0 +if [ -e "$1" ] || [ -L "$1" ]; then + legacy_home=$(readlink -f -- "$1") || exit 30 + legacy_home_resolved=1 +fi +if [ -e "$2" ] || [ -L "$2" ]; then + active_home=$(readlink -f -- "$2") || exit 31 + if [ "$legacy_home_resolved" = 1 ]; then + [ "$active_home" = "$legacy_home" ] || exit 32 + else + legacy_home="$active_home" + fi +fi +` + +const INSPECT_LEGACY_AUTH_SCRIPT = ` +set -eu +[ ! -f "$3" ] || exit ${MARKER_PRESENT_EXIT} +${RESOLVE_LEGACY_HOME_SCRIPT} +source_auth="$legacy_home/auth.json" +[ -f "$source_auth" ] || exit ${SOURCE_AUTH_ABSENT_EXIT} +encode_file() { + encoded=$(base64 < "$1") || return 1 + printf '%s' "$encoded" | tr -d '\n' +} +encode_file "$source_auth" +printf '\n' +source_credentials="$legacy_home/.credentials.json" +if [ -f "$source_credentials" ]; then + printf 'present\n' + encode_file "$source_credentials" + printf '\n' +elif [ ! -e "$source_credentials" ] && [ ! -L "$source_credentials" ]; then + printf 'missing\n\n' +else + exit 44 +fi +` + +const APPLY_LEGACY_AUTH_SCRIPT = ` +set -eu +[ ! -f "$3" ] || exit 0 +${RESOLVE_LEGACY_HOME_SCRIPT} +target_home=$(readlink -f -- "$4") || exit 33 +[ "$legacy_home" != "$target_home" ] || exit 34 +source_auth="$legacy_home/auth.json" +target_auth="$target_home/auth.json" +[ -f "$source_auth" ] || exit 35 +[ -f "$target_auth" ] || exit 36 +hash_file() { sha256sum -- "$1" | cut -d ' ' -f 1; } +[ "$(hash_file "$source_auth")" = "$5" ] || exit 37 +[ "$(hash_file "$target_auth")" = "$6" ] || exit 38 +umask 077 +temporary_auth="$target_auth.orca-drain-$$" +temporary_credentials="$target_home/.credentials.json.orca-drain-$$" +temporary_previous_auth="$target_auth.orca-drain-previous-$$" +temporary_marker="$3.orca-drain-$$" +cleanup() { rm -f -- "$temporary_auth" "$temporary_credentials" "$temporary_previous_auth" "$temporary_marker"; } +trap cleanup EXIT HUP INT TERM +source_credentials="$legacy_home/.credentials.json" +target_credentials="$target_home/.credentials.json" +if [ -f "$source_credentials" ] && [ ! -e "$target_credentials" ] && [ ! -L "$target_credentials" ]; then + [ "$9" != missing ] || exit 43 + [ "$(hash_file "$source_credentials")" = "$9" ] || exit 43 + cp -- "$source_credentials" "$temporary_credentials" + chmod 600 "$temporary_credentials" + [ "$(hash_file "$temporary_credentials")" = "$9" ] || exit 43 + [ "$(hash_file "$source_credentials")" = "$9" ] || exit 43 + mv -n -- "$temporary_credentials" "$target_credentials" +elif [ "$9" = missing ] && [ ! -e "$target_credentials" ] && [ ! -L "$target_credentials" ]; then + [ ! -e "$source_credentials" ] && [ ! -L "$source_credentials" ] || exit 43 +fi +if [ "$7" = 1 ]; then + cp -- "$source_auth" "$temporary_auth" + chmod 600 "$temporary_auth" + # Codex rewrites auth.json in place, so this copy is a second read: verify the + # bytes being promoted, not the ones freshness was judged on. + [ "$(hash_file "$temporary_auth")" = "$5" ] || exit 42 + [ "$(hash_file "$target_auth")" = "$6" ] || exit 39 + # The hard link keeps the destination inode observable without creating a + # missing-path crash window. In-place writers update both names. + ln -- "$target_auth" "$temporary_previous_auth" + [ "$(hash_file "$temporary_previous_auth")" = "$6" ] || exit 39 + mv -f -- "$temporary_auth" "$target_auth" + if [ "$(hash_file "$temporary_previous_auth")" != "$6" ]; then + mv -f -- "$temporary_previous_auth" "$target_auth" + exit 39 + fi + rm -- "$temporary_previous_auth" +fi +if [ "$8" = 1 ]; then + [ "$(hash_file "$source_auth")" = "$5" ] || exit 40 + rm -- "$source_auth" + printf '%s\n' '{"completed":true}' > "$temporary_marker" + chmod 600 "$temporary_marker" + mv -f -- "$temporary_marker" "$3" +fi +` + +const FINALIZE_ABSENT_AUTH_SCRIPT = ` +set -eu +[ ! -f "$3" ] || exit 0 +${RESOLVE_LEGACY_HOME_SCRIPT} +[ ! -e "$legacy_home/auth.json" ] && [ ! -L "$legacy_home/auth.json" ] || exit 41 +umask 077 +temporary_marker="$3.orca-drain-$$" +trap 'rm -f -- "$temporary_marker"' EXIT HUP INT TERM +printf '%s\n' '{"completed":true}' > "$temporary_marker" +chmod 600 "$temporary_marker" +mv -f -- "$temporary_marker" "$3" +` + +export const _internals = { + applyLegacyAuthScript: APPLY_LEGACY_AUTH_SCRIPT, + resetDrainQueue: (): void => { + drainQueueByDistro.clear() + completedDistroKeys.clear() + } +} diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index cf3a0105d63..e9c0b9a2e0d 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -61,6 +61,7 @@ import { } from '../codex/codex-config-mirror' import { parseWslUncPath, toLinuxPath } from '../../shared/wsl-paths' import { + getCodexSelectionLaneKey, getWslSelectionKey, getSelectedCodexAccountIdForTarget, normalizeCodexRuntimeSelection, @@ -91,10 +92,16 @@ import { CodexCredentialAbsenceGrace } from './codex-credential-absence-grace' import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility' import { getCodexPaneAccount, + hasRecordedLegacyWslCodexPane, hasRecordedLegacySharedCodexPane, type CodexPaneHomeRoute } from '../codex/codex-pane-account-registry' import { isShellStartupEnvProbeSupported } from '../pty/shell-startup-env' +import { + startLegacyWslRuntimeAuthDrain, + type LegacyWslRuntimeAuthDestination +} from './legacy-wsl-runtime-auth-drain' +import { readWslCodexAuths, type WslCodexAuthRead } from './wsl-codex-auth-batch-reader' type CodexSystemDefaultSnapshot = { authJson: string | null @@ -244,11 +251,11 @@ export class CodexRuntimeHomeService { ): string | null { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) - const syncedRuntimeHomePath = this.syncWslRuntimeForCurrentSelection(wslTarget) - this.syncWslConfigAndGlobalInstructionsForLaunch(wslTarget, syncedRuntimeHomePath) - const runtimeHomePath = syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) - this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath) - return runtimeHomePath + const homePath = this.getWslCodexHomePathForSelection(wslTarget) + this.startLegacyWslAuthDrain(wslTarget) + this.syncWslConfigAndGlobalInstructionsForLaunch(wslTarget, homePath) + this.startWslSessionBridgeForLaunch(wslTarget, homePath) + return homePath } const selfContainedAccount = this.getSelfContainedManagedHostAccount() if (selfContainedAccount) { @@ -790,10 +797,9 @@ export class CodexRuntimeHomeService { prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): CodexRateLimitHomeResolution { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) - const syncedRuntimeHomePath = this.getPreparedWslRateLimitHomePath(wslTarget) return { kind: 'ready', - codexHomePath: syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) + codexHomePath: this.getPreparedWslRateLimitHomePath(wslTarget) } } const selfContainedAccount = this.getSelfContainedManagedHostAccount() @@ -833,7 +839,7 @@ export class CodexRuntimeHomeService { launchEnv?: NodeJS.ProcessEnv ): void { if (target?.runtime === 'wsl') { - this.syncWslRuntimeForCurrentSelection(target) + this.startLegacyWslAuthDrain(this.resolveWslDefaultTarget(target)) return } @@ -1051,28 +1057,128 @@ export class CodexRuntimeHomeService { } private getPreparedWslRateLimitHomePath(target: CodexAccountSelectionTarget): string | null { - const distro = target.wslDistro?.trim() - if (distro) { - const settings = this.store.getSettings() - const selectedAccountId = getSelectedCodexAccountIdForTarget(settings, target) - if (selectedAccountId === null) { - // Why: the system-default account changes outside Orca, so read its real home directly to avoid a stale cached runtime copy. - return this.getWslSystemCodexHomePath(target) - } - const cachedRuntimeHomePath = this.wslRuntimeHomePathByDistro.get(distro) - if ( - cachedRuntimeHomePath && - this.lastSyncedWslAccountIdByDistro.has(distro) && - this.lastSyncedWslAccountIdByDistro.get(distro) === selectedAccountId - ) { - // Why: RateLimitService resolves provenance twice per poll; stay path-only so it doesn't block main on UNC reads and a wsl.exe probe. - return cachedRuntimeHomePath - } - } - return this.syncWslRuntimeForCurrentSelection(target) + this.startLegacyWslAuthDrain(target) + return this.getWslCodexHomePathForSelection(target) } - private syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null { + private getWslCodexHomePathForSelection(target: CodexAccountSelectionTarget): string | null { + const settings = this.store.getSettings() + const account = this.getActiveAccount( + settings.codexManagedAccounts, + getSelectedCodexAccountIdForTarget(settings, target) + ) + if (account) { + const managedHome = parseWslUncPath(account.managedHomePath) + const targetDistro = this.resolveWslDefaultTarget(target).wslDistro?.trim() + // Persisted selections can outlive an account's runtime metadata. Never + // hand a host home (or another distro's UNC home) to a WSL launch. + const accountDistro = account.wslDistro?.trim() + const distroMatches = + (!accountDistro || + !targetDistro || + accountDistro.toLowerCase() === targetDistro.toLowerCase()) && + (!managedHome || + !targetDistro || + managedHome.distro.toLowerCase() === targetDistro.toLowerCase()) + // Older persisted fixtures may not carry a UNC spelling, but the + // explicit WSL runtime marker still proves the lane; reject unmarked + // host paths while retaining those legacy WSL records. + if (distroMatches && (account.managedHomeRuntime === 'wsl' || managedHome)) { + return account.managedHomePath + } + } + return this.getWslSystemCodexHomePath(target) + } + + private startLegacyWslAuthDrain(target: CodexAccountSelectionTarget): void { + if (process.platform !== 'win32') { + return + } + const distro = target.wslDistro?.trim() || getDefaultWslDistro() + if (!distro) { + return + } + const guestHome = getWslHome(distro) + const parsedGuestHome = guestHome ? parseWslUncPath(guestHome) : null + if (!parsedGuestHome) { + return + } + let legacyPanePresent: boolean + try { + legacyPanePresent = hasRecordedLegacyWslCodexPane(getCodexSelectionLaneKey(target)) + } catch (error) { + console.warn('[codex-wsl-auth-drain] Pane registry unavailable; deferring drain:', error) + return + } + startLegacyWslRuntimeAuthDrain({ + distro, + guestHomeLinuxPath: parsedGuestHome.linuxPath, + legacyPanePresent, + resolveDestination: (runtimeAuthContents) => + this.resolveLegacyWslAuthDestination(distro, runtimeAuthContents) + }) + } + + private async resolveLegacyWslAuthDestination( + distro: string, + runtimeAuthContents: string + ): Promise { + const accounts = this.store.getSettings().codexManagedAccounts.filter((account) => { + const parsedHome = parseWslUncPath(account.managedHomePath) + return parsedHome?.distro.toLowerCase() === distro.toLowerCase() + }) + const accountHomes = accounts.flatMap((account) => { + const parsedHome = parseWslUncPath(account.managedHomePath) + return parsedHome ? [{ account, linuxPath: parsedHome.linuxPath }] : [] + }) + const systemHome = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) + const parsedSystemHome = systemHome ? parseWslUncPath(systemHome) : null + let reads: WslCodexAuthRead[] + try { + reads = await readWslCodexAuths(distro, [ + ...accountHomes.map(({ linuxPath }) => linuxPath), + ...(parsedSystemHome ? [parsedSystemHome.linuxPath] : []) + ]) + } catch { + reads = accountHomes.map(() => ({ kind: 'unreadable' })) + if (parsedSystemHome) { + reads.push({ kind: 'unreadable' }) + } + } + const authReads = new Map( + accountHomes.map(({ account }, index) => [account.id, reads[index] ?? { kind: 'unreadable' }]) + ) + const match = this.findManagedAccountForRuntimeAuth(runtimeAuthContents, undefined, { + accounts, + authReads + }) + if (match.kind === 'ambiguous') { + return null + } + if (match.kind === 'matched') { + const parsedHome = parseWslUncPath(match.account.managedHomePath) + if (!parsedHome || parsedHome.distro.toLowerCase() !== distro.toLowerCase()) { + return null + } + return { + authContents: match.managedAuthContents, + linuxHomePath: parsedHome.linuxPath + } + } + + if (!systemHome || !parsedSystemHome) { + return null + } + const systemAuth = reads[accountHomes.length] ?? { kind: 'unreadable' } + if (systemAuth.kind !== 'present') { + return null + } + return this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemAuth.contents) + ? { authContents: systemAuth.contents, linuxHomePath: parsedSystemHome.linuxPath } + : null + } + + syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null { if (process.platform !== 'win32') { return null } @@ -1319,7 +1425,11 @@ export class CodexRuntimeHomeService { private findManagedAccountForRuntimeAuth( runtimeAuthContents: string, - expectedAccountId?: string + expectedAccountId?: string, + options?: { + accounts: readonly CodexManagedAccount[] + authReads: ReadonlyMap + } ): CodexReadBackMatch { const matches: { account: CodexManagedAccount @@ -1327,18 +1437,17 @@ export class CodexRuntimeHomeService { managedAuthContents: string }[] = [] let unreadableHomeCouldOwnRuntimeAuth = false - for (const account of this.store.getSettings().codexManagedAccounts) { + for (const account of options?.accounts ?? this.store.getSettings().codexManagedAccounts) { if (expectedAccountId && account.id !== expectedAccountId) { continue } const managedAuthPath = join(account.managedHomePath, 'auth.json') - if (!existsSync(managedAuthPath)) { + let managedAuthContents: string + const suppliedRead = options?.authReads.get(account.id) + if (suppliedRead?.kind === 'missing') { continue } - let managedAuthContents: string - try { - managedAuthContents = readFileSync(managedAuthPath, 'utf-8') - } catch { + if (suppliedRead?.kind === 'unreadable') { // Why: an unreadable home can never be compared, but letting the read // throw abandons the scan for every other account — dropping a refresh // the runtime home holds for one of them. Only its record can rule it @@ -1351,6 +1460,24 @@ export class CodexRuntimeHomeService { } continue } + if (suppliedRead?.kind === 'present') { + managedAuthContents = suppliedRead.contents + } else { + if (!existsSync(managedAuthPath)) { + continue + } + try { + managedAuthContents = readFileSync(managedAuthPath, 'utf-8') + } catch { + if ( + !expectedAccountId && + codexAuthCouldBelongToManagedAccount(runtimeAuthContents, account) + ) { + unreadableHomeCouldOwnRuntimeAuth = true + } + continue + } + } if (codexAuthMatchesManagedAccount(runtimeAuthContents, account, managedAuthContents)) { matches.push({ account, managedAuthPath, managedAuthContents }) } diff --git a/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts b/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts index efc7ea3f2b9..95228744bab 100644 --- a/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { createSettings } from './runtime-home-settings-test-fixtures' import { @@ -87,18 +87,16 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"host-system"}\n') expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslRuntimeHomePath - ) - expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe( - '{"account":"wsl"}\n' + wslManagedHomePath ) + expect(existsSync(join(wslRuntimeHomePath, 'auth.json'))).toBe(false) expect(service.prepareForRateLimitFetch()).toEqual({ kind: 'ready', codexHomePath: getRuntimeCodexHomePath() }) expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toEqual({ kind: 'ready', - codexHomePath: wslRuntimeHomePath + codexHomePath: wslManagedHomePath }) } finally { if (originalPlatform) { @@ -107,7 +105,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('clears a selected WSL managed account when auth.json is missing', async () => { + it('keeps a selected WSL managed home when auth.json is temporarily missing', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -161,13 +159,11 @@ describe('CodexRuntimeHomeService', () => { ) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslRuntimeHomePath + managedHomePath ) - expect(store.updateSettings).toHaveBeenCalledWith({ - activeCodexManagedAccountId: null, - activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } - }) - expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth) + expect(store.updateSettings).not.toHaveBeenCalled() + expect(existsSync(join(wslRuntimeHomePath, 'auth.json'))).toBe(false) + expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -175,7 +171,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('seeds the WSL runtime config with rewritten paths and no system hook trust', async () => { + it('launches WSL system default against its existing config without a runtime seed', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -214,21 +210,18 @@ describe('CodexRuntimeHomeService', () => { ) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslRuntimeHomePath + systemCodexHomePath ) const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml') - const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8') - expect(runtimeConfig).toContain( - `model_instructions_file = '${join(systemCodexHomePath, 'instructions.md')}'` - ) - expect(runtimeConfig).toContain('[projects."/home/alice/repo"]') - expect(runtimeConfig).not.toContain('[hooks.state.') + expect(existsSync(runtimeConfigPath)).toBe(false) + const systemConfigPath = join(systemCodexHomePath, 'config.toml') + const systemConfig = readFileSync(systemConfigPath, 'utf-8') + expect(systemConfig).toContain('model_instructions_file = "instructions.md"') + expect(systemConfig).toContain('[hooks.state.') - // Why: WSL runtime configs are seeded once; Codex writes trust into them - // afterwards, so a relaunch must not clobber the seeded file. - writeFileSync(runtimeConfigPath, `${runtimeConfig}\n[projects."/tmp/x"]\n`, 'utf-8') + writeFileSync(systemConfigPath, `${systemConfig}\n[projects."/tmp/x"]\n`, 'utf-8') service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - expect(readFileSync(runtimeConfigPath, 'utf-8')).toContain('[projects."/tmp/x"]') + expect(readFileSync(systemConfigPath, 'utf-8')).toContain('[projects."/tmp/x"]') } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -255,7 +248,7 @@ describe('CodexRuntimeHomeService', () => { ).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'") }) - it('switches WSL accounts by rewriting one stable WSL runtime home', async () => { + it('switches WSL accounts by selecting each account home directly', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -317,16 +310,17 @@ describe('CodexRuntimeHomeService', () => { 'home' ) - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) - expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(firstAuth) + expect(service.prepareForCodexLaunch(target)).toBe(firstManagedHomePath) + expect(existsSync(join(wslRuntimeHomePath, 'auth.json'))).toBe(false) store.updateSettings({ activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-2' } } }) service.syncForCurrentSelection(target) - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) - expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(secondAuth) + expect(service.prepareForCodexLaunch(target)).toBe(secondManagedHomePath) + expect(readFileSync(join(firstManagedHomePath, 'auth.json'), 'utf-8')).toBe(firstAuth) + expect(readFileSync(join(secondManagedHomePath, 'auth.json'), 'utf-8')).toBe(secondAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -413,10 +407,10 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslRuntimeHomePath + wslManagedHomePath ) expect(readFileSync(join(wslManagedHomePath, 'auth.json'), 'utf-8')).toBe(wslManagedAuth) - expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(wslManagedAuth) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(staleWslRuntimeAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -476,7 +470,8 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + expect(service.prepareForCodexLaunch(target)).toBe(managedHomePath) + mkdirSync(wslRuntimeHomePath, { recursive: true }) writeFileSync(runtimeAuthPath, staleRuntimeAuth, 'utf-8') writeFileSync(managedAuthPath, reauthedAuth, 'utf-8') @@ -484,7 +479,7 @@ describe('CodexRuntimeHomeService', () => { service.syncForCurrentSelection(target) expect(readFileSync(managedAuthPath, 'utf-8')).toBe(reauthedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(reauthedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(staleRuntimeAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -492,7 +487,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('reads active WSL token refreshes back before restart using the selected distro', async () => { + it('keeps direct-home WSL token refreshes canonical across restart preservation', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -548,13 +543,13 @@ describe('CodexRuntimeHomeService', () => { ) const runtimeAuthPath = join(wslRuntimeHomePath, 'auth.json') - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) - writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') + expect(service.prepareForCodexLaunch(target)).toBe(managedHomePath) + writeFileSync(managedAuthPath, refreshedAuth, 'utf-8') service.syncActiveWslSelectionsBeforeRestart() expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) + expect(existsSync(runtimeAuthPath)).toBe(false) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) diff --git a/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts b/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts index 05cfa12e240..33eae33977f 100644 --- a/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import type * as WslPaths from '../../shared/wsl-paths' import { createSettings } from './runtime-home-settings-test-fixtures' @@ -89,7 +89,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('starts WSL session bridging after materializing the WSL launch home', async () => { + it('skips WSL session bridging when system default already uses its direct home', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve()) @@ -124,17 +124,13 @@ describe('CodexRuntimeHomeService', () => { ) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslRuntimeHomePath + wslSystemHomePath ) - expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1) - expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ - distro: 'Ubuntu', - systemCodexHomePath: wslSystemHomePath, - managedCodexHomePath: wslRuntimeHomePath - }) - const runtimeAgentsPath = join(wslRuntimeHomePath, 'AGENTS.md') - expect(readFileSync(runtimeAgentsPath, 'utf-8')).toBe('# WSL instructions\n') - expect(lstatSync(runtimeAgentsPath).isSymbolicLink()).toBe(false) + expect(startWslCodexSessionBridgeInBackground).not.toHaveBeenCalled() + expect(readFileSync(join(wslSystemHomePath, 'AGENTS.md'), 'utf-8')).toBe( + '# WSL instructions\n' + ) + expect(existsSync(join(wslRuntimeHomePath, 'AGENTS.md'))).toBe(false) } finally { vi.doUnmock('../codex/wsl-codex-session-bridge') vi.doUnmock('../wsl') @@ -144,7 +140,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('promotes WSL in-Codex setting changes on the next Codex launch', async () => { + it('keeps WSL in-Codex setting changes in the direct system home', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) vi.doMock('../codex/wsl-codex-session-bridge', () => ({ @@ -177,32 +173,20 @@ describe('CodexRuntimeHomeService', () => { 'home' ) - // First launch seeds the runtime config and records the per-distro baseline. expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslRuntimeHomePath + join(wslHome, '.codex') ) const baselinePath = join(wslRuntimeHomePath, '.orca-config-settings-baseline.json') - expect(existsSync(baselinePath)).toBe(true) + expect(existsSync(baselinePath)).toBe(false) - // A direct WSL Codex edit wins and is mirrored into Orca's runtime before - // the baseline advances, so later in-Orca changes remain promotable. - const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml') writeFileSync(wslSystemConfigPath, 'model = "outside-edit"\n', 'utf-8') service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('model = "outside-edit"\n') - expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"outside-edit\\""') - - // Codex now persists a /model change inside Orca's reconciled runtime. - writeFileSync( - runtimeConfigPath, - readFileSync(runtimeConfigPath, 'utf-8').replace('model = "outside-edit"', 'model = "o4"'), - 'utf-8' - ) + expect(readFileSync(wslSystemConfigPath, 'utf-8')).toBe('model = "outside-edit"\n') + writeFileSync(wslSystemConfigPath, 'model = "o4"\n', 'utf-8') service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) expect(readFileSync(wslSystemConfigPath, 'utf-8')).toBe('model = "o4"\n') - // Baseline advances so the promoted value is not re-promoted forever. - expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"o4\\""') + expect(existsSync(baselinePath)).toBe(false) } finally { vi.doUnmock('../codex/wsl-codex-session-bridge') vi.doUnmock('../wsl') @@ -236,22 +220,13 @@ describe('CodexRuntimeHomeService', () => { try { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - const wslRuntimeHomePath = join( - wslHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home' - ) - service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1) expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ distro: 'Ubuntu', systemCodexHomePath: '/home/me/.config/codex', - managedCodexHomePath: wslRuntimeHomePath + managedCodexHomePath: join(wslHome, '.codex') }) } finally { vi.doUnmock('../codex/wsl-codex-session-bridge') @@ -287,7 +262,8 @@ describe('CodexRuntimeHomeService', () => { return { ...actual, parseWslUncPath: (candidate: string) => - candidate === wslRuntimeHomePath + candidate === wslRuntimeHomePath || + candidate.includes('codex-accounts/debian-account/home') ? { distro: 'Debian', linuxPath: '/home/alice/.local/share/orca/codex-runtime-home/home' @@ -328,12 +304,12 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: null })).toBe( - wslRuntimeHomePath + managedHomePath ) expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ distro: 'Debian', systemCodexHomePath: join(wslHome, '.codex'), - managedCodexHomePath: wslRuntimeHomePath + managedCodexHomePath: managedHomePath }) } finally { vi.doUnmock('../codex/wsl-codex-session-bridge') diff --git a/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts b/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts index a1ad86a0a5f..dbf0241a155 100644 --- a/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts @@ -93,15 +93,6 @@ describe('CodexRuntimeHomeService', () => { const debianAuth = createCodexAuthJson('debian@example.com', 'acct-debian', 'debian-token') const ubuntuHomePath = createManagedAuth(testState.userDataDir, 'ubuntu-account', ubuntuAuth) const debianHomePath = createManagedAuth(testState.userDataDir, 'debian-account', debianAuth) - const runtimeAuthPath = join( - wslHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home', - 'auth.json' - ) const store = createStore( createSettings({ codexManagedAccounts: [ @@ -147,9 +138,9 @@ describe('CodexRuntimeHomeService', () => { expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: null })).toEqual({ kind: 'ready', - codexHomePath: join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home') + codexHomePath: ubuntuHomePath }) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(ubuntuAuth) + expect(readFileSync(join(ubuntuHomePath, 'auth.json'), 'utf-8')).toBe(ubuntuAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -228,7 +219,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('reads WSL system-default token refreshes back to WSL system auth', async () => { + it('keeps WSL system-default token refreshes in its direct home', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -257,21 +248,11 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - const wslRuntimeHomePath = join( - wslHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home' - ) + expect(service.prepareForCodexLaunch(target)).toBe(systemCodexHomePath) + writeFileSync(join(systemCodexHomePath, 'auth.json'), refreshedAuth, 'utf-8') - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) - writeFileSync(join(wslRuntimeHomePath, 'auth.json'), refreshedAuth, 'utf-8') - - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + expect(service.prepareForCodexLaunch(target)).toBe(systemCodexHomePath) expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) - expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -279,7 +260,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('preserves WSL system-default token refreshes after app restart', async () => { + it('does not overwrite direct WSL system auth from the retired runtime on restart', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -319,8 +300,8 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) - expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) + expect(service.prepareForCodexLaunch(target)).toBe(systemCodexHomePath) + expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth) expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) } finally { if (originalPlatform) { diff --git a/src/main/codex-accounts/wsl-codex-auth-batch-reader.ts b/src/main/codex-accounts/wsl-codex-auth-batch-reader.ts new file mode 100644 index 00000000000..40870fcd5ef --- /dev/null +++ b/src/main/codex-accounts/wsl-codex-auth-batch-reader.ts @@ -0,0 +1,70 @@ +import { runWslProcess } from '../wsl/wsl-runner' + +export type WslCodexAuthRead = + | { kind: 'missing' | 'unreadable' } + | { kind: 'present'; contents: string } + +export async function readWslCodexAuths( + distro: string, + linuxHomePaths: readonly string[] +): Promise { + if (linuxHomePaths.length === 0) { + return [] + } + const result = await runWslProcess({ + distro, + loginPath: 'none', + script: READ_AUTHS_SCRIPT, + args: linuxHomePaths, + timeoutMs: 5_000, + maxOutputBytes: 2 * 1024 * 1024 + }) + if (result.code !== 0 || result.timedOut) { + return linuxHomePaths.map(() => ({ kind: 'unreadable' })) + } + const rows = result.stdout.split('\n') + return linuxHomePaths.map((_, index) => parseAuthReadRow(rows[index] ?? '')) +} + +export function decodeWslBase64Payload(encoded: string): string | null { + try { + const decoded = Buffer.from(encoded, 'base64') + const canonical = decoded.toString('base64').replace(/=+$/, '') + return canonical === encoded.replace(/=+$/, '') ? decoded.toString('utf8') : null + } catch { + return null + } +} + +function parseAuthReadRow(row: string): WslCodexAuthRead { + if (row === 'missing' || row === 'unreadable') { + return { kind: row } + } + if (!row.startsWith('present:')) { + return { kind: 'unreadable' } + } + const contents = decodeWslBase64Payload(row.slice('present:'.length)) + return contents === null ? { kind: 'unreadable' } : { kind: 'present', contents } +} + +const READ_AUTHS_SCRIPT = ` +set -eu +for home_path in "$@"; do + auth_path="$home_path/auth.json" + if [ ! -f "$auth_path" ]; then + if [ ! -e "$auth_path" ] && [ ! -L "$auth_path" ]; then + printf 'missing\n' + else + printf 'unreadable\n' + fi + continue + fi + if encoded=$(base64 < "$auth_path"); then + printf 'present:' + printf '%s' "$encoded" | tr -d '\n' + printf '\n' + else + printf 'unreadable\n' + fi +done +` diff --git a/src/main/codex/codex-pane-account-registry.ts b/src/main/codex/codex-pane-account-registry.ts index 449c11ba711..4a4467bd219 100644 --- a/src/main/codex/codex-pane-account-registry.ts +++ b/src/main/codex/codex-pane-account-registry.ts @@ -266,6 +266,15 @@ export function hasRecordedLegacySharedCodexPane(): boolean { ) } +/** True when a retained WSL pane may still read the retired per-distro runtime home. */ +export function hasRecordedLegacyWslCodexPane(selectionKey: string): boolean { + return Object.values(readRegistryOrThrow().panes).some( + (record) => + (record.selectionKey === selectionKey || record.selectionKey === 'wsl:__default__') && + (record.homeRoute === undefined || record.homeRoute === 'wsl-home') + ) +} + /** True when startup may need to repair hooks for a retained managed host pane. */ export function hasRecordedManagedHostCodexPane(): boolean { return Object.values(readRegistry().panes).some( diff --git a/src/main/codex/codex-pane-launch-account.test.ts b/src/main/codex/codex-pane-launch-account.test.ts index 456b9bc08be..c862099e3ac 100644 --- a/src/main/codex/codex-pane-launch-account.test.ts +++ b/src/main/codex/codex-pane-launch-account.test.ts @@ -202,7 +202,7 @@ describe('resolveCodexPaneLaunchAccount', () => { ).toEqual({ selectionKey: 'wsl:Ubuntu', accountId: 'wsl-account', - homeRoute: 'wsl-home' + homeRoute: 'account-home' }) }) diff --git a/src/main/codex/codex-pane-launch-account.ts b/src/main/codex/codex-pane-launch-account.ts index b99700da081..089c1975db3 100644 --- a/src/main/codex/codex-pane-launch-account.ts +++ b/src/main/codex/codex-pane-launch-account.ts @@ -1,5 +1,6 @@ import type { GlobalSettings } from '../../shared/global-settings-types' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { parseWslUncPath } from '../../shared/wsl-paths' import { getCodexSelectionLaneKey, getCodexSelectionTargetForAccount, @@ -80,9 +81,6 @@ function resolveCodexPaneHomeRoute(args: { settings: CodexPaneLaunchAccountSettings target: CodexAccountSelectionTarget }): CodexPaneHomeRoute { - if (args.target.runtime === 'wsl') { - return 'wsl-home' - } if ( !args.launchCodexHomePath || normalizeRuntimePathForComparison(args.launchCodexHomePath) === @@ -93,10 +91,19 @@ function resolveCodexPaneHomeRoute(args: { const launchHome = normalizeRuntimePathForComparison(args.launchCodexHomePath) const accountOwnsHome = args.settings.codexManagedAccounts?.some( (account) => - getCodexSelectionTargetForAccount(account).runtime === 'host' && + getCodexSelectionLaneKey(getCodexSelectionTargetForAccount(account)) === + getCodexSelectionLaneKey(args.target) && normalizeRuntimePathForComparison(account.managedHomePath) === launchHome ) - return accountOwnsHome ? 'account-home' : 'shared-home' + if (accountOwnsHome) { + return 'account-home' + } + if (args.target.runtime === 'wsl') { + return parseWslUncPath(args.launchCodexHomePath)?.linuxPath.endsWith('/.codex') + ? 'real-home' + : 'wsl-home' + } + return 'shared-home' } /** undefined when no account owns the home; null means the system-default account. */ diff --git a/src/main/codex/codex-stale-pane-accounts.test.ts b/src/main/codex/codex-stale-pane-accounts.test.ts index bf2f7e5b5ea..c5deb104543 100644 --- a/src/main/codex/codex-stale-pane-accounts.test.ts +++ b/src/main/codex/codex-stale-pane-accounts.test.ts @@ -8,6 +8,7 @@ import { forgetCodexPaneAccount, getCodexPaneAccount, hasRecordedLegacySharedCodexPane, + hasRecordedLegacyWslCodexPane, hasRecordedManagedHostCodexPane, isCodexPaneHomeRouteProvenAwayFromSharedHome, reconcileCodexPaneAccountsWithLivePtys, @@ -138,6 +139,36 @@ describe('codex pane account registry', () => { expect(hasRecordedLegacySharedCodexPane()).toBe(true) }) + it('identifies only legacy runtime-home panes on the requested WSL lane', () => { + recordCodexPaneAccount('pty-legacy', { + selectionKey: 'wsl:Ubuntu', + accountId: 'account-old', + homeRoute: 'wsl-home' + }) + recordCodexPaneAccount('pty-direct', { + selectionKey: 'wsl:Ubuntu', + accountId: 'account-new', + homeRoute: 'account-home' + }) + recordCodexPaneAccount('pty-other-distro', { + selectionKey: 'wsl:Debian', + accountId: 'account-debian', + homeRoute: 'wsl-home' + }) + recordCodexPaneAccount('pty-default', { + selectionKey: 'wsl:__default__', + accountId: null, + homeRoute: 'wsl-home' + }) + + expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(true) + forgetCodexPaneAccount('pty-legacy') + expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(true) + forgetCodexPaneAccount('pty-default') + expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(false) + expect(hasRecordedLegacyWslCodexPane('wsl:Debian')).toBe(true) + }) + it('requests startup inventory only for managed host panes', () => { recordCodexPaneAccount('pty-real', { selectionKey: 'host', From 9fb522023979c0d56761782be024c7630f6046f1 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:17:59 -0700 Subject: [PATCH 039/152] Prevent duplicate file renames when input unmounts after Enter (#16719) When Enter is pressed to confirm a rename, the input unmounts and its onBlur handler fires as it detaches from the DOM. Without consuming this event, a second commitRename call would attempt to rename against the old path. Setting the cancel flag after capturing the new name causes the trailing onBlur to return early, preventing the duplicate operation. --- .../editor/editor-header-file-rename.ts | 4 ++- .../components/tab-bar/EditorFileTab.test.tsx | 24 ++++++++++++++ .../src/components/tab-bar/EditorFileTab.tsx | 4 ++- tests/e2e/floating-tab-rename.spec.ts | 31 +++++++++++++++++++ 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/editor/editor-header-file-rename.ts b/src/renderer/src/components/editor/editor-header-file-rename.ts index 16f5e557faa..e8a787dd2b9 100644 --- a/src/renderer/src/components/editor/editor-header-file-rename.ts +++ b/src/renderer/src/components/editor/editor-header-file-rename.ts @@ -42,7 +42,6 @@ export function useEditorHeaderFileRename(activeFile: OpenFile): EditorHeaderFil const commitRename = (): void => { if (renameCancelledRef.current) { - renameCancelledRef.current = false setIsRenaming(false) return } @@ -52,6 +51,9 @@ export function useEditorHeaderFileRename(activeFile: OpenFile): EditorHeaderFil return } const newName = input.value.trim() + // onBlur follows Enter when the input unmounts; consume that trailing event + // so one user action cannot start a second rename against the old path. + renameCancelledRef.current = true setIsRenaming(false) if (!newName || newName === currentFileName) { return diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx index 4edf74a7cba..6eb614550c9 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx @@ -454,6 +454,30 @@ describe('EditorFileTab rename menu', () => { }) }) + it('does not re-commit when unmounting the rename input emits multiple blur events', async () => { + const file = baseFile() + const firstRender = expandNode((await renderEditorFileTab(file)).element) + const renameItem = findMenuItemByText(firstRender, 'Rename') + + ;(renameItem.props.onSelect as () => void)() + + const secondRender = expandNode((await renderEditorFileTab(file)).element) + const input = findElementsByType(secondRender, 'input')[0] + const setInputRef = input.props.ref as (input: HTMLInputElement | null) => void + setInputRef({ + focus: vi.fn(), + select: vi.fn(), + setSelectionRange: vi.fn(), + value: 'renamed.md' + } as unknown as HTMLInputElement) + + pressInputKey(input, 'Enter') + ;(input.props.onBlur as () => void)() + ;(input.props.onBlur as () => void)() + + expect(renameFileOnDiskMock).toHaveBeenCalledTimes(1) + }) + it('disables Rename for diff tabs that do not map to one writable file', async () => { const file = baseFile({ mode: 'diff', diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index aca2014bb91..028c0b745b6 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -132,7 +132,6 @@ export default function EditorFileTab({ const commitRename = (): void => { if (renameCancelledRef.current) { - renameCancelledRef.current = false setIsRenaming(false) return } @@ -142,6 +141,9 @@ export default function EditorFileTab({ return } const newName = input.value.trim() + // onBlur follows Enter when the input unmounts; consume that trailing event + // so one user action cannot start a second rename against the old path. + renameCancelledRef.current = true setIsRenaming(false) if (!newName) { return diff --git a/tests/e2e/floating-tab-rename.spec.ts b/tests/e2e/floating-tab-rename.spec.ts index 94efe4650ee..6f671789353 100644 --- a/tests/e2e/floating-tab-rename.spec.ts +++ b/tests/e2e/floating-tab-rename.spec.ts @@ -141,6 +141,37 @@ test('concurrent floating Markdown renames do not clobber the destination', asyn ).toEqual(['first\n', 'second\n']) }) +test('Enter commits a floating Markdown rename only once', async ({ orcaPage }) => { + const seeded = await seedFloatingMarkdownFile(orcaPage) + await openFloatingPanel(orcaPage) + + const panel = orcaPage.locator(OPEN_PANEL_SELECTOR) + const tab = panel.locator(`[data-tab-id="${seeded.tabId}"]`) + await tab.click({ button: 'right' }) + await orcaPage.getByRole('menuitem').filter({ hasText: 'Rename' }).first().click() + + const input = panel.getByRole('textbox', { + name: `Rename file ${seeded.originalName}`, + exact: true + }) + await input.fill(seeded.renamedName) + await input.press('Enter') + + await expect(tab).toContainText(seeded.renamedName) + await expect + .poll(() => + orcaPage.evaluate( + async ({ originalPath, renamedPath }) => ({ + originalExists: await window.api.fs.pathExists({ filePath: originalPath }), + renamedExists: await window.api.fs.pathExists({ filePath: renamedPath }) + }), + { originalPath: seeded.originalPath, renamedPath: seeded.renamedPath } + ) + ) + .toEqual({ originalExists: false, renamedExists: true }) + await expect(orcaPage.getByText(/Failed to rename/)).toHaveCount(0) +}) + test('Electron serializes native Unicode rename aliases', async ({ orcaPage }) => { test.skip(process.platform !== 'darwin', 'Requires native Unicode aliasing') const directory = await orcaPage.evaluate(() => window.api.app.getFloatingMarkdownDirectory()) From 673842db35b37557006d9f20d04e077780253d82 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:24:32 -0700 Subject: [PATCH 040/152] refactor(codex): remove WSL runtime mirror machinery (#16505) * refactor(codex): remove WSL runtime mirror machinery * test(codex): drop allowlist entries the mirror removal made stale runtime-home-service.ts no longer spawns wsl.exe or imports child_process; both boundary guards fail closed on a stale entry so the goalpost keeps moving. * fix(codex): drain legacy WSL auth before restart * fix(codex): await WSL auth drain before restart --- .../agent-auth-restart-preservation.test.ts | 126 ++++-- src/main/agent-auth-restart-preservation.ts | 49 +-- .../legacy-wsl-runtime-auth-drain.ts | 12 +- .../codex-accounts/runtime-home-service.ts | 390 ++---------------- .../runtime-home-wsl-managed-accounts.test.ts | 182 +------- .../runtime-home-wsl-session-bridge.test.ts | 68 +-- .../runtime-home-wsl-system-default.test.ts | 10 - src/main/pty/codex-home-wsl-env.ts | 4 +- .../__fixtures__/wsl-invocation-allowlist.txt | 1 - .../child-process-import-allowlist.txt | 1 - 10 files changed, 164 insertions(+), 679 deletions(-) diff --git a/src/main/agent-auth-restart-preservation.test.ts b/src/main/agent-auth-restart-preservation.test.ts index 441ec609624..b94bcc2435d 100644 --- a/src/main/agent-auth-restart-preservation.test.ts +++ b/src/main/agent-auth-restart-preservation.test.ts @@ -15,8 +15,7 @@ describe('preserveAgentAuthBeforeRestart', () => { codexRuntimeHome: { syncForCurrentSelection: vi.fn(() => { calls.push('codex') - }), - syncActiveWslSelectionsBeforeRestart: vi.fn() + }) }, claudeRuntimeAuth: { syncForCurrentSelection: vi.fn(async () => { @@ -33,35 +32,13 @@ describe('preserveAgentAuthBeforeRestart', () => { expect(calls).toEqual(['codex', 'claude', 'flush']) }) - it('runs WSL Codex preservation through the runtime service', async () => { - const syncForCurrentSelection = vi.fn() - const syncActiveWslSelectionsBeforeRestart = vi.fn() - - await preserveAgentAuthBeforeRestart({ - codexRuntimeHome: { - syncForCurrentSelection, - syncActiveWslSelectionsBeforeRestart - }, - store: { - flushPendingOrThrowAsync: vi.fn() - } - }) - - expect(syncForCurrentSelection).toHaveBeenCalledTimes(1) - expect(syncForCurrentSelection).toHaveBeenNthCalledWith(1) - expect(syncActiveWslSelectionsBeforeRestart).toHaveBeenCalledTimes(1) - }) - - it('runs Claude preservation before WSL Codex preservation', async () => { + it('runs Claude preservation after Codex and before the store flush', async () => { const calls: string[] = [] await preserveAgentAuthBeforeRestart({ codexRuntimeHome: { syncForCurrentSelection: vi.fn(() => { calls.push('codex-host') - }), - syncActiveWslSelectionsBeforeRestart: vi.fn(() => { - calls.push('codex-wsl') }) }, claudeRuntimeAuth: { @@ -76,26 +53,80 @@ describe('preserveAgentAuthBeforeRestart', () => { } }) - expect(calls).toEqual(['codex-host', 'claude', 'codex-wsl', 'flush']) + expect(calls).toEqual(['codex-host', 'claude', 'flush']) }) - it('continues after WSL Codex preservation fails', async () => { + it('drains retained WSL Codex auth before flushing the store', async () => { + const calls: string[] = [] + + await preserveAgentAuthBeforeRestart({ + codexRuntimeHome: { + syncForCurrentSelection: vi.fn(() => { + calls.push('codex-host') + }), + syncActiveWslSelectionsBeforeRestart: vi.fn(async () => { + calls.push('codex-wsl') + }) + }, + store: { + flushPendingOrThrowAsync: vi.fn(async () => { + calls.push('flush') + }) + } + }) + + expect(calls).toEqual(['codex-host', 'codex-wsl', 'flush']) + }) + + it('does not release restart while the bounded WSL drain is still running', async () => { + vi.useFakeTimers() + let finishWslDrain!: () => void + let settled = false + const flushPendingOrThrowAsync = vi.fn() + + const preservation = preserveAgentAuthBeforeRestart({ + codexRuntimeHome: { + syncForCurrentSelection: vi.fn(), + syncActiveWslSelectionsBeforeRestart: vi.fn( + () => + new Promise((resolve) => { + finishWslDrain = resolve + }) + ) + }, + store: { flushPendingOrThrowAsync } + }).then(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(2_000) + await vi.advanceTimersByTimeAsync(1) + expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(1) + expect(settled).toBe(false) + + finishWslDrain() + await preservation + expect(settled).toBe(true) + }) + + it('continues after the bounded WSL drain fails without logging secrets', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const flushPendingOrThrowAsync = vi.fn() await preserveAgentAuthBeforeRestart({ codexRuntimeHome: { syncForCurrentSelection: vi.fn(), - syncActiveWslSelectionsBeforeRestart: vi.fn(() => { + syncActiveWslSelectionsBeforeRestart: vi.fn(async () => { throw new Error('wsl-token-secret') }) }, - store: { - flushPendingOrThrowAsync - } + store: { flushPendingOrThrowAsync } }) expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith( + '[agent-auth-restart] Codex auth preservation failed (Error); continuing restart/update' + ) expect(JSON.stringify(warn.mock.calls)).not.toContain('token-secret') }) @@ -116,8 +147,7 @@ describe('preserveAgentAuthBeforeRestart', () => { codexRuntimeHome: { syncForCurrentSelection: vi.fn(() => { throw new Error('codex-token-secret') - }), - syncActiveWslSelectionsBeforeRestart: vi.fn() + }) }, claudeRuntimeAuth: { syncForCurrentSelection: vi.fn(async () => { @@ -170,6 +200,36 @@ describe('preserveAgentAuthBeforeRestart', () => { expect(calls).toEqual(['claude-start', 'flush', 'claude-finish']) }) + it('gives store persistence its own timeout after auth preservation expires', async () => { + vi.useFakeTimers() + let finishStoreFlush!: () => void + let settled = false + + const preservation = preserveAgentAuthBeforeRestart({ + claudeRuntimeAuth: { + syncForCurrentSelection: vi.fn(() => new Promise(() => {})) + }, + store: { + flushPendingOrThrowAsync: vi.fn( + () => + new Promise((resolve) => { + finishStoreFlush = resolve + }) + ) + } + }).then(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(2_000) + await vi.advanceTimersByTimeAsync(1) + expect(settled).toBe(false) + + finishStoreFlush() + await preservation + expect(settled).toBe(true) + }) + it('bounds a store flush that never settles', async () => { vi.useFakeTimers() const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/src/main/agent-auth-restart-preservation.ts b/src/main/agent-auth-restart-preservation.ts index eaae45f331b..e351bfee334 100644 --- a/src/main/agent-auth-restart-preservation.ts +++ b/src/main/agent-auth-restart-preservation.ts @@ -4,10 +4,8 @@ import type { Store } from './persistence' const AUTH_PRESERVATION_TIMEOUT_MS = 2_000 -type CodexRuntimeAuthSync = Pick< - CodexRuntimeHomeService, - 'syncForCurrentSelection' | 'syncActiveWslSelectionsBeforeRestart' -> +type CodexRuntimeAuthSync = Pick & + Partial> type ClaudeRuntimeAuthSync = Pick type ShutdownStore = Pick @@ -27,35 +25,26 @@ export async function preserveAgentAuthBeforeRestart({ claudeRuntimeAuth, store }: AgentAuthRestartPreservationOptions): Promise { - const startedAt = Date.now() - runCodexPreservationStep(codexRuntimeHome) + // Why: the drain owns guest-process timeouts; a shared 2s cutoff can relaunch before promotion. + const wslCodexPreservation = runWslCodexPreservationStep(codexRuntimeHome) - const remainingMs = Math.max(0, AUTH_PRESERVATION_TIMEOUT_MS - (Date.now() - startedAt)) - if (claudeRuntimeAuth && remainingMs > 0) { + if (claudeRuntimeAuth) { await runWithinLifecycleTimeout( 'Claude auth preservation', () => claudeRuntimeAuth.syncForCurrentSelection(), - remainingMs - ) - } else if (claudeRuntimeAuth) { - logStepTimeout('Claude auth preservation', 0) - } - - if (codexRuntimeHome && Date.now() - startedAt < AUTH_PRESERVATION_TIMEOUT_MS) { - runWslCodexPreservationStep(codexRuntimeHome) - } else if (codexRuntimeHome) { - logStepTimeout('Codex auth preservation', 0) - } - - if (store) { - const storeRemainingMs = Math.max(0, AUTH_PRESERVATION_TIMEOUT_MS - (Date.now() - startedAt)) - await runWithinLifecycleTimeout( - 'Store persistence', - () => store.flushPendingOrThrowAsync(), - storeRemainingMs + AUTH_PRESERVATION_TIMEOUT_MS ) } + + const storePreservation = store + ? runWithinLifecycleTimeout( + 'Store persistence', + () => store.flushPendingOrThrowAsync(), + AUTH_PRESERVATION_TIMEOUT_MS + ) + : Promise.resolve() + await Promise.all([wslCodexPreservation, storePreservation]) } function runCodexPreservationStep(codexRuntimeHome: CodexRuntimeAuthSync | null | undefined): void { @@ -66,11 +55,11 @@ function runCodexPreservationStep(codexRuntimeHome: CodexRuntimeAuthSync | null } } -function runWslCodexPreservationStep( +async function runWslCodexPreservationStep( codexRuntimeHome: CodexRuntimeAuthSync | null | undefined -): void { +): Promise { try { - codexRuntimeHome?.syncActiveWslSelectionsBeforeRestart() + await codexRuntimeHome?.syncActiveWslSelectionsBeforeRestart?.() } catch (error) { logStepFailure('Codex auth preservation', error) } @@ -89,7 +78,7 @@ async function runWithinLifecycleTimeout( }) // Why: this timeout only releases the restart/update path. It does not - // cancel a sync that already started, and Codex sync is synchronous today. + // cancel a sync that already started. const timeoutResult = new Promise<'timeout'>((resolve) => { timeout = setTimeout(() => resolve('timeout'), timeoutMs) }) diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts index cc50be185af..bde5d4c9ebc 100644 --- a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts @@ -31,16 +31,19 @@ type LegacyWslRuntimeAuthDrainOptions = { const drainQueueByDistro = new Map>() const completedDistroKeys = new Set() -export function startLegacyWslRuntimeAuthDrain(options: LegacyWslRuntimeAuthDrainOptions): void { +export function startLegacyWslRuntimeAuthDrain( + options: LegacyWslRuntimeAuthDrainOptions +): Promise { const key = options.distro.trim().toLowerCase() if (completedDistroKeys.has(key)) { - return + return Promise.resolve() } // Coalesce launch/rate-limit callers while a drain is in flight. Queuing a // new pass for every poll can otherwise build an unbounded promise chain // while a legacy pane keeps the migration pending. - if (drainQueueByDistro.has(key)) { - return + const inFlight = drainQueueByDistro.get(key) + if (inFlight) { + return inFlight } const next = drainLegacyWslRuntimeAuth(options) .then((status) => { @@ -57,6 +60,7 @@ export function startLegacyWslRuntimeAuthDrain(options: LegacyWslRuntimeAuthDrai drainQueueByDistro.delete(key) } }) + return next } export async function drainLegacyWslRuntimeAuth( diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index e9c0b9a2e0d..7975c7172ad 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -1,5 +1,4 @@ /* eslint-disable max-lines -- Why: keeps Codex's whole runtime-home contract in one place so account-switch semantics don't drift across launch/login/quota paths. */ -import { quotePosixShell } from '../../shared/wsl-login-shell-command' import { appendFileSync, copyFileSync, @@ -18,7 +17,6 @@ import { unlinkSync } from 'node:fs' import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' -import { execFileSync } from 'node:child_process' import { dirname, extname, @@ -33,7 +31,6 @@ import { app } from 'electron' import type { CodexManagedAccount } from '../../shared/managed-account-types' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import type { Store } from '../persistence' -import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env' import { recoverInterruptedGuardedFileOperation, removeFileAtomicallyIfUnchanged, @@ -55,17 +52,13 @@ import { resolveWslCodexSessionSourceHome } from '../codex/codex-session-source-home' import { startWslCodexSessionBridgeInBackground } from '../codex/wsl-codex-session-bridge' -import { - prepareSystemConfigForFreshRuntimeMirror, - syncSystemConfigIntoManagedCodexHome -} from '../codex/codex-config-mirror' +import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' import { parseWslUncPath, toLinuxPath } from '../../shared/wsl-paths' import { getCodexSelectionLaneKey, getWslSelectionKey, getSelectedCodexAccountIdForTarget, normalizeCodexRuntimeSelection, - setSelectedCodexAccountIdForTarget, type CodexAccountSelectionTarget } from './runtime-selection' import { getDefaultWslDistro, getWslHome } from '../wsl' @@ -137,7 +130,6 @@ type CodexRuntimeLogoutMarkerStatus = | { kind: 'applies' } | { kind: 'system-default-changed'; systemDefaultAuthJson: string | null } -type CodexReadBackResult = 'unchanged' | 'persisted' | 'rejected' type CodexReadBackMatch = | { kind: 'matched' @@ -202,11 +194,6 @@ export class CodexRuntimeHomeService { private lastSyncedAccountId: string | null = null // Last auth.json Orca wrote to the runtime home; a later diff signals an out-of-band change (Codex token refresh, or external login to adopt). private lastWrittenAuthJson: string | null = null - // Why: WSL terminals have per-distro runtime homes; sharing the host baseline can make stale WSL auth look newer than managed storage. - private readonly lastWrittenWslAuthJsonByDistro = new Map() - private readonly lastSyncedWslAccountIdByDistro = new Map() - private readonly wslRuntimeHomePathByDistro = new Map() - private skipNextReadBackForAccountId: string | null = null // Why: a managed host account refreshes auth in its own home. Remember that // provenance so a later deselect never adopts stale shared bytes. private lastHostAccountUsedSelfContainedHome = false @@ -731,26 +718,6 @@ export class CodexRuntimeHomeService { syncLegacySharedCodexConfigForRetainedPanes() } - syncActiveWslSelectionsBeforeRestart(): void { - if (process.platform !== 'win32') { - return - } - - const settings = this.store.getSettings() - for (const [selectedDistroKey, accountId] of Object.entries( - normalizeCodexRuntimeSelection(settings).wsl - )) { - if (!accountId) { - continue - } - const account = this.getActiveAccount(settings.codexManagedAccounts, accountId) - if (!account || account.managedHomeRuntime !== 'wsl') { - continue - } - this.safeReadBackActiveWslAccountBeforeRestart(account, selectedDistroKey) - } - } - private getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null { if (process.platform !== 'win32') { return null @@ -886,7 +853,6 @@ export class CodexRuntimeHomeService { // distro-local runtime home, so the host mirror only drops its baseline. this.lastSyncedAccountId = null this.lastWrittenAuthJson = null - this.skipNextReadBackForAccountId = null return } if (normalizeCodexRuntimeSelection(settings).host) { @@ -928,71 +894,13 @@ export class CodexRuntimeHomeService { } } - // Why: re-auth/add-account write fresh managed tokens, so skip the next read-back to avoid clobbering them with stale runtime tokens. + // Why: re-auth/add-account writes fresh host tokens, invalidating the shared mirror baseline. clearLastWrittenAuthJson( accountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host ): void { if (accountId === normalizeCodexRuntimeSelection(this.store.getSettings()).host) { this.lastWrittenAuthJson = null } - this.skipNextReadBackForAccountId = accountId - } - - private readBackRefreshedTokensFromPath( - runtimeAuthPath: string, - options: { - updateLastWrittenAuthJson: boolean - lastWrittenAuthJson?: string | null - setLastWrittenAuthJson?: (contents: string) => void - expectedAccountId?: string - } - ): CodexReadBackResult { - try { - if (!existsSync(runtimeAuthPath)) { - return 'unchanged' - } - - const lastWrittenAuthJson = - options.lastWrittenAuthJson === undefined - ? this.lastWrittenAuthJson - : options.lastWrittenAuthJson - const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8') - if (lastWrittenAuthJson !== null && runtimeContents === lastWrittenAuthJson) { - return 'unchanged' - } - - const match = this.findManagedAccountForRuntimeAuth( - runtimeContents, - options.expectedAccountId - ) - if (match.kind !== 'matched') { - if (match.kind === 'ambiguous') { - console.warn('[codex-runtime-home] Refusing ambiguous Codex auth read-back') - } - return 'rejected' - } - // Why: after restart there's no last-written baseline, so identity alone can't prove runtime auth is newer than managed storage. - if ( - lastWrittenAuthJson === null && - !this.runtimeAuthIsFresher(runtimeContents, match.managedAuthContents) - ) { - return 'rejected' - } - - writeFileAtomically(match.managedAuthPath, runtimeContents, { mode: 0o600 }) - if (options.updateLastWrittenAuthJson) { - if (options.setLastWrittenAuthJson) { - options.setLastWrittenAuthJson(runtimeContents) - } else { - this.lastWrittenAuthJson = runtimeContents - } - } - return 'persisted' - } catch (error) { - // Why: read-back is best-effort; a transient fs error must not block the forward sync — worst case is one more stale-token cycle. - console.warn('[codex-runtime-home] Failed to read back refreshed tokens:', error) - return 'rejected' - } } // Why: which ~/.codex bytes the mirror was seeded from, and whether the system @@ -1057,7 +965,6 @@ export class CodexRuntimeHomeService { } private getPreparedWslRateLimitHomePath(target: CodexAccountSelectionTarget): string | null { - this.startLegacyWslAuthDrain(target) return this.getWslCodexHomePathForSelection(target) } @@ -1090,27 +997,27 @@ export class CodexRuntimeHomeService { return this.getWslSystemCodexHomePath(target) } - private startLegacyWslAuthDrain(target: CodexAccountSelectionTarget): void { + private startLegacyWslAuthDrain(target: CodexAccountSelectionTarget): Promise { if (process.platform !== 'win32') { - return + return Promise.resolve() } const distro = target.wslDistro?.trim() || getDefaultWslDistro() if (!distro) { - return + return Promise.resolve() } const guestHome = getWslHome(distro) const parsedGuestHome = guestHome ? parseWslUncPath(guestHome) : null if (!parsedGuestHome) { - return + return Promise.resolve() } let legacyPanePresent: boolean try { legacyPanePresent = hasRecordedLegacyWslCodexPane(getCodexSelectionLaneKey(target)) } catch (error) { console.warn('[codex-wsl-auth-drain] Pane registry unavailable; deferring drain:', error) - return + return Promise.resolve() } - startLegacyWslRuntimeAuthDrain({ + return startLegacyWslRuntimeAuthDrain({ distro, guestHomeLinuxPath: parsedGuestHome.linuxPath, legacyPanePresent, @@ -1119,6 +1026,34 @@ export class CodexRuntimeHomeService { }) } + /** Preserve refreshed auth from retained legacy WSL panes before restart. */ + async syncActiveWslSelectionsBeforeRestart(): Promise { + if (process.platform !== 'win32') { + return + } + const settings = this.store.getSettings() + const drains: Promise[] = [] + for (const [selectedDistroKey, accountId] of Object.entries( + normalizeCodexRuntimeSelection(settings).wsl + )) { + if (!accountId) { + continue + } + const account = this.getActiveAccount(settings.codexManagedAccounts, accountId) + if (!account || account.managedHomeRuntime !== 'wsl') { + continue + } + const distro = + selectedDistroKey === getWslSelectionKey(null) + ? account.wslDistro?.trim() || null + : selectedDistroKey.trim() || null + if (distro) { + drains.push(this.startLegacyWslAuthDrain({ runtime: 'wsl', wslDistro: distro })) + } + } + await Promise.all(drains) + } + private async resolveLegacyWslAuthDestination( distro: string, runtimeAuthContents: string @@ -1178,204 +1113,6 @@ export class CodexRuntimeHomeService { : null } - syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null { - if (process.platform !== 'win32') { - return null - } - - const wslTarget = this.resolveWslDefaultTarget(target) - const settings = this.store.getSettings() - const activeAccount = this.getActiveAccount( - settings.codexManagedAccounts, - getSelectedCodexAccountIdForTarget(settings, wslTarget) - ) - const distro = wslTarget.wslDistro?.trim() || activeAccount?.wslDistro || getDefaultWslDistro() - if (!distro) { - return null - } - - const runtimeHomePath = this.getWslRuntimeHomePath(distro) - if (!runtimeHomePath) { - return null - } - this.wslRuntimeHomePathByDistro.set(distro, runtimeHomePath) - - mkdirSync(runtimeHomePath, { recursive: true }) - this.safeMigrateLegacyWslActiveHomePointer(distro, runtimeHomePath) - this.seedWslRuntimeHome(runtimeHomePath, activeAccount, distro) - - const runtimeAuthPath = join(runtimeHomePath, 'auth.json') - const previousWslAccountId = this.lastSyncedWslAccountIdByDistro.get(distro) ?? null - if (previousWslAccountId) { - if (this.skipNextReadBackForAccountId === previousWslAccountId) { - this.skipNextReadBackForAccountId = null - } else { - const previousWslAccount = this.getActiveAccount( - settings.codexManagedAccounts, - previousWslAccountId - ) - if (previousWslAccount) { - this.readBackRefreshedTokensFromPath(runtimeAuthPath, { - updateLastWrittenAuthJson: true, - lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null, - setLastWrittenAuthJson: (contents) => { - this.lastWrittenWslAuthJsonByDistro.set(distro, contents) - }, - expectedAccountId: previousWslAccount.id - }) - } - } - } - - const activeAuthPath = activeAccount ? join(activeAccount.managedHomePath, 'auth.json') : null - if (activeAccount && activeAuthPath && existsSync(activeAuthPath)) { - const activeAuth = readFileSync(activeAuthPath, 'utf-8') - this.writeRuntimeAuthAtPath(runtimeAuthPath, activeAuth) - this.lastWrittenWslAuthJsonByDistro.set(distro, activeAuth) - this.lastSyncedWslAccountIdByDistro.set(distro, activeAccount.id) - return runtimeHomePath - } - if (activeAccount && activeAuthPath) { - console.warn( - '[codex-runtime-home] Active WSL managed account is missing auth.json, restoring system default' - ) - this.store.updateSettings({ - activeCodexManagedAccountId: settings.activeCodexManagedAccountId, - activeCodexManagedAccountIdsByRuntime: setSelectedCodexAccountIdForTarget( - normalizeCodexRuntimeSelection(settings), - null, - wslTarget - ) - }) - } - - const systemAuthPath = this.getWslSystemCodexAuthPath({ runtime: 'wsl', wslDistro: distro }) - if (systemAuthPath && existsSync(systemAuthPath)) { - const systemAuth = readFileSync(systemAuthPath, 'utf-8') - const mirroredSystemDefaultAuth = this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null - const runtimeAuth = existsSync(runtimeAuthPath) - ? readFileSync(runtimeAuthPath, 'utf-8') - : null - if ( - runtimeAuth !== null && - runtimeAuth !== systemAuth && - this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, systemAuth) && - ((mirroredSystemDefaultAuth !== null && systemAuth === mirroredSystemDefaultAuth) || - (mirroredSystemDefaultAuth === null && - this.runtimeAuthIsFresher(runtimeAuth, systemAuth))) - ) { - // Why: WSL baselines are lost on restart, so a same-identity fresher runtime auth is a token refresh; copy it back before mirroring ~/.codex. - this.writeRuntimeAuthAtPath(systemAuthPath, runtimeAuth) - this.lastWrittenWslAuthJsonByDistro.set(distro, runtimeAuth) - this.lastSyncedWslAccountIdByDistro.set(distro, null) - return runtimeHomePath - } - this.writeRuntimeAuthAtPath(runtimeAuthPath, systemAuth) - this.lastWrittenWslAuthJsonByDistro.set(distro, systemAuth) - this.lastSyncedWslAccountIdByDistro.set(distro, null) - return runtimeHomePath - } - - rmSync(runtimeAuthPath, { force: true }) - this.lastWrittenWslAuthJsonByDistro.set(distro, null) - this.lastSyncedWslAccountIdByDistro.set(distro, null) - return runtimeHomePath - } - - private getWslRuntimeHomePath(distro: string): string | null { - const home = getWslHome(distro) - return home ? this.joinWslPath(home, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS) : null - } - - private safeReadBackActiveWslAccountBeforeRestart( - account: CodexManagedAccount, - selectedDistroKey: string - ): void { - try { - this.readBackActiveWslAccountBeforeRestart(account, selectedDistroKey) - } catch (error) { - console.warn('[codex-runtime-home] Failed to preserve WSL Codex auth before restart:', error) - } - } - - private readBackActiveWslAccountBeforeRestart( - account: CodexManagedAccount, - selectedDistroKey: string - ): void { - const distro = - selectedDistroKey === getWslSelectionKey(null) - ? account.wslDistro?.trim() - : selectedDistroKey.trim() || account.wslDistro?.trim() - if (!distro) { - return - } - - const runtimeHomePath = this.wslRuntimeHomePathByDistro.get(distro) - if (!runtimeHomePath) { - return - } - - this.readBackRefreshedTokensFromPath(join(runtimeHomePath, 'auth.json'), { - updateLastWrittenAuthJson: true, - lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null, - setLastWrittenAuthJson: (contents) => { - this.lastWrittenWslAuthJsonByDistro.set(distro, contents) - }, - expectedAccountId: account.id - }) - } - - private safeMigrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void { - try { - this.migrateLegacyWslActiveHomePointer(distro, runtimeHomePath) - } catch (error) { - console.warn('[codex-runtime-home] Failed to migrate legacy WSL active Codex home:', error) - } - } - - private migrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void { - const runtimeWsl = parseWslUncPath(runtimeHomePath) - if (!runtimeWsl?.linuxPath.endsWith('/codex-runtime-home/home')) { - return - } - const activeLinuxPath = runtimeWsl.linuxPath.replace( - /\/codex-runtime-home\/home$/, - '/codex-runtime-home/active/wsl/home' - ) - const nextLinuxPath = `${activeLinuxPath}.next-${process.pid}-${Date.now()}` - const activeLinuxParentPath = this.dirnameLinuxPath(activeLinuxPath) - // Why: login-shell cleanup turns `exit 0` into status 1, so fall through. - execFileSync( - 'wsl.exe', - [ - '-d', - distro, - '--exec', - 'bash', - '-lc', - [ - 'set -e', - `if [ ! -e ${quotePosixShell(activeLinuxPath)} ] && [ ! -L ${quotePosixShell(activeLinuxPath)} ]; then :`, - `elif [ -e ${quotePosixShell(activeLinuxPath)} ] && [ ! -L ${quotePosixShell(activeLinuxPath)} ]; then :`, - 'else', - `mkdir -p ${quotePosixShell(activeLinuxParentPath)}`, - `rm -rf -- ${quotePosixShell(nextLinuxPath)}`, - `ln -s -- ${quotePosixShell(runtimeWsl.linuxPath)} ${quotePosixShell(nextLinuxPath)}`, - `mv -Tf -- ${quotePosixShell(nextLinuxPath)} ${quotePosixShell(activeLinuxPath)}`, - 'fi' - ].join('\n') - ], - // wsl.exe is console-subsystem: without this a GUI-launched Orca flashes - // a conhost and steals foreground for up to the timeout (#10488). - { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000, windowsHide: true } - ) - } - - private dirnameLinuxPath(value: string): string { - const index = value.lastIndexOf('/') - return index > 0 ? value.slice(0, index) : '/' - } - private joinWslPath(basePath: string, ...segments: string[]): string { return parseWslUncPath(basePath) ? pathWin32.join(basePath, ...segments) @@ -1392,37 +1129,6 @@ export class CodexRuntimeHomeService { return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : target } - private getWslSystemCodexAuthPath(target: CodexAccountSelectionTarget): string | null { - const home = this.getWslSystemCodexHomePath(target) - return home ? this.joinWslPath(home, 'auth.json') : null - } - - private seedWslRuntimeHome( - runtimeHomePath: string, - activeAccount: CodexManagedAccount | null, - distro: string - ): void { - const runtimeConfigPath = join(runtimeHomePath, 'config.toml') - if (existsSync(runtimeConfigPath)) { - return - } - - const candidateHomes = [ - activeAccount?.managedHomePath, - this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) - ].filter((value): value is string => Boolean(value)) - for (const homePath of candidateHomes) { - const configPath = join(homePath, 'config.toml') - if (existsSync(configPath)) { - writeFileAtomically( - runtimeConfigPath, - prepareWslRuntimeSeedConfig(readFileSync(configPath, 'utf-8'), homePath) - ) - return - } - } - } - private findManagedAccountForRuntimeAuth( runtimeAuthContents: string, expectedAccountId?: string, @@ -1499,10 +1205,6 @@ export class CodexRuntimeHomeService { return codexAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemDefaultAuthContents) } - private runtimeAuthIsFresher(runtimeAuthContents: string, managedAuthContents: string): boolean { - return codexAuthIsFresher(runtimeAuthContents, managedAuthContents) - } - private safeMigrateLegacySharedAuth(): void { const settings = this.store.getSettings() try { @@ -2209,15 +1911,6 @@ export class CodexRuntimeHomeService { return true } - private writeRuntimeAuthAtPath(authPath: string, contents: string): void { - if (this.fileContentsEqual(authPath, contents)) { - this.ensureOwnerOnlyMode(authPath) - return - } - mkdirSync(dirname(authPath), { recursive: true }) - writeFileAtomically(authPath, contents, { mode: 0o600 }) - } - /** * `true`/`false` only when the bytes were actually read; `null` when the file * could not be read at all. The old `catch { return false }` reported "these @@ -2524,14 +2217,3 @@ export class CodexRuntimeHomeService { rmSync(this.getSystemDefaultSnapshotPath(), { force: true }) } } - -// Why: Codex reads this config inside WSL, so relative path settings must anchor to the Linux-side home (verbatim copy breaks load, os error 2). -export function prepareWslRuntimeSeedConfig( - configContents: string, - sourceHomePath: string -): string { - return prepareSystemConfigForFreshRuntimeMirror( - configContents, - parseWslUncPath(sourceHomePath)?.linuxPath ?? sourceHomePath - ) -} diff --git a/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts b/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts index 95228744bab..81593c46013 100644 --- a/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts @@ -229,25 +229,6 @@ describe('CodexRuntimeHomeService', () => { } }) - it('anchors WSL seed rewrites to the Linux-side home parsed from the UNC source', async () => { - const { prepareWslRuntimeSeedConfig } = await import('./runtime-home-service') - - // Why: real UNC sources cannot back live fs operations in tests, so pin - // the UNC -> Linux-side anchor translation on the extracted seed function. - expect( - prepareWslRuntimeSeedConfig( - 'model_instructions_file = "instructions.md"\n', - '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex' - ) - ).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'") - expect( - prepareWslRuntimeSeedConfig( - 'model_instructions_file = "instructions.md"\n', - '\\\\wsl$\\Ubuntu\\home\\alice\\.codex' - ) - ).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'") - }) - it('switches WSL accounts by selecting each account home directly', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) @@ -328,7 +309,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('does not use host auth baseline to accept stale WSL runtime auth', async () => { + it('ignores stale retired runtime auth when launching a managed WSL account', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -336,7 +317,6 @@ describe('CodexRuntimeHomeService', () => { getDefaultWslDistro: () => 'Ubuntu', getWslHome: () => wslHome })) - const hostAuth = createCodexAuthJson('host@example.com', 'acct-host', 'host-token') const wslManagedAuth = createCodexAuthJson( 'wsl@example.com', 'acct-wsl', @@ -349,7 +329,6 @@ describe('CodexRuntimeHomeService', () => { 'runtime-stale', 1_000 ) - const hostManagedHomePath = createManagedAuth(testState.userDataDir, 'host-account', hostAuth) const wslManagedHomePath = createManagedAuth( testState.userDataDir, 'wsl-account', @@ -368,17 +347,6 @@ describe('CodexRuntimeHomeService', () => { const store = createStore( createSettings({ codexManagedAccounts: [ - { - id: 'host-account', - email: 'host@example.com', - managedHomePath: hostManagedHomePath, - providerAccountId: 'acct-host', - workspaceLabel: null, - workspaceAccountId: 'acct-host', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, { id: 'wsl-account', email: 'wsl@example.com', @@ -389,14 +357,13 @@ describe('CodexRuntimeHomeService', () => { providerAccountId: 'acct-wsl', workspaceLabel: null, workspaceAccountId: 'acct-wsl', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 } ], - activeCodexManagedAccountId: 'host-account', activeCodexManagedAccountIdsByRuntime: { - host: 'host-account', + host: null, wsl: { Ubuntu: 'wsl-account' } } }) @@ -417,143 +384,4 @@ describe('CodexRuntimeHomeService', () => { } } }) - - it('does not clobber fresh WSL tokens after clearLastWrittenAuthJson', async () => { - const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const wslHome = join(testState.userDataDir, 'wsl-home') - vi.doMock('../wsl', () => ({ - getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => wslHome - })) - const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - const originalAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'original', 1_000) - const staleRuntimeAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'stale', 1_500) - const reauthedAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'reauthed', 2_000) - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const wslRuntimeHomePath = join( - wslHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home' - ) - const runtimeAuthPath = join(wslRuntimeHomePath, 'auth.json') - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'wsl@example.com', - managedHomePath, - managedHomeRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home', - providerAccountId: 'acct-wsl', - workspaceLabel: null, - workspaceAccountId: 'acct-wsl', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'account-1' } - } - }) - ) - - try { - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - expect(service.prepareForCodexLaunch(target)).toBe(managedHomePath) - mkdirSync(wslRuntimeHomePath, { recursive: true }) - writeFileSync(runtimeAuthPath, staleRuntimeAuth, 'utf-8') - writeFileSync(managedAuthPath, reauthedAuth, 'utf-8') - - service.clearLastWrittenAuthJson('account-1') - service.syncForCurrentSelection(target) - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(reauthedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(staleRuntimeAuth) - } finally { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - } - }) - - it('keeps direct-home WSL token refreshes canonical across restart preservation', async () => { - const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const wslHome = join(testState.userDataDir, 'wsl-home') - vi.doMock('../wsl', () => ({ - getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => wslHome - })) - const managedAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'managed', 1_000) - const refreshedAuth = createCodexAuthJson( - 'wsl@example.com', - 'acct-wsl', - 'runtime-refreshed', - 2_000 - ) - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'wsl@example.com', - managedHomePath, - managedHomeRuntime: 'wsl', - wslDistro: null, - wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home', - providerAccountId: 'acct-wsl', - workspaceLabel: null, - workspaceAccountId: 'acct-wsl', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'account-1' } - } - }) - ) - - try { - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - const wslRuntimeHomePath = join( - wslHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home' - ) - const runtimeAuthPath = join(wslRuntimeHomePath, 'auth.json') - - expect(service.prepareForCodexLaunch(target)).toBe(managedHomePath) - writeFileSync(managedAuthPath, refreshedAuth, 'utf-8') - - service.syncActiveWslSelectionsBeforeRestart() - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth) - expect(existsSync(runtimeAuthPath)).toBe(false) - } finally { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - } - }) }) diff --git a/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts b/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts index 33eae33977f..fe91d223a40 100644 --- a/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts @@ -34,61 +34,6 @@ describe('CodexRuntimeHomeService', () => { teardownRuntimeHomeTest() }) - it('builds a valid WSL legacy active-home migration shell command', async () => { - const execFileSyncMock = vi.fn() - vi.doMock('node:child_process', () => ({ execFileSync: execFileSyncMock })) - - try { - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService( - createStore(createSettings()) as never - ) as unknown as { - migrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void - } - - service.migrateLegacyWslActiveHomePointer( - 'Ubuntu', - '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home' - ) - - expect(execFileSyncMock).toHaveBeenCalledTimes(1) - const firstCall = execFileSyncMock.mock.calls[0] - expect(firstCall).toBeDefined() - const [command, args] = firstCall as [string, string[]] - expect(command).toBe('wsl.exe') - expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--exec', 'bash', '-lc']) - expect(args).toHaveLength(6) - - const shellCommand = args[5] - expect(shellCommand).toContain( - "if [ ! -e '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ] && [ ! -L '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ]; then :" - ) - expect(shellCommand).toContain( - "elif [ -e '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ] && [ ! -L '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ]; then :" - ) - expect(shellCommand).toContain( - "mkdir -p '/home/alice/.local/share/orca/codex-runtime-home/active/wsl'" - ) - expect(shellCommand).toContain( - "ln -s -- '/home/alice/.local/share/orca/codex-runtime-home/home' '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home.next-" - ) - expect(shellCommand).toContain( - "mv -Tf -- '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home.next-" - ) - expect(shellCommand).toContain( - "' '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home'" - ) - expect(shellCommand).not.toContain('[! -L') - expect(shellCommand).not.toContain('mv -Tf--') - expect(shellCommand).not.toContain('$1') - expect(shellCommand).not.toContain('$2') - expect(shellCommand).not.toContain('$3') - expect(shellCommand).not.toContain('exit 0') - } finally { - vi.doUnmock('node:child_process') - } - }) - it('skips WSL session bridging when system default already uses its direct home', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) @@ -237,7 +182,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('starts WSL session bridging for the distro used by the materialized runtime home', async () => { + it('starts WSL session bridging for the selected direct account home', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve()) @@ -245,14 +190,6 @@ describe('CodexRuntimeHomeService', () => { startWslCodexSessionBridgeInBackground })) const wslHome = join(testState.userDataDir, 'debian-wsl-home') - const wslRuntimeHomePath = join( - wslHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home' - ) vi.doMock('../wsl', () => ({ getDefaultWslDistro: () => null, getWslHome: (distro: string) => (distro === 'Debian' ? wslHome : null) @@ -262,11 +199,10 @@ describe('CodexRuntimeHomeService', () => { return { ...actual, parseWslUncPath: (candidate: string) => - candidate === wslRuntimeHomePath || candidate.includes('codex-accounts/debian-account/home') ? { distro: 'Debian', - linuxPath: '/home/alice/.local/share/orca/codex-runtime-home/home' + linuxPath: '/home/alice/.local/share/orca/codex-accounts/debian-account/home' } : null } diff --git a/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts b/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts index dbf0241a155..8e8965f6a40 100644 --- a/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts @@ -53,15 +53,6 @@ describe('CodexRuntimeHomeService', () => { try { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - const syncWslRuntime = vi.spyOn( - service as unknown as { - syncWslRuntimeForCurrentSelection: (target: { - runtime: 'wsl' - wslDistro?: string | null - }) => string | null - }, - 'syncWslRuntimeForCurrentSelection' - ) const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } const expectedHome = join(wslHome, '.codex') @@ -73,7 +64,6 @@ describe('CodexRuntimeHomeService', () => { kind: 'ready', codexHomePath: expectedHome }) - expect(syncWslRuntime).not.toHaveBeenCalled() } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) diff --git a/src/main/pty/codex-home-wsl-env.ts b/src/main/pty/codex-home-wsl-env.ts index f61272de8c3..6667e3fd608 100644 --- a/src/main/pty/codex-home-wsl-env.ts +++ b/src/main/pty/codex-home-wsl-env.ts @@ -1,6 +1,4 @@ -/** Guest-relative layout of Orca's managed WSL CODEX_HOME. Must stay in sync - * with getWslRuntimeHomePath (codex-accounts/runtime-home-service.ts), which - * builds the UNC twin of this path. */ +/** Guest-relative layout of Orca's retired WSL CODEX_HOME, retained for migration reads. */ export const WSL_CODEX_RUNTIME_HOME_SEGMENTS = [ '.local', 'share', diff --git a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt index befefb5a2c8..e226814ba84 100644 --- a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt +++ b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt @@ -17,7 +17,6 @@ main/agent-hooks/wsl-hook-relay-launch.ts main/browser/wsl-browser-network-relay-launch.ts main/claude-accounts/claude-command-process.ts -main/codex-accounts/runtime-home-service.ts main/codex-accounts/service.ts main/codex/codex-state-db-backfill-recovery.ts main/codex/codex-trust-grant-host.ts diff --git a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt index 03e06eb374f..dbe9c0c2aa4 100644 --- a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt +++ b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt @@ -41,7 +41,6 @@ src/main/browser/browser-cookie-import.ts src/main/browser/browser-route-egress-electron-launch.ts src/main/browser/browser-route-persisted-worker-electron-process.ts src/main/claude-accounts/keychain.ts -src/main/codex-accounts/runtime-home-service.ts src/main/codex-accounts/service.ts src/main/codex/codex-app-server-client.ts src/main/codex/codex-app-server-session.ts From 0f522c35e58acad011c925e33c76849c3d7eeb41 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:30:48 -0700 Subject: [PATCH 041/152] fix(remote): gate empty session inventory on host authority (#16546) --- config/reliability-gates.jsonc | 17 +- src/cli/runtime/websocket-transport.test.ts | 2 + .../terminal-host-agent-session.test.ts | 43 ++ src/main/runtime/orca-runtime.ts | 190 +++++++- .../session-tabs-inventory-rpc.test.ts | 402 +++++++++++++++++ .../rpc/methods/session-tabs-inventory.ts | 110 +++++ .../runtime/rpc/methods/session-tabs.test.ts | 2 + src/main/runtime/rpc/methods/session-tabs.ts | 82 +--- ...session-tabs-inventory-publication.test.ts | 416 ++++++++++++++++++ .../direct-ssh-hydration-fanout.test.ts | 3 +- .../src/runtime/host-live-terminal-probe.ts | 14 +- ...sion-mirror-empty-inventory-settle.test.ts | 284 +++++++++++- ...t-session-mirror-frame-ordering-harness.ts | 6 + ...n-mirror-hydration-frame-ordering.test.tsx | 18 + .../host-session-mirror-settle-census.test.ts | 6 +- ...sion-mirror-settle-receipt-frames.test.tsx | 31 ++ ...runtime-browser-capability-cleanup.test.ts | 1 + ...e-session-browser-client-placement.test.ts | 2 + ...ime-session-browser-create-failure.test.ts | 2 + ...ntime-session-browser-create-focus.test.ts | 2 + ...ion-browser-create-split-placement.test.ts | 2 + ...ession-browser-create-staged-focus.test.ts | 2 + ...owser-create-staged-hosting-intent.test.ts | 2 + ...ime-session-browser-create-staging.test.ts | 2 + ...ession-browser-placement-staleness.test.ts | 2 + ...runtime-session-tab-activate-close.test.ts | 2 + .../web-runtime-session-tab-move.test.ts | 2 + .../web-runtime-session-tab-props.test.ts | 2 + ...me-session-terminal-host-authority.test.ts | 2 + ...ime-session-terminal-legacy-create.test.ts | 2 + ...e-session-terminal-pane-delegation.test.ts | 2 + .../src/runtime/web-runtime-session.test.ts | 2 + .../src/runtime/web-runtime-session.ts | 21 +- ...ssion-tabs-sync-window-visibility.test.tsx | 8 +- .../src/runtime/web-session-tabs-sync.ts | 245 +++++++++-- .../src/store/slices/runtime-status.ts | 7 + src/shared/protocol-version.ts | 3 + .../remote-runtime-client-capabilities.ts | 2 + ...minal-host-restart-background-sync.spec.ts | 9 + ...empty-inventory-daemon-oracle.unit.test.ts | 236 ++++++++++ 40 files changed, 2045 insertions(+), 143 deletions(-) create mode 100644 src/main/runtime/rpc/methods/session-tabs-inventory-rpc.test.ts create mode 100644 src/main/runtime/rpc/methods/session-tabs-inventory.ts create mode 100644 src/main/runtime/session-tabs-inventory-publication.test.ts create mode 100644 tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 8a6dc2b0d96..ac37e3760b9 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -8124,6 +8124,7 @@ "hidden remote worktree retention", "remote terminal reveal and input", "paired host relaunch with preserved daemon PTYs", + "paired host session inventory publication authority", "manual server disconnect" ], "platforms": ["macos", "linux", "windows"], @@ -8135,13 +8136,14 @@ "https://github.com/stablyai/orca/issues/8652", "https://github.com/stablyai/orca/pull/10625" ], - "invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Parked watcher synchronization compares parking semantics rather than fresh object identity, owns setup and cleanup across StrictMode replay, reconciles real PTY/layout changes, and performs no terminal-state scan when nothing is parked. After a host main-process relaunch, unknown local delivery-sync state must not be treated as proof that a surviving daemon PTY is already foregrounded. If subscription precedes provider readiness, authoritative inventory must reconsider that existing subscriber without spawning, resizing, or changing reconnect state. A snapshot without an output high-water must keep it unknown rather than borrowing a layout version that can suppress newer live output. Hosts without the capability must gate hidden raw output before xterm scheduling and repaint from the authoritative snapshot on reveal while retaining the existing limit/TTL force-parking fallback. After the first semantic title state, decorative spinner frequency must add zero paired client-event frames and zero full session-tabs work between status-freshness leases regardless of hidden worktree count. Continuous working or permission evidence may refresh each affected worktree once per 15 minutes through one globally spaced FIFO so current and legacy viewers do not decay before 30 minutes; sibling PTYs share that worktree refresh, while local title animation, raw output, and semantic title, status, bell, completion, and query facts remain intact. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.", + "invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Parked watcher synchronization compares parking semantics rather than fresh object identity, owns setup and cleanup across StrictMode replay, reconciles real PTY/layout changes, and performs no terminal-state scan when nothing is parked. After a host main-process relaunch, unknown local delivery-sync state must not be treated as proof that a surviving daemon PTY is already foregrounded. An empty headed session-tab inventory is authoritative only after the current renderer graph generation publishes a complete inventory and every execution host answers the PTY census; unpublished, resync-pending, transport-failed, and partially scoped inventories remain unverifiable, while genuinely authoritative empty and non-empty inventories settle and a scope-less legacy empty keeps its pre-authority best-effort settle so outdated hosts retain agent auto-resume. The unpublished-empty regression must keep the resume dispatch parked with exactly one daemon process/writer; restoring unconditional empty hydration must release one dispatch and create two live writers. If subscription precedes provider readiness, authoritative inventory must reconsider that existing subscriber without spawning, resizing, or changing reconnect state. A snapshot without an output high-water must keep it unknown rather than borrowing a layout version that can suppress newer live output. Hosts without the capability must gate hidden raw output before xterm scheduling and repaint from the authoritative snapshot on reveal while retaining the existing limit/TTL force-parking fallback. After the first semantic title state, decorative spinner frequency must add zero paired client-event frames and zero full session-tabs work between status-freshness leases regardless of hidden worktree count. Continuous working or permission evidence may refresh each affected worktree once per 15 minutes through one globally spaced FIFO so current and legacy viewers do not decay before 30 minutes; sibling PTYs share that worktree refresh, while local title animation, raw output, and semantic title, status, bell, completion, and query facts remain intact. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.", "oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, hide all six warm managers, emit an initial working title followed by 30 timed real-PTY spinner frames and a final idle title per worktree, and observe a second real session-tabs subscription plus the production renderer store. Require exactly two transport envelopes and two title mutations per worktree, no decorative title crossing either boundary, and positive decoded-envelope bytes. Enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Drive 24 real PTY title trackers in distinct worktrees through 40 decorative spinner frames each, then 40 spinner frames alternating with Cursor's bare native identity redraw, using fake clocks. Require all 2,880 raw chunks exactly and zero host session-tabs publications, serialized bytes, renderer apply calls, and renderer store mutations. Continue decorative evidence beyond 30 minutes, require exactly one refresh per worktree per 15-minute lease through a FIFO spaced by at least 50 ms, and require all 24 viewer statuses to remain working and fresh; then require one exact semantic idle transition and one visible-output chunk per PTY. Require ordinary and interior-Braille title changes to publish. Drive 64 host PTYs through ten 80 ms synthetic title frames, require zero paired client events after the first frame per PTY while all 640 local frames remain observable, attach a late client and require one current frame per PTY, then require semantic bell and idle transitions on both clients. Reconstruct a legacy subscribed stream with no outputPause capability, hide a chatty pane, require no hidden xterm writes, reveal it, and require one authoritative snapshot plus continued live output. Drop and acknowledge output only for one original paired-client stream, prove the fixture process consumed input while the host model advanced and the client stayed stale, then require the command snapshot probe to replace that stream, repaint exact fixture output, preserve authoritative PTY identity and target-tab cardinality, and resume live I/O. For a client without a delivered sequence, require the first numeric probe to establish a baseline without replacement, a same-sequence probe to remain attached, and a later advanced probe to replace only that stream. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts --maxWorkers=1", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts --maxWorkers=1", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex-initial-snapshot-buffering.test.ts src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-parked-ssh-snapshot.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-activation-inventory-fallback.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api-runtime-environment.test.ts src/main/ipc/runtime-environments-pairing.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/session-tabs-inventory-publication.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts src/main/daemon/terminal-host-agent-session.test.ts tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts --maxWorkers=1", "pnpm exec vitest run --config config/vitest.config.ts src/shared/remote-runtime-shared-control-connection.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts", @@ -8174,6 +8176,11 @@ "src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts", "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts", "src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", + "src/main/runtime/session-tabs-inventory-publication.test.ts", + "src/main/runtime/rpc/methods/session-tabs.test.ts", + "src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts", + "src/main/daemon/terminal-host-agent-session.test.ts", + "tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts", "src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts", "src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts", "src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts", @@ -8257,6 +8264,14 @@ "file": "src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", "assertions": ["retries an existing subscriber when provider inventory becomes ready"] }, + { + "file": "tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts", + "assertions": [ + "unpublished empty inventory keeps resume dispatch at zero with the original daemon session, process id, and writer as the sole live writer", + "an authoritative empty inventory settles the mirror and releases exactly one resume dispatch with zero liveness probes", + "re-enabling unconditional empty hydration makes the identical oracle red with one resume dispatch, two session ids, two process ids, and two writer calls" + ] + }, { "file": "src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts", "assertions": [ diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 985953fd4a9..20328a9b614 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -22,6 +22,7 @@ import { AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, RUNTIME_PROTOCOL_VERSION, + SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SKILL_INSTALL_RESULT_V2_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, @@ -69,6 +70,7 @@ describe('CLI remote WebSocket transport', () => { expect.objectContaining({ clientCapabilities: [ SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, SKILL_INSTALL_RESULT_V2_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, diff --git a/src/main/daemon/terminal-host-agent-session.test.ts b/src/main/daemon/terminal-host-agent-session.test.ts index b087e579ae4..1cf929fb93f 100644 --- a/src/main/daemon/terminal-host-agent-session.test.ts +++ b/src/main/daemon/terminal-host-agent-session.test.ts @@ -90,6 +90,49 @@ describe('TerminalHost agent-session claims', () => { expect(spawnSubprocess).toHaveBeenCalledOnce() }) + it('cannot adopt a live session that predates provider-session claims', async () => { + const subprocesses: ReturnType[] = [] + host = new TerminalHost({ + spawnSubprocess: () => { + const handle = createClaimedSubprocess() + subprocesses.push(handle) + return handle + } + }) + await host.createOrAttach({ + sessionId: 'renderer-owned-live-session', + cols: 80, + rows: 24, + launchAgent: 'codex', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + const resumed = await host.createOrAttach({ + sessionId: 'replacement-resume-session', + cols: 80, + rows: 24, + launchAgent: 'codex', + streamClient: { onData: vi.fn(), onExit: vi.fn() }, + agentSessionEnsure: { claim, surface } + }) + + expect(resumed.agentSessionEnsure?.disposition).toBe('created') + expect( + host + .listSessions() + .map(({ sessionId }) => sessionId) + .sort() + ).toEqual(['renderer-owned-live-session', 'replacement-resume-session']) + expect(subprocesses).toHaveLength(2) + host.write('renderer-owned-live-session', 'original-writer') + host.write('replacement-resume-session', 'replacement-writer') + expect(subprocesses[0]?.write).toHaveBeenCalledWith('original-writer') + expect(subprocesses[1]?.write).toHaveBeenCalledWith('replacement-writer') + for (const handle of subprocesses) { + handle.exit() + } + }) + it('rejects a competing claim without replacing the winning stream', async () => { let releaseSpawn: () => void = () => {} const spawnGate = new Promise((resolve) => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index b8196482870..c33d741bc29 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -655,6 +655,7 @@ import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY, RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION, + SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../shared/protocol-version' @@ -3254,7 +3255,8 @@ export class OrcaRuntimeService { // reconnect-scan bounding for sequential soft freezes. private readonly terminalFocusNavigationCoalescer = new TerminalFocusNavigationCoalescer() - private pendingMobileSessionPtyInventoryRefresh: Promise | null> | null = null + private pendingMobileSessionPtyAggregateInventoryRefresh: Promise | null = + null private leaves = new Map() // Why: PTY output is a per-keystroke hot path. Looking up affected leaves by // ptyId keeps active TUI redraws independent of the total open terminal count. @@ -3269,6 +3271,8 @@ export class OrcaRuntimeService { private syntheticTerminalHandles = new Set() private detachedPreAllocatedLeaves = new Map() private graphSyncCallbacks: (() => void)[] = [] + private sessionTabsInventoryPublicationEpoch: number | null = null + private sessionTabsInventoryWaiters = new Set<() => void>() private waitersByHandle = new Map>() private ptyExitListenersByPtyId = new Map void>>() private ptyController: RuntimePtyController | null = null @@ -6449,7 +6453,9 @@ export class OrcaRuntimeService { (process.env.ORCA_E2E_DISABLE_RUNTIME_SHARED_CONTROL !== '1' || capability !== REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) && (process.env.ORCA_E2E_DISABLE_PAIRED_TERMINAL_PARKING !== '1' || - capability !== TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY) + capability !== TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY) && + (process.env.ORCA_E2E_DISABLE_AUTHORITATIVE_SESSION_TABS_INVENTORY !== '1' || + capability !== SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY) ) if (hasOffscreen || hasHeadlessCommands) { capabilities.push(BROWSER_HEADLESS_RUNTIME_CAPABILITY) @@ -7327,7 +7333,19 @@ export class OrcaRuntimeService { this.mobileSessionTabsNotifyCoalescer.schedule(worktreeId) } } + // Why: only the authoritative window grants inventory authority; headless qualifies because it becomes authoritative before its next sync. + const isAuthoritativeGraphPublisher = windowId === this.authoritativeWindowId this.markGraphReady(windowId) + if ( + isAuthoritativeGraphPublisher && + (windowId === HEADLESS_RUNTIME_WINDOW_ID || graph.mobileSessionTabs !== undefined) + ) { + if (mobileSessionResyncWorktrees.size === 0) { + this.markSessionTabsInventoryPublished() + } else { + this.sessionTabsInventoryPublicationEpoch = null + } + } if (rendererGeneration !== undefined) { this.rendererGeneration = rendererGeneration } @@ -7439,6 +7457,13 @@ export class OrcaRuntimeService { async listAllMobileSessionTabs( clientNavigationId?: string ): Promise { + return (await this.collectAllMobileSessionTabs(clientNavigationId)).snapshots + } + + private async collectAllMobileSessionTabs(clientNavigationId?: string): Promise<{ + snapshots: RuntimeMobileSessionTabsResult[] + ptyInventory: PtyControllerInventory | null + }> { for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { allowAttachedWindow: true, @@ -7446,13 +7471,132 @@ export class OrcaRuntimeService { }) } this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() - await this.refreshMobileSessionPtyRecords() - return [...this.mobileSessionTabsByWorktree.values()].map((snapshot) => - this.projectMobileSessionTabsForClient( - this.toMobileSessionTabsResult(snapshot), - clientNavigationId - ) - ) + const ptyInventory = await this.refreshMobileSessionPtyInventory() + this.restoreLivePairedRendererSessionOwnedMobileTerminals(null) + return { + snapshots: [...this.mobileSessionTabsByWorktree.values()].map((snapshot) => + this.projectMobileSessionTabsForClient( + this.toMobileSessionTabsResult(snapshot), + clientNavigationId + ) + ), + ptyInventory + } + } + + async listAllMobileSessionTabsInventory( + clientNavigationId?: string, + signal?: AbortSignal + ): Promise<{ snapshots: RuntimeMobileSessionTabsResult[]; authoritative?: true }> { + this.assertSessionTabsInventoryRequestActive(signal) + const primedPublicationEpoch = this.getAuthoritativeSessionTabsInventoryEpoch() + const primed = await this.collectAllMobileSessionTabs(clientNavigationId) + this.assertSessionTabsInventoryRequestActive(signal) + if ( + primedPublicationEpoch !== null && + this.getAuthoritativeSessionTabsInventoryEpoch() === primedPublicationEpoch + ) { + return await this.settleSessionTabsInventory(primed, clientNavigationId, signal) + } + while (true) { + const publicationEpoch = this.getAuthoritativeSessionTabsInventoryEpoch() + if (publicationEpoch === null) { + await this.waitForSessionTabsInventoryPublication(signal) + continue + } + const inventory = await this.collectAllMobileSessionTabs(clientNavigationId) + this.assertSessionTabsInventoryRequestActive(signal) + if (this.getAuthoritativeSessionTabsInventoryEpoch() === publicationEpoch) { + return await this.settleSessionTabsInventory(inventory, clientNavigationId, signal) + } + } + } + + // Why: a failed census only invalidates the emptiness verdict, never the + // list. An incomplete census usually means a concurrent scan invalidated + // this one mid-relaunch and daemon-backed tabs could not be restored yet, so + // retry the collection once; a still-incomplete retry serves its snapshots + // unlabeled rather than erroring the request. + private async settleSessionTabsInventory( + inventory: { + snapshots: RuntimeMobileSessionTabsResult[] + ptyInventory: PtyControllerInventory | null + }, + clientNavigationId?: string, + signal?: AbortSignal + ): Promise<{ snapshots: RuntimeMobileSessionTabsResult[]; authoritative?: true }> { + if (this.isCompleteSessionTabsPtyCensus(inventory.ptyInventory)) { + return { snapshots: inventory.snapshots, authoritative: true } + } + const retried = await this.collectAllMobileSessionTabs(clientNavigationId) + this.assertSessionTabsInventoryRequestActive(signal) + // Why: the retry ran outside the epoch fence, so it never claims authority. + return { snapshots: retried.snapshots } + } + + supportsAuthoritativeSessionTabsInventory(): boolean { + return process.env.ORCA_E2E_DISABLE_AUTHORITATIVE_SESSION_TABS_INVENTORY !== '1' + } + + private assertSessionTabsInventoryRequestActive(signal?: AbortSignal): void { + if (signal?.aborted) { + throw new Error('client_disconnected') + } + } + + private isCompleteSessionTabsPtyCensus(inventory: PtyControllerInventory | null): boolean { + if (!inventory) { + return false + } + const knownHostIds = this.listKnownExecutionHostIds(inventory.queriedHostIds) + return ![...knownHostIds].some((hostId) => { + const parsed = parseExecutionHostId(hostId) + return parsed?.kind !== 'runtime' && !inventory.queriedHostIds.has(hostId) + }) + } + + private waitForSessionTabsInventoryPublication(signal?: AbortSignal): Promise { + if (this.getAuthoritativeSessionTabsInventoryEpoch() !== null) { + return Promise.resolve() + } + return new Promise((resolve, reject) => { + const cleanup = (): void => { + this.sessionTabsInventoryWaiters.delete(onPublished) + signal?.removeEventListener('abort', onAbort) + } + const onPublished = (): void => { + cleanup() + resolve() + } + const onAbort = (): void => { + cleanup() + reject(new Error('client_disconnected')) + } + this.sessionTabsInventoryWaiters.add(onPublished) + signal?.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted) { + onAbort() + } else if (this.getAuthoritativeSessionTabsInventoryEpoch() !== null) { + onPublished() + } + }) + } + + private getAuthoritativeSessionTabsInventoryEpoch(): number | null { + return this.graphStatus === 'ready' && + this.sessionTabsInventoryPublicationEpoch === this.rendererGraphEpoch + ? this.rendererGraphEpoch + : null + } + + private markSessionTabsInventoryPublished(): void { + if (this.sessionTabsInventoryPublicationEpoch === this.rendererGraphEpoch) { + return + } + this.sessionTabsInventoryPublicationEpoch = this.rendererGraphEpoch + for (const publish of [...this.sessionTabsInventoryWaiters]) { + publish() + } } private hydrateHeadlessMobileSessionTabsFromWorkspaceSession( @@ -9185,19 +9329,29 @@ export class OrcaRuntimeService { private async refreshMobileSessionPtyRecords( targetWorktreeId: string | null = null ): Promise | null> { + const inventory = await this.refreshMobileSessionPtyInventory(targetWorktreeId) + return inventory ? new Set(inventory.livePtyIds) : null + } + + private async refreshMobileSessionPtyInventory( + targetWorktreeId: string | null = null + ): Promise { if (targetWorktreeId !== FLOATING_TERMINAL_WORKTREE_ID) { - const pending = this.pendingMobileSessionPtyInventoryRefresh + // Non-floating refreshes all query the aggregate controller inventory; + // coalesce targeted and all-worktree callers so they cannot invalidate + // one another through the shared aggregate generation fence. + const pending = this.pendingMobileSessionPtyAggregateInventoryRefresh if (pending) { return pending } // Why: reconnect exit bursts share one authoritative daemon inventory // instead of multiplying a full cross-generation list RPC per stale tab. const refresh = this.performMobileSessionPtyRecordsRefresh(targetWorktreeId).finally(() => { - if (this.pendingMobileSessionPtyInventoryRefresh === refresh) { - this.pendingMobileSessionPtyInventoryRefresh = null + if (this.pendingMobileSessionPtyAggregateInventoryRefresh === refresh) { + this.pendingMobileSessionPtyAggregateInventoryRefresh = null } }) - this.pendingMobileSessionPtyInventoryRefresh = refresh + this.pendingMobileSessionPtyAggregateInventoryRefresh = refresh return refresh } return await this.performMobileSessionPtyRecordsRefresh(targetWorktreeId) @@ -9205,14 +9359,14 @@ export class OrcaRuntimeService { private async performMobileSessionPtyRecordsRefresh( targetWorktreeId: string | null - ): Promise | null> { + ): Promise { if (!this.ptyController?.listProcesses && !this.ptyController?.hasPty) { return null } // Why: floating PTY identity is explicit, so polling must not resolve every Git/SSH worktree. const isFloatingWorkspace = targetWorktreeId === FLOATING_TERMINAL_WORKTREE_ID const resolvedWorktrees = isFloatingWorkspace ? [] : await this.listResolvedWorktrees() - return await this.refreshPtyWorktreeRecordsFromController( + return await this.refreshPtyWorktreeRecordsWithControllerInventory( resolvedWorktrees, isFloatingWorkspace ? targetWorktreeId : null ) @@ -31730,6 +31884,8 @@ export class OrcaRuntimeService { return false } if (fence.recovery === 'renderer') { + const restoresPublishedInventory = + this.sessionTabsInventoryPublicationEpoch === this.rendererGraphEpoch - 1 this.graphStatus = 'ready' this.setTerminalSideEffectConsumerAvailable(true) for (const leaf of this.leaves.values()) { @@ -31737,6 +31893,9 @@ export class OrcaRuntimeService { } this.reconcilePtyIncarnationHandles() this.refreshWritableFlags() + if (restoresPublishedInventory) { + this.markSessionTabsInventoryPublished() + } return true } this.graphReloadLifecycle.begin(windowId) @@ -31849,6 +32008,7 @@ export class OrcaRuntimeService { this.clearPtyIncarnationHandles() this.rejectAllWaiters('terminal_handle_stale') this.refreshWritableFlags() + this.markSessionTabsInventoryPublished() } private assertGraphReady(): void { diff --git a/src/main/runtime/rpc/methods/session-tabs-inventory-rpc.test.ts b/src/main/runtime/rpc/methods/session-tabs-inventory-rpc.test.ts new file mode 100644 index 00000000000..2834c55b36d --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tabs-inventory-rpc.test.ts @@ -0,0 +1,402 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import { OrcaRuntimeService } from '../../orca-runtime' +import { SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { SESSION_TAB_METHODS } from './session-tabs' +import { listSessionTabsInventory } from './session-tabs-inventory' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('session tabs inventory RPC methods', () => { + it('withholds an old-client list until the host inventory is authoritative', async () => { + let resolveInventory!: (value: { snapshots: []; authoritative: true }) => void + const runtime = { + getRuntimeId: () => 'test-runtime', + supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), + listAllMobileSessionTabsInventory: vi.fn( + () => + new Promise<{ snapshots: []; authoritative: true }>((resolve) => { + resolveInventory = resolve + }) + ) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const messages: string[] = [] + + const pending = dispatcher.dispatchStreaming( + makeRequest('session.tabs.listAll'), + (message) => messages.push(message), + { clientKind: 'runtime' } + ) + for (let index = 0; index < 20 && !resolveInventory; index += 1) { + await Promise.resolve() + } + expect(messages).toEqual([]) + + resolveInventory({ snapshots: [], authoritative: true }) + await pending + + expect(JSON.parse(messages[0]!).result).toEqual({ snapshots: [] }) + }) + + // Why: a census failure only invalidates the emptiness verdict — the runtime + // degrades to the same scan unlabeled, so a capable client keeps the list + // (e.g. one configured-but-disconnected SSH host) without a second collect. + it('serves a capable client an unlabeled list when the census fails', async () => { + const legacyListAll = vi.fn() + const runtime = { + getRuntimeId: () => 'test-runtime', + supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), + listAllMobileSessionTabsInventory: vi.fn(async () => ({ + snapshots: [ + { + worktree: 'wt-local', + publicationEpoch: 'epoch-local', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + })), + listAllMobileSessionTabs: legacyListAll + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const messages: string[] = [] + + await dispatcher.dispatchStreaming( + makeRequest('session.tabs.listAll'), + (message) => messages.push(message), + { + clientKind: 'runtime', + clientCapabilities: [SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY] + } + ) + + expect(legacyListAll).not.toHaveBeenCalled() + const result = JSON.parse(messages[0]!).result + expect(result.snapshots).toEqual([expect.objectContaining({ worktree: 'wt-local' })]) + expect(result.authoritative).toBeUndefined() + }) + + it('propagates disconnect errors to legacy clients', async () => { + const legacyListAll = vi.fn() + const runtime = { + getRuntimeId: () => 'test-runtime', + supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), + listAllMobileSessionTabsInventory: vi.fn(async () => { + throw new Error('client_disconnected') + }), + listAllMobileSessionTabs: legacyListAll + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const messages: string[] = [] + + await dispatcher.dispatchStreaming( + makeRequest('session.tabs.listAll'), + (message) => messages.push(message), + { clientKind: 'runtime' } + ) + + expect(legacyListAll).not.toHaveBeenCalled() + expect(JSON.parse(messages[0]!)).toMatchObject({ + ok: false, + error: expect.objectContaining({ code: 'runtime_error' }) + }) + }) + + it('labels an authoritative inventory only for a client that negotiated it', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), + listAllMobileSessionTabsInventory: vi.fn(async () => ({ + snapshots: [], + authoritative: true as const + })) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const messages: string[] = [] + + await dispatcher.dispatchStreaming( + makeRequest('session.tabs.listAll'), + (message) => messages.push(message), + { + clientKind: 'runtime', + clientCapabilities: [SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY] + } + ) + + expect(JSON.parse(messages[0]!).result).toEqual({ snapshots: [], authoritative: true }) + }) + + it('serves an old client the degraded scan when terminal liveness cannot be proven', async () => { + const legacyListAll = vi.fn() + const runtime = { + getRuntimeId: () => 'test-runtime', + supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), + listAllMobileSessionTabsInventory: vi.fn(async () => ({ + snapshots: [ + { + worktree: 'wt-local', + publicationEpoch: 'epoch-local', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + })), + listAllMobileSessionTabs: legacyListAll + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const messages: string[] = [] + + await dispatcher.dispatchStreaming( + makeRequest('session.tabs.listAll'), + (message) => messages.push(message), + { clientKind: 'runtime' } + ) + + expect(legacyListAll).not.toHaveBeenCalled() + expect(JSON.parse(messages[0]!).result).toEqual({ + snapshots: [expect.objectContaining({ worktree: 'wt-local' })] + }) + }) + + it('forwards the caller signal so a disconnected request never scans', async () => { + const inventory = vi.fn(async (_navigationId?: string, signal?: AbortSignal) => { + if (signal?.aborted) { + throw new Error('client_disconnected') + } + return { snapshots: [] } + }) + const runtime = { + getRuntimeId: () => 'test-runtime', + supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), + listAllMobileSessionTabsInventory: inventory + } as unknown as OrcaRuntimeService + const controller = new AbortController() + controller.abort() + + await expect(listSessionTabsInventory({ runtime, signal: controller.signal })).rejects.toThrow( + 'client_disconnected' + ) + expect(inventory).toHaveBeenCalledWith(undefined, controller.signal) + }) + + it('lets the final authoritative inventory subsume prior-epoch updates', async () => { + let resolveInventory!: (value: { + snapshots: RuntimeMobileSessionTabsResult[] + authoritative: true + }) => void + const listeners: ((snapshot: RuntimeMobileSessionTabsResult) => void)[] = [] + const runtime = { + getRuntimeId: () => 'test-runtime', + supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), + listAllMobileSessionTabsInventory: vi.fn( + () => + new Promise<{ + snapshots: RuntimeMobileSessionTabsResult[] + authoritative: true + }>((resolve) => { + resolveInventory = resolve + }) + ), + onMobileSessionTabsChanged: vi.fn( + (listener: (snapshot: RuntimeMobileSessionTabsResult) => void) => { + listeners.push(listener) + return vi.fn() + } + ), + registerSubscriptionCleanup: vi.fn(), + cleanupSubscription: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const messages: string[] = [] + + const pending = dispatcher.dispatchStreaming( + makeRequest('session.tabs.subscribeAll'), + (message) => messages.push(message), + { + connectionId: 'conn-authoritative', + clientCapabilities: [SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY] + } + ) + await Promise.resolve() + listeners[0]?.({ + worktree: 'wt-authoritative', + publicationEpoch: 'epoch-before-reload', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + }) + expect(messages).toEqual([]) + + resolveInventory({ + snapshots: [ + { + worktree: 'wt-authoritative', + publicationEpoch: 'epoch-after-reload', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ], + authoritative: true + }) + await pending + listeners[0]?.({ + worktree: 'wt-authoritative', + publicationEpoch: 'epoch-after-reload', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + }) + + expect(messages.map((message) => JSON.parse(message).result)).toEqual([ + { + type: 'snapshots', + snapshots: [ + expect.objectContaining({ + worktree: 'wt-authoritative', + publicationEpoch: 'epoch-after-reload', + snapshotVersion: 1 + }) + ], + authoritative: true + }, + expect.objectContaining({ + type: 'updated', + worktree: 'wt-authoritative', + publicationEpoch: 'epoch-after-reload', + snapshotVersion: 2 + }) + ]) + }) + + it('lets authoritative empty inventory win over prior-epoch updates', async () => { + let resolveInventory!: (value: { snapshots: []; authoritative: true }) => void + const listeners: ((snapshot: RuntimeMobileSessionTabsResult) => void)[] = [] + const runtime = { + getRuntimeId: () => 'test-runtime', + supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), + listAllMobileSessionTabsInventory: vi.fn( + () => + new Promise<{ snapshots: []; authoritative: true }>((resolve) => { + resolveInventory = resolve + }) + ), + onMobileSessionTabsChanged: vi.fn( + (listener: (snapshot: RuntimeMobileSessionTabsResult) => void) => { + listeners.push(listener) + return vi.fn() + } + ), + registerSubscriptionCleanup: vi.fn(), + cleanupSubscription: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const messages: string[] = [] + const snapshot = (snapshotVersion: number): RuntimeMobileSessionTabsResult => ({ + worktree: 'wt-buffered', + publicationEpoch: 'epoch-buffered', + snapshotVersion, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + }) + + const pending = dispatcher.dispatchStreaming( + makeRequest('session.tabs.subscribeAll'), + (message) => messages.push(message), + { + connectionId: 'conn-authoritative-empty', + clientCapabilities: [SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY] + } + ) + await Promise.resolve() + listeners[0]?.(snapshot(2)) + listeners[0]?.(snapshot(3)) + + resolveInventory({ snapshots: [], authoritative: true }) + await pending + + expect(messages.map((message) => JSON.parse(message).result)).toEqual([ + { type: 'snapshots', snapshots: [], authoritative: true } + ]) + }) + + it('does no subscription work for an already-cancelled stream', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), + listAllMobileSessionTabsInventory: vi.fn(), + onMobileSessionTabsChanged: vi.fn(), + registerSubscriptionCleanup: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const controller = new AbortController() + const messages: string[] = [] + controller.abort() + + await dispatcher.dispatchStreaming( + makeRequest('session.tabs.subscribeAll'), + (message) => messages.push(message), + { signal: controller.signal } + ) + + expect(runtime.onMobileSessionTabsChanged).not.toHaveBeenCalled() + expect(runtime.listAllMobileSessionTabsInventory).not.toHaveBeenCalled() + expect(messages.map((message) => JSON.parse(message))).toEqual([ + expect.objectContaining({ + ok: false, + error: expect.objectContaining({ code: 'runtime_error' }) + }) + ]) + }) + + it('aborts and removes a publication waiter when the stream is cleaned up', async () => { + const runtime = new OrcaRuntimeService() + runtime.attachWindow(1) + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const messages: string[] = [] + const pending = dispatcher.dispatchStreaming( + makeRequest('session.tabs.subscribeAll'), + (message) => messages.push(message), + { + connectionId: 'conn-cancel', + clientCapabilities: [SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY] + } + ) + const waiters = (runtime as unknown as { sessionTabsInventoryWaiters: Set<() => void> }) + .sessionTabsInventoryWaiters + for (let index = 0; index < 20 && waiters.size === 0; index += 1) { + await Promise.resolve() + } + expect(waiters.size).toBe(1) + + runtime.cleanupSubscription('session.tabs:conn-cancel:*:req-1') + await pending + + expect(waiters.size).toBe(0) + expect(messages.map((message) => JSON.parse(message))).toEqual([ + expect.objectContaining({ + ok: false, + error: expect.objectContaining({ code: 'runtime_error' }) + }) + ]) + }) +}) diff --git a/src/main/runtime/rpc/methods/session-tabs-inventory.ts b/src/main/runtime/rpc/methods/session-tabs-inventory.ts new file mode 100644 index 00000000000..f47c2251f6e --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tabs-inventory.ts @@ -0,0 +1,110 @@ +import { SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import type { RpcContext } from '../core' +import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection' +import { projectSessionTabBrowserPlacements } from './session-tab-browser-placement-projection' + +type SessionTabsInventory = { + snapshots: RuntimeMobileSessionTabsResult[] + authoritative?: true +} + +function clientUnderstandsAuthoritativeInventory(context: RpcContext): boolean { + return ( + context.clientCapabilities?.includes( + SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY + ) === true + ) +} + +export function projectSessionTabsForClient( + snapshot: RuntimeMobileSessionTabsResult, + clientKind: 'mobile' | 'runtime' | undefined, + clientCapabilities: Parameters[2] +): RuntimeMobileSessionTabsResult { + return projectSessionTabBrowserPlacements( + projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities), + clientCapabilities + ) +} + +function projectInventory( + inventory: SessionTabsInventory, + context: RpcContext +): SessionTabsInventory { + return { + snapshots: inventory.snapshots.map((snapshot) => + projectSessionTabsForClient(snapshot, context.clientKind, context.clientCapabilities) + ), + ...(inventory.authoritative && clientUnderstandsAuthoritativeInventory(context) + ? { authoritative: true as const } + : {}) + } +} + +export async function listSessionTabsInventory(context: RpcContext): Promise { + const { runtime, pairedDeviceId, signal } = context + // Why: a failed census degrades inside the runtime to the same scan without + // the authoritative label, so no client ever pays a second full collection. + const inventory = runtime.supportsAuthoritativeSessionTabsInventory() + ? await runtime.listAllMobileSessionTabsInventory(pairedDeviceId, signal) + : { snapshots: await runtime.listAllMobileSessionTabs(pairedDeviceId) } + return projectInventory(inventory, context) +} + +export async function subscribeSessionTabsInventory( + context: RpcContext, + emit: (result: unknown) => void +): Promise { + const { runtime, connectionId, requestId, pairedDeviceId } = context + if (context.signal?.aborted) { + throw new Error('client_disconnected') + } + const cleanupPrefix = `session.tabs:${connectionId ?? 'local'}:*` + const subscriptionId = requestId ? `${cleanupPrefix}:${requestId}` : cleanupPrefix + const inventoryController = new AbortController() + const abortInventory = (): void => inventoryController.abort() + context.signal?.addEventListener('abort', abortInventory, { once: true }) + let initialized = false + let closed = false + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + if (!initialized) { + // Why: the final authoritative scan subsumes these; replaying a prior epoch afterward resurrects stale host state. + return + } + emit({ + type: 'updated', + ...projectSessionTabsForClient(snapshot, context.clientKind, context.clientCapabilities) + }) + }, pairedDeviceId) + runtime.registerSubscriptionCleanup( + subscriptionId, + () => { + closed = true + inventoryController.abort() + unsubscribe() + if (initialized) { + emit({ type: 'end' }) + } + }, + connectionId + ) + if (closed) { + context.signal?.removeEventListener('abort', abortInventory) + return + } + const inventory = await listSessionTabsInventory({ + ...context, + signal: inventoryController.signal + }) + .catch((error) => { + runtime.cleanupSubscription(subscriptionId) + throw error + }) + .finally(() => context.signal?.removeEventListener('abort', abortInventory)) + if (closed) { + return + } + emit({ type: 'snapshots', ...inventory }) + initialized = true +} diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index 488a9da35a3..18f130bd869 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -651,6 +651,7 @@ describe('session tab RPC methods', () => { tabs: [] } ]), + supportsAuthoritativeSessionTabsInventory: vi.fn(() => false), onMobileSessionTabsChanged: vi.fn((listener: (snapshot: unknown) => void) => { listeners.push(listener) return unsubscribe @@ -697,6 +698,7 @@ describe('session tab RPC methods', () => { const runtime = { getRuntimeId: () => 'test-runtime', listAllMobileSessionTabs: vi.fn(() => []), + supportsAuthoritativeSessionTabsInventory: vi.fn(() => false), onMobileSessionTabsChanged: vi.fn(() => vi.fn()), registerSubscriptionCleanup: vi.fn() } as unknown as OrcaRuntimeService diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 1f459debd50..5b9958aeee8 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -7,23 +7,14 @@ import { WorktreeTabSelector } from './session-tabs-schemas' import { SESSION_TAB_CLOSE_METHODS } from './session-tab-close-methods' -import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection' -import { projectSessionTabBrowserPlacements } from './session-tab-browser-placement-projection' -import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { + listSessionTabsInventory, + projectSessionTabsForClient, + subscribeSessionTabsInventory +} from './session-tabs-inventory' import { SESSION_TAB_MARKDOWN_METHODS } from './session-tab-markdown-methods' import { SESSION_TAB_MUTATION_METHODS } from './session-tab-mutation-methods' -function projectSessionTabsForClient( - snapshot: RuntimeMobileSessionTabsResult, - clientKind: 'mobile' | 'runtime' | undefined, - clientCapabilities: Parameters[2] -) { - return projectSessionTabBrowserPlacements( - projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities), - clientCapabilities - ) -} - export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'session.tabs.list', @@ -38,11 +29,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'session.tabs.listAll', params: null, - handler: async (_params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => ({ - snapshots: (await runtime.listAllMobileSessionTabs(pairedDeviceId)).map((snapshot) => - projectSessionTabsForClient(snapshot, clientKind, clientCapabilities) - ) - }) + handler: async (_params, context) => listSessionTabsInventory(context) }), ...SESSION_TAB_MUTATION_METHODS, ...SESSION_TAB_CLOSE_METHODS, @@ -155,62 +142,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineStreamingMethod({ name: 'session.tabs.subscribeAll', params: null, - handler: async ( - _params, - { runtime, connectionId, requestId, pairedDeviceId, clientKind, clientCapabilities }, - emit - ) => { - let unsubscribe = (): void => {} - let closed = false - // Why: initial listAll errors should return one RPC error, not a leaked - // subscription cleanup that later emits a stray end frame. - let initialized = false - const cleanupPrefix = `session.tabs:${connectionId ?? 'local'}:*` - const subscriptionId = requestId ? `${cleanupPrefix}:${requestId}` : cleanupPrefix - // Why: shared-control can carry multiple all-tab subscribers on one - // socket; include the RPC id so closing one does not evict siblings. - runtime.registerSubscriptionCleanup( - subscriptionId, - () => { - closed = true - unsubscribe() - if (initialized) { - emit({ type: 'end' }) - } - }, - connectionId - ) - - if (closed) { - return - } - const snapshots = await Promise.resolve( - runtime.listAllMobileSessionTabs(pairedDeviceId) - ).catch((error) => { - runtime.cleanupSubscription(subscriptionId) - throw error - }) - if (closed) { - return - } - emit({ - type: 'snapshots', - snapshots: snapshots.map((snapshot) => - projectSessionTabsForClient(snapshot, clientKind, clientCapabilities) - ) - }) - initialized = true - - if (closed) { - return - } - unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { - emit({ - type: 'updated', - ...projectSessionTabsForClient(snapshot, clientKind, clientCapabilities) - }) - }, pairedDeviceId) - } + handler: async (_params, context, emit) => subscribeSessionTabsInventory(context, emit) }), defineMethod({ name: 'session.tabs.unsubscribeAll', diff --git a/src/main/runtime/session-tabs-inventory-publication.test.ts b/src/main/runtime/session-tabs-inventory-publication.test.ts new file mode 100644 index 00000000000..761504c273f --- /dev/null +++ b/src/main/runtime/session-tabs-inventory-publication.test.ts @@ -0,0 +1,416 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +type InventoryInternals = { + sessionTabsInventoryWaiters: Set<() => void> +} + +function createInventoryRuntime(): OrcaRuntimeService { + const runtime = new OrcaRuntimeService() + runtime.setPtyController({ listProcesses: vi.fn(async () => []) } as never) + return runtime +} + +async function waitForInventoryWaiter(runtime: OrcaRuntimeService): Promise { + const internals = runtime as unknown as InventoryInternals + for (let index = 0; index < 20 && internals.sessionTabsInventoryWaiters.size === 0; index += 1) { + await Promise.resolve() + } + expect(internals.sessionTabsInventoryWaiters.size).toBe(1) +} + +describe('authoritative session tab inventory publication', () => { + it('primes daemon-backed inventory but withholds headed results until renderer publication', async () => { + const runtime = createInventoryRuntime() + runtime.attachWindow(1) + const collect = vi.spyOn( + runtime as unknown as { collectAllMobileSessionTabs: () => Promise }, + 'collectAllMobileSessionTabs' + ) + let settled = false + + const pending = runtime.listAllMobileSessionTabsInventory().then((result) => { + settled = true + return result + }) + await waitForInventoryWaiter(runtime) + + expect(collect).toHaveBeenCalledTimes(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + await Promise.resolve() + expect(settled).toBe(false) + + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + await expect(pending).resolves.toEqual({ snapshots: [], authoritative: true }) + expect(collect).toHaveBeenCalledTimes(2) + }) + + it('treats an initial headless graph as an authoritative empty publication', async () => { + const runtime = createInventoryRuntime() + const pending = runtime.listAllMobileSessionTabsInventory() + await waitForInventoryWaiter(runtime) + + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + await expect(pending).resolves.toEqual({ snapshots: [], authoritative: true }) + }) + + it('returns a non-empty headed publication normally', async () => { + const runtime = createInventoryRuntime() + runtime.attachWindow(1) + const pending = runtime.listAllMobileSessionTabsInventory() + await waitForInventoryWaiter(runtime) + + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: 'repo::/worktree', + publicationEpoch: 'renderer-epoch', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + + await expect(pending).resolves.toMatchObject({ + authoritative: true, + snapshots: [{ worktree: 'repo::/worktree', publicationEpoch: 'renderer-epoch' }] + }) + }) + + it('uses one inventory scan when the current publication is already authoritative', async () => { + const runtime = createInventoryRuntime() + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + const collect = vi.spyOn( + runtime as unknown as { collectAllMobileSessionTabs: () => Promise }, + 'collectAllMobileSessionTabs' + ) + + await expect(runtime.listAllMobileSessionTabsInventory()).resolves.toEqual({ + snapshots: [], + authoritative: true + }) + + expect(collect).toHaveBeenCalledOnce() + }) + + it('retries publication churn until one inventory observes a stable epoch', async () => { + const runtime = createInventoryRuntime() + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + const inventory = { + snapshots: [], + ptyInventory: { + livePtyIds: new Set(), + allLivePtyIds: new Set(), + terminalIdentityByPtyId: new Map(), + queriedHostIds: new Set(['local']) + } + } + let collections = 0 + vi.spyOn( + runtime as unknown as { collectAllMobileSessionTabs: () => Promise }, + 'collectAllMobileSessionTabs' + ).mockImplementation(async () => { + collections += 1 + if (collections <= 3) { + expect(runtime.markRendererReloading(1)).not.toBeNull() + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + } + return inventory + }) + + await expect(runtime.listAllMobileSessionTabsInventory()).resolves.toEqual({ + snapshots: [], + authoritative: true + }) + expect(collections).toBe(4) + }) + + it('coalesces targeted and all-host PTY refreshes behind one aggregate census', async () => { + const runtime = createInventoryRuntime() + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + const emptyInventory = { + livePtyIds: new Set(), + allLivePtyIds: new Set(), + terminalIdentityByPtyId: new Map(), + queriedHostIds: new Set(['local']) + } + let resolveRefresh: ((inventory: typeof emptyInventory) => void) | undefined + const internals = runtime as unknown as { + refreshMobileSessionPtyInventory: (targetWorktreeId?: string | null) => Promise + performMobileSessionPtyRecordsRefresh: (targetWorktreeId: string | null) => Promise + } + const perform = vi.spyOn(internals, 'performMobileSessionPtyRecordsRefresh').mockImplementation( + () => + new Promise((resolve) => { + resolveRefresh = resolve + }) + ) + + const allHosts = runtime.listAllMobileSessionTabsInventory() + await Promise.resolve() + const targeted = internals.refreshMobileSessionPtyInventory('repo::/target') + await Promise.resolve() + + expect(perform).toHaveBeenCalledOnce() + resolveRefresh?.(emptyInventory) + + await targeted + await expect(allHosts).resolves.toEqual({ snapshots: [], authoritative: true }) + }) + + it('retries once and serves an unlabeled scan when the PTY census is unavailable', async () => { + const runtime = new OrcaRuntimeService() + const collect = vi.spyOn( + runtime as unknown as { collectAllMobileSessionTabs: () => Promise }, + 'collectAllMobileSessionTabs' + ) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + + await expect(runtime.listAllMobileSessionTabsInventory()).resolves.toEqual({ snapshots: [] }) + // Why: the retry gives daemon-backed tabs a post-race restore chance. + expect(collect).toHaveBeenCalledTimes(2) + }) + + it('serves an unlabeled scan when an execution host is omitted from the census', async () => { + const runtime = createInventoryRuntime() + vi.spyOn( + runtime as unknown as { + listKnownExecutionHostIds: () => Set + }, + 'listKnownExecutionHostIds' + ).mockReturnValue(new Set(['local', 'ssh:box-1'])) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + + await expect(runtime.listAllMobileSessionTabsInventory()).resolves.toEqual({ snapshots: [] }) + }) + + it('restores a renderer-owned PTY discovered by the authoritative inventory refresh', async () => { + const runtime = createInventoryRuntime() + const ptyId = 'pty-survived-host-relaunch' + const worktreeId = 'repo::/survived' + const tabId = '11111111-1111-4111-8111-111111111111' + const leafId = '22222222-2222-4222-8222-222222222222' + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + ;( + runtime as unknown as { + pairedRendererSessionOwnedPtyIds: Set + } + ).pairedRendererSessionOwnedPtyIds.add(ptyId) + vi.spyOn( + runtime as unknown as { refreshMobileSessionPtyInventory: () => Promise }, + 'refreshMobileSessionPtyInventory' + ).mockImplementation(async () => { + runtime.registerPty(ptyId, worktreeId, null, { + tabId, + leafId, + incarnationId: 'incarnation-survived-host-relaunch' + }) + return { + livePtyIds: new Set([ptyId]), + allLivePtyIds: new Set([ptyId]), + terminalIdentityByPtyId: new Map(), + queriedHostIds: new Set(['local']) + } + }) + + const result = await runtime.listAllMobileSessionTabsInventory() + + expect(result).toMatchObject({ + authoritative: true, + snapshots: [ + { + worktree: worktreeId, + tabs: [ + expect.objectContaining({ + type: 'terminal', + parentTabId: tabId, + leafId, + ptyId + }) + ] + } + ] + }) + }) + + it('requires the current headed renderer generation to publish', async () => { + const runtime = createInventoryRuntime() + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [] + }) + runtime.markRendererReloading(1) + let settled = false + + const pending = runtime.listAllMobileSessionTabsInventory().then((result) => { + settled = true + return result + }) + await waitForInventoryWaiter(runtime) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + await Promise.resolve() + expect(settled).toBe(false) + + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: 'repo::/restored', + publicationEpoch: 'renderer-reloaded', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + await expect(pending).resolves.toMatchObject({ + authoritative: true, + snapshots: [{ worktree: 'repo::/restored', publicationEpoch: 'renderer-reloaded' }] + }) + }) + + it('withholds authority while the renderer owes a requested worktree resync', async () => { + const runtime = createInventoryRuntime() + const worktree = 'repo::/resync' + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + + const incomplete = runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [], + unchangedMobileSessionWorktrees: [worktree] + }) + expect(incomplete.mobileSessionResyncWorktrees).toEqual([worktree]) + const pending = runtime.listAllMobileSessionTabsInventory() + await waitForInventoryWaiter(runtime) + + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree, + publicationEpoch: 'renderer-resynced', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + + await expect(pending).resolves.toMatchObject({ + authoritative: true, + snapshots: [{ worktree, publicationEpoch: 'renderer-resynced' }] + }) + }) + + it('restores publication authority when a renderer reload is cancelled', async () => { + const runtime = createInventoryRuntime() + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + const fence = runtime.markRendererReloading(1) + if (!fence) { + throw new Error('expected renderer reload fence') + } + const pending = runtime.listAllMobileSessionTabsInventory() + await waitForInventoryWaiter(runtime) + + expect(runtime.markRendererReloadCancelled(1, fence)).toBe(true) + + await expect(pending).resolves.toEqual({ snapshots: [], authoritative: true }) + }) + + it('restores authoritative headless publication after a failed desktop promotion', async () => { + const runtime = createInventoryRuntime() + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + runtime.attachWindow(1) + const pending = runtime.listAllMobileSessionTabsInventory() + await waitForInventoryWaiter(runtime) + + runtime.markGraphReloadFailed(1, 'renderer-process-gone') + + await expect(pending).resolves.toEqual({ snapshots: [], authoritative: true }) + }) + + it('removes a publication waiter when the request is cancelled', async () => { + const runtime = createInventoryRuntime() + runtime.attachWindow(1) + const controller = new AbortController() + const pending = runtime.listAllMobileSessionTabsInventory(undefined, controller.signal) + await waitForInventoryWaiter(runtime) + + controller.abort() + + await expect(pending).rejects.toThrow('client_disconnected') + expect((runtime as unknown as InventoryInternals).sessionTabsInventoryWaiters.size).toBe(0) + }) + + it('does not scan when the inventory request is already cancelled', async () => { + const runtime = createInventoryRuntime() + const collect = vi.spyOn( + runtime as unknown as { collectAllMobileSessionTabs: () => Promise }, + 'collectAllMobileSessionTabs' + ) + const controller = new AbortController() + controller.abort() + + await expect( + runtime.listAllMobileSessionTabsInventory(undefined, controller.signal) + ).rejects.toThrow('client_disconnected') + expect(collect).not.toHaveBeenCalled() + }) + + it('does not let a secondary renderer graph publish inventory authority', async () => { + const runtime = createInventoryRuntime() + runtime.attachWindow(1) + let settled = false + + const pending = runtime.listAllMobileSessionTabsInventory().then((result) => { + settled = true + return result + }) + await waitForInventoryWaiter(runtime) + + expect(() => + runtime.syncWindowGraph(2, { tabs: [], leaves: [], mobileSessionTabs: [] }) + ).toThrow('Runtime graph publisher does not match the authoritative window') + await Promise.resolve() + expect(settled).toBe(false) + expect( + (runtime as unknown as { sessionTabsInventoryPublicationEpoch: number | null }) + .sessionTabsInventoryPublicationEpoch + ).toBeNull() + + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + await expect(pending).resolves.toEqual({ snapshots: [], authoritative: true }) + }) + + it('reports no authoritative support behind the e2e disable override', () => { + vi.stubEnv('ORCA_E2E_DISABLE_AUTHORITATIVE_SESSION_TABS_INVENTORY', '1') + try { + expect(createInventoryRuntime().supportsAuthoritativeSessionTabsInventory()).toBe(false) + } finally { + vi.unstubAllEnvs() + } + }) +}) diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts index 145300fddc6..e63ce38d2fa 100644 --- a/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts @@ -64,8 +64,7 @@ describe('direct SSH hydration fanout', () => { api: { ui: {}, ssh: { - listTargets: () => - Promise.resolve(targets.map((id) => ({ id, label: id }))), + listTargets: () => Promise.resolve(targets.map((id) => ({ id, label: id }))), listRemovedTargetLabels: () => Promise.resolve({}), // A wedged relay answers nothing until the 30s RPC timeout fires. getState: ({ targetId }: { targetId: string }) => { diff --git a/src/renderer/src/runtime/host-live-terminal-probe.ts b/src/renderer/src/runtime/host-live-terminal-probe.ts index 1294fcb539e..8e5d8a14539 100644 --- a/src/renderer/src/runtime/host-live-terminal-probe.ts +++ b/src/renderer/src/runtime/host-live-terminal-probe.ts @@ -19,6 +19,7 @@ type RuntimeCall = (args: { method: string params: unknown timeoutMs: number + expectedEnvironmentPairingRevision?: number }) => Promise> type ValidTerminalListResult = RuntimeTerminalListResult & { @@ -52,7 +53,8 @@ function isTerminalListResult(value: unknown): value is ValidTerminalListResult async function probeHost( environmentId: string, - call: RuntimeCall + call: RuntimeCall, + expectedEnvironmentPairingRevision?: number ): Promise { const response = await call({ selector: environmentId, @@ -66,7 +68,8 @@ async function probeHost( requireFreshPtyLiveness: true, includeVisualLayouts: false }, - timeoutMs: 15_000 + timeoutMs: 15_000, + expectedEnvironmentPairingRevision }) if (response.ok === false || !isTerminalListResult(response.result)) { return 'unverifiable' @@ -86,14 +89,15 @@ async function probeHost( export function probeHostLiveTerminals( environmentId: string, call: RuntimeCall = (args) => window.api.runtimeEnvironments.call(args), - connectionGeneration = 0 + connectionGeneration = 0, + expectedEnvironmentPairingRevision?: number ): Promise { - const key = `${environmentId}\0${connectionGeneration}` + const key = `${environmentId}\0${connectionGeneration}\0${expectedEnvironmentPairingRevision ?? 'unknown'}` const existing = inFlightProbeByEnvironment.get(key) if (existing) { return existing } - const probe = probeHost(environmentId, call) + const probe = probeHost(environmentId, call, expectedEnvironmentPairingRevision) .catch((): HostLiveTerminalProbeVerdict => 'unverifiable') .finally(() => { if (inFlightProbeByEnvironment.get(key) === probe) { diff --git a/src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts b/src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts index 0d590055917..8c815968eec 100644 --- a/src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts +++ b/src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts @@ -25,7 +25,14 @@ vi.mock('../store', () => ({ } })) -import { clearRuntimeEnvironmentConnectionGenerationsForTests } from '@/store/slices/runtime-status' +import { + clearRuntimeEnvironmentConnectionGenerationsForTests, + setRuntimeEnvironmentConnectionGenerationForTests +} from '@/store/slices/runtime-status' +import { + getRuntimeEnvironmentRevision, + replaceRuntimeEnvironmentRevisions +} from './runtime-environment-revision' import { hasHostSessionMirrorHydrated, parkUntilHostSessionMirrorHydrates, @@ -35,11 +42,19 @@ import { clearHostLiveTerminalProbesForTests, probeHostLiveTerminals } from './host-live-terminal-probe' -import { applyWebSessionTabsStorePatch } from './web-session-tabs-sync' +import { + applyWebSessionTabsStorePatch, + clearWebSessionTabsTrackingForEnvironment, + type HostSessionMirrorSettle +} from './web-session-tabs-sync' const WORKTREE = 'repo1::/path/wt1' -type HostCall = { method: string; params: Record } +type HostCall = { + method: string + params: Record + expectedEnvironmentPairingRevision?: number +} /** `null` models a failed probe: `unverifiable`, never `the host has none`. */ function stubPairedHost( @@ -71,11 +86,31 @@ function stubPairedHost( } /** The exact verdict both `listAll` sites build when the host publishes nothing. */ -function settleEmptyInventory(environmentId: string): void { - applyWebSessionTabsStorePatch(() => ({}), { +function settleEmptyInventory( + environmentId: string, + options: { + authoritative?: boolean + expectedEnvironmentConnectionGeneration?: number + expectedEnvironmentPairingRevision?: number + expectedTrackingGeneration?: number + } = {} +): void { + createEmptyInventorySettle(environmentId, options)() +} + +function createEmptyInventorySettle( + environmentId: string, + options: { + authoritative?: boolean + expectedEnvironmentConnectionGeneration?: number + expectedEnvironmentPairingRevision?: number + expectedTrackingGeneration?: number + } = {} +): HostSessionMirrorSettle { + return applyWebSessionTabsStorePatch(() => ({}), { frames: [], - fullInventory: { environmentId, publishedSnapshotCount: 0 } - })() + fullInventory: { environmentId, publishedSnapshotCount: 0, ...options } + }) } async function flushProbe(): Promise { @@ -87,6 +122,7 @@ describe('empty host inventory settling the session mirror', () => { resetHostSessionMirrorHydrationForTests() clearRuntimeEnvironmentConnectionGenerationsForTests() clearHostLiveTerminalProbesForTests() + replaceRuntimeEnvironmentRevisions([]) }) afterEach(() => { @@ -127,6 +163,94 @@ describe('empty host inventory settling the session mirror', () => { expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(true) }) + it('settles an authoritative empty inventory despite an unrelated live terminal', async () => { + const environmentId = 'env-authoritative-empty' + const calls = stubPairedHost(['unrelated-terminal']) + let resumeSweeps = 0 + parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => { + resumeSweeps += 1 + }) + + settleEmptyInventory(environmentId, { authoritative: true }) + + expect(resumeSweeps).toBe(1) + expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(true) + expect(calls).toEqual([]) + }) + + it('rejects an authoritative receipt after the same id is re-paired', () => { + const environmentId = 'env-authoritative-repaired' + replaceRuntimeEnvironmentRevisions([{ id: environmentId, createdAt: 1, pairingRevision: 11 }]) + let resumeSweeps = 0 + parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => { + resumeSweeps += 1 + }) + const settle = createEmptyInventorySettle(environmentId, { + authoritative: true, + expectedEnvironmentPairingRevision: 11 + }) + + replaceRuntimeEnvironmentRevisions([{ id: environmentId, createdAt: 1, pairingRevision: 12 }]) + settle() + + expect(resumeSweeps).toBe(0) + expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false) + }) + + it('rejects an authoritative receipt from the previous connection generation', () => { + const environmentId = 'env-authoritative-reconnected' + setRuntimeEnvironmentConnectionGenerationForTests(environmentId, 1) + let resumeSweeps = 0 + parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => { + resumeSweeps += 1 + }) + const settle = createEmptyInventorySettle(environmentId, { + authoritative: true, + expectedEnvironmentConnectionGeneration: 1 + }) + + setRuntimeEnvironmentConnectionGenerationForTests(environmentId, 2) + settle() + + expect(resumeSweeps).toBe(0) + expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false) + }) + + it('rejects stale inventory even when recovery completes after reconnect', () => { + const environmentId = 'env-reconnected-before-receipt' + setRuntimeEnvironmentConnectionGenerationForTests(environmentId, 2) + let resumeSweeps = 0 + parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => { + resumeSweeps += 1 + }) + + createEmptyInventorySettle(environmentId, { + authoritative: true, + expectedEnvironmentConnectionGeneration: 1 + })() + + expect(resumeSweeps).toBe(0) + expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false) + }) + + it('rejects an authoritative receipt after mirror tracking is cleared', () => { + const environmentId = 'env-authoritative-cleared' + let resumeSweeps = 0 + parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => { + resumeSweeps += 1 + }) + const settle = createEmptyInventorySettle(environmentId, { + authoritative: true, + expectedTrackingGeneration: 0 + }) + + clearWebSessionTabsTrackingForEnvironment(environmentId) + settle() + + expect(resumeSweeps).toBe(0) + expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false) + }) + it('leaves waiters parked when the readiness probe fails', async () => { const environmentId = 'env-probe-failed' stubPairedHost(null) @@ -157,7 +281,7 @@ describe('empty host inventory settling the session mirror', () => { expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false) }) - it('accepts a legacy zero-terminal result without host scope', async () => { + it('treats a legacy zero-terminal result without host scope as none', async () => { const result = await probeHostLiveTerminals( 'env-legacy-zero', vi.fn(async () => ({ @@ -189,6 +313,28 @@ describe('empty host inventory settling the session mirror', () => { expect(result).toBe('live') }) + // Why: pre-hostScope hosts cannot prove emptiness; parking them forever would + // strand auto-resume on every truly-empty legacy host, so their best-effort + // empty keeps settling as it did before authoritative inventories existed. + it('settles a scope-less legacy empty inventory', async () => { + const environmentId = 'env-legacy-scope-less' + const call = vi.fn(async () => ({ + ok: true, + result: { terminals: [], totalCount: 0, truncated: false } + })) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call } } }) + let resumeSweeps = 0 + parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => { + resumeSweeps += 1 + }) + + settleEmptyInventory(environmentId) + await flushProbe() + + expect(resumeSweeps).toBe(1) + expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(true) + }) + it('rejects a present malformed host scope as unverifiable', async () => { const result = await probeHostLiveTerminals( 'env-malformed-scope', @@ -273,4 +419,126 @@ describe('empty host inventory settling the session mirror', () => { await expect(firstProbe).resolves.toBe('live') await expect(secondProbe).resolves.toBe('none') }) + + it('does not let a probe response cross a same-id pairing revision', async () => { + const environmentId = 'env-repaired' + let resolveProbe!: (response: RuntimeRpcResponse) => void + const call = vi.fn( + () => + new Promise>((resolve) => { + resolveProbe = resolve + }) + ) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call } } }) + replaceRuntimeEnvironmentRevisions([{ id: environmentId, createdAt: 1, pairingRevision: 11 }]) + let resumeSweeps = 0 + parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => { + resumeSweeps += 1 + }) + + settleEmptyInventory(environmentId, { expectedEnvironmentPairingRevision: 11 }) + expect(call).toHaveBeenCalledWith( + expect.objectContaining({ expectedEnvironmentPairingRevision: 11 }) + ) + replaceRuntimeEnvironmentRevisions([{ id: environmentId, createdAt: 1, pairingRevision: 12 }]) + expect(getRuntimeEnvironmentRevision(environmentId)).toBe(12) + resolveProbe({ + id: 'probe-repaired', + ok: true, + result: { + terminals: [], + totalCount: 0, + truncated: false, + hostScope: { hostIds: ['runtime:env'], omittedHostIds: [] } + }, + _meta: { runtimeId: 'runtime' } + }) + await flushProbe() + + expect(resumeSweeps).toBe(0) + expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false) + }) + + it('does not reuse an in-flight probe across same-id pairing revisions', async () => { + const environmentId = 'env-repaired-inflight' + const responses: ((response: RuntimeRpcResponse) => void)[] = [] + const call = vi.fn( + () => + new Promise>((resolve) => { + responses.push(resolve) + }) + ) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call } } }) + replaceRuntimeEnvironmentRevisions([{ id: environmentId, createdAt: 1, pairingRevision: 11 }]) + let resumeSweeps = 0 + parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => { + resumeSweeps += 1 + }) + + settleEmptyInventory(environmentId, { expectedEnvironmentPairingRevision: 11 }) + replaceRuntimeEnvironmentRevisions([{ id: environmentId, createdAt: 1, pairingRevision: 12 }]) + settleEmptyInventory(environmentId, { expectedEnvironmentPairingRevision: 12 }) + expect(call).toHaveBeenCalledTimes(2) + + responses[0]!({ + id: 'probe-old-owner', + ok: true, + result: { + terminals: [], + totalCount: 0, + truncated: false, + hostScope: { hostIds: ['runtime:old'], omittedHostIds: [] } + }, + _meta: { runtimeId: 'runtime-old' } + }) + responses[1]!({ + id: 'probe-new-owner', + ok: true, + result: { + terminals: [{ handle: 'terminal-new-owner' }], + totalCount: 1, + truncated: false, + hostScope: { hostIds: ['runtime:new'], omittedHostIds: [] } + }, + _meta: { runtimeId: 'runtime-new' } + }) + await flushProbe() + + expect(resumeSweeps).toBe(0) + expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false) + }) + + it('invalidates a pending legacy probe when environment tracking is cleared', async () => { + const environmentId = 'env-cleared' + let resolveProbe!: (response: RuntimeRpcResponse) => void + const call = vi.fn( + () => + new Promise>((resolve) => { + resolveProbe = resolve + }) + ) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call } } }) + let resumeSweeps = 0 + parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => { + resumeSweeps += 1 + }) + + settleEmptyInventory(environmentId) + clearWebSessionTabsTrackingForEnvironment(environmentId) + resolveProbe({ + id: 'probe-cleared', + ok: true, + result: { + terminals: [], + totalCount: 0, + truncated: false, + hostScope: { hostIds: ['runtime:env'], omittedHostIds: [] } + }, + _meta: { runtimeId: 'runtime' } + }) + await flushProbe() + + expect(resumeSweeps).toBe(0) + expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false) + }) }) diff --git a/src/renderer/src/runtime/host-session-mirror-frame-ordering-harness.ts b/src/renderer/src/runtime/host-session-mirror-frame-ordering-harness.ts index dd852ec3367..db385639a41 100644 --- a/src/renderer/src/runtime/host-session-mirror-frame-ordering-harness.ts +++ b/src/renderer/src/runtime/host-session-mirror-frame-ordering-harness.ts @@ -4,6 +4,10 @@ import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-envi import { makePaneKey } from '../../../shared/stable-pane-id' import { resetStaleDocumentVisibilityForTesting } from '@/components/terminal-pane/stale-document-visibility' import { useAppStore, type AppState } from '@/store' +import { + clearRuntimeEnvironmentConnectionGenerationsForTests, + setRuntimeEnvironmentConnectionGenerationForTests +} from '@/store/slices/runtime-status' import { BG_MIRROR_TAB_ID, BG_WT, @@ -189,6 +193,7 @@ export function installFrameOrderingHarness(options: { fakeTimers?: boolean } = } resetWebSessionTabsSnapshotFreshnessForTests() resetHostSessionMirrorHydrationForTests() + setRuntimeEnvironmentConnectionGenerationForTests(ENV, 1) seedState() }) @@ -198,6 +203,7 @@ export function installFrameOrderingHarness(options: { fakeTimers?: boolean } = replaceRuntimeEnvironmentRevisions([]) resetWebSessionTabsSnapshotFreshnessForTests() resetHostSessionMirrorHydrationForTests() + clearRuntimeEnvironmentConnectionGenerationsForTests() if (options.fakeTimers) { resetStaleDocumentVisibilityForTesting() setDocumentVisibility('visible') diff --git a/src/renderer/src/runtime/host-session-mirror-hydration-frame-ordering.test.tsx b/src/renderer/src/runtime/host-session-mirror-hydration-frame-ordering.test.tsx index 6c3b7388d63..3bb1355bfad 100644 --- a/src/renderer/src/runtime/host-session-mirror-hydration-frame-ordering.test.tsx +++ b/src/renderer/src/runtime/host-session-mirror-hydration-frame-ordering.test.tsx @@ -68,9 +68,11 @@ import { tabIds } from './host-session-mirror-frame-ordering-harness' import { + clearWebSessionTabsTrackingForEnvironment, useWebSessionTabsSync, WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS } from './web-session-tabs-sync' +import { hasHostSessionMirrorHydrated } from './host-session-mirror-hydration' import { WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS } from './window-visibility-subscription-parking' /** @@ -212,6 +214,22 @@ describe('mirrored-pane resume deferral against real stream frames', () => { expect(tabIds(WT)).not.toContain(MIRROR_TAB_ID) expectReplayedResume(paneKey, WT, 'codex-session-scoped-frame') }) + + it('does not let a scoped frame from before tracking reset release its pane', async () => { + renderHook(() => useWebSessionTabsSync()) + await act(settle) + const paneKey = seedSleepingRecord(MIRROR_TAB_ID, WT, 'codex-session-scoped-old-tracking') + expect(resumeSleepingAgentSessionsForWorktree(WT)).toBe(0) + + clearWebSessionTabsTrackingForEnvironment(ENV) + await publish(findSubscription('session.tabs.subscribe'), { + type: 'snapshot', + ...makeHostSnapshot(WT, OTHER_HOST_SURFACE_ID, OTHER_HOST_PARENT_TAB_ID) + }) + + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() + expect(hasHostSessionMirrorHydrated(ENV, WT)).toBe(false) + }) }) describe('mirror latch verdicts against real stream failures', () => { diff --git a/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts b/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts index dcda5332160..75a88500462 100644 --- a/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts +++ b/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts @@ -104,11 +104,11 @@ describe('host-session-mirror settle census', () => { 'runtime/host-session-mirror-hydration.ts': { hydrated: 1, worktreeHydrated: 1 }, // Exactly the receipt constructors: the patch receipt (environment-wide // and per-worktree) and the stale-frame receipt. The environment-wide - // mark has two audited arms, both inside the receipt: an inventory that - // published snapshots settles on the spot, and one that published none + // mark has three audited arms, all inside the receipt: an authoritative + // empty inventory, a non-empty inventory, and an empty inventory that // settles only after `terminal.list` says the host has no live PTY — // `0 === 0` is not host evidence (STA-5377). - 'runtime/web-session-tabs-sync.ts': { hydrated: 2, worktreeHydrated: 2 } + 'runtime/web-session-tabs-sync.ts': { hydrated: 3, worktreeHydrated: 2 } }) }) diff --git a/src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx b/src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx index 4f7050bf9e6..b07bce1052c 100644 --- a/src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx +++ b/src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx @@ -70,6 +70,7 @@ import { } from './host-session-mirror-hydration' import { refreshWebRuntimeSessionTabsSnapshot } from './web-runtime-session' import { + clearWebSessionTabsTrackingForEnvironment, useWebSessionTabsSync, WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS } from './web-session-tabs-sync' @@ -278,6 +279,36 @@ describe('the eager post-create list answers for its worktree', () => { expect(useAppStore.getState().tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toBeUndefined() expect(hasHostSessionMirrorHydrated(ENV, FLOATING_TERMINAL_WORKTREE_ID)).toBe(false) }) + + it('does not let a pre-reset eager list settle in the new tracking epoch', async () => { + let resolveList!: (response: unknown) => void + runtimeCall.mockImplementation((request: { method: string }) => + request.method === 'session.tabs.list' + ? new Promise((resolve) => { + resolveList = resolve + }) + : new Promise(() => {}) + ) + const paneKey = seedSleepingRecord(MIRROR_TAB_ID, WT, 'codex-session-eager-old-tracking') + expect(resumeSleepingAgentSessionsForWorktree(WT)).toBe(0) + + const refresh = refreshWebRuntimeSessionTabsSnapshot(ENV, WT) + await act(settle) + clearWebSessionTabsTrackingForEnvironment(ENV) + resolveList({ + id: 'list', + ok: true as const, + result: makeHostSnapshot(WT, OTHER_HOST_SURFACE_ID, OTHER_HOST_PARENT_TAB_ID), + _meta: { runtimeId: 'runtime-a' } + }) + await act(async () => { + await refresh + await settle() + }) + + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() + expect(hasHostSessionMirrorHydrated(ENV, WT)).toBe(false) + }) }) /** diff --git a/src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts b/src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts index 760f025f57a..bded4a1eb67 100644 --- a/src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts +++ b/src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts @@ -24,6 +24,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), applyWebSessionTabsStorePatch: vi.fn(() => () => {}), resolveHostSessionTabIdForWebSessionTab: vi.fn() })) diff --git a/src/renderer/src/runtime/web-runtime-session-browser-client-placement.test.ts b/src/renderer/src/runtime/web-runtime-session-browser-client-placement.test.ts index 6e7b99757d4..1b5a2bea7b3 100644 --- a/src/renderer/src/runtime/web-runtime-session-browser-client-placement.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-browser-client-placement.test.ts @@ -29,6 +29,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -49,6 +50,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-browser-create-failure.test.ts b/src/renderer/src/runtime/web-runtime-session-browser-create-failure.test.ts index ef959660c5d..505c22aafd4 100644 --- a/src/renderer/src/runtime/web-runtime-session-browser-create-failure.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-browser-create-failure.test.ts @@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -44,6 +45,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts b/src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts index 9a0294b90e9..b920f9e62bd 100644 --- a/src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts @@ -27,6 +27,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -48,6 +49,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-browser-create-split-placement.test.ts b/src/renderer/src/runtime/web-runtime-session-browser-create-split-placement.test.ts index fb4b0dfe22f..b348d73548a 100644 --- a/src/renderer/src/runtime/web-runtime-session-browser-create-split-placement.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-browser-create-split-placement.test.ts @@ -26,6 +26,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -47,6 +48,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-browser-create-staged-focus.test.ts b/src/renderer/src/runtime/web-runtime-session-browser-create-staged-focus.test.ts index edc7aa06a80..2cfd7917cd8 100644 --- a/src/renderer/src/runtime/web-runtime-session-browser-create-staged-focus.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-browser-create-staged-focus.test.ts @@ -30,6 +30,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -50,6 +51,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-browser-create-staged-hosting-intent.test.ts b/src/renderer/src/runtime/web-runtime-session-browser-create-staged-hosting-intent.test.ts index cd469bf4768..17c810c8060 100644 --- a/src/renderer/src/runtime/web-runtime-session-browser-create-staged-hosting-intent.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-browser-create-staged-hosting-intent.test.ts @@ -30,6 +30,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -50,6 +51,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-browser-create-staging.test.ts b/src/renderer/src/runtime/web-runtime-session-browser-create-staging.test.ts index 92263598015..181f8b9a5fc 100644 --- a/src/renderer/src/runtime/web-runtime-session-browser-create-staging.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-browser-create-staging.test.ts @@ -26,6 +26,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -47,6 +48,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-browser-placement-staleness.test.ts b/src/renderer/src/runtime/web-runtime-session-browser-placement-staleness.test.ts index 74801336fd5..480012c4b96 100644 --- a/src/renderer/src/runtime/web-runtime-session-browser-placement-staleness.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-browser-placement-staleness.test.ts @@ -30,6 +30,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -50,6 +51,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) return () => {} diff --git a/src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts b/src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts index 42dfa7612dc..9dbcf96d823 100644 --- a/src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts @@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -44,6 +45,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-tab-move.test.ts b/src/renderer/src/runtime/web-runtime-session-tab-move.test.ts index f9e1fee9dd4..15ae9bfb1d8 100644 --- a/src/renderer/src/runtime/web-runtime-session-tab-move.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-tab-move.test.ts @@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -37,6 +38,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-tab-props.test.ts b/src/renderer/src/runtime/web-runtime-session-tab-props.test.ts index 9585f87fd33..8d645c32174 100644 --- a/src/renderer/src/runtime/web-runtime-session-tab-props.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-tab-props.test.ts @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -36,6 +37,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts b/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts index 209cd182da9..394f67d9e77 100644 --- a/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts @@ -28,6 +28,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -49,6 +50,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-terminal-legacy-create.test.ts b/src/renderer/src/runtime/web-runtime-session-terminal-legacy-create.test.ts index 58c3b369712..6b1ba5e6d7c 100644 --- a/src/renderer/src/runtime/web-runtime-session-terminal-legacy-create.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-terminal-legacy-create.test.ts @@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -42,6 +43,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-terminal-pane-delegation.test.ts b/src/renderer/src/runtime/web-runtime-session-terminal-pane-delegation.test.ts index d632e8b80fa..b08040c0e66 100644 --- a/src/renderer/src/runtime/web-runtime-session-terminal-pane-delegation.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-terminal-pane-delegation.test.ts @@ -19,6 +19,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -40,6 +41,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session.test.ts b/src/renderer/src/runtime/web-runtime-session.test.ts index 76af63fbd63..675c9f34313 100644 --- a/src/renderer/src/runtime/web-runtime-session.test.ts +++ b/src/renderer/src/runtime/web-runtime-session.test.ts @@ -26,6 +26,7 @@ const mocks = vi.hoisted(() => ({ applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), acceptReplayedWebSessionTabsSnapshot: vi.fn(), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), deliverLaunchPromptToAgentTab: vi.fn(), @@ -46,6 +47,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index 1e443b13b25..c9313bbca7d 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -72,6 +72,7 @@ import { import { runRemoteAgentSessionLaunch } from './remote-agent-session-launch' import { translate } from '../i18n/i18n' import { getRuntimeEnvironmentRevision } from './runtime-environment-revision' +import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' import { parsePaneKey } from '../../../shared/stable-pane-id' import { toRuntimeExecutionHostId } from '../../../shared/execution-host' import { @@ -1074,8 +1075,13 @@ export async function refreshWebRuntimeSessionTabsSnapshot( errorMode?: 'warn' | 'throw' } = {} ): Promise { + const webSessionTabsSync = await import('./web-session-tabs-sync') const expectedEnvironmentPairingRevision = options.expectedEnvironmentPairingRevision ?? getRuntimeEnvironmentRevision(environmentId) + const expectedEnvironmentConnectionGeneration = + getRuntimeEnvironmentConnectionGeneration(environmentId) + const expectedTrackingGeneration = + webSessionTabsSync.getWebSessionTabsTrackingGeneration(environmentId) const callEnvironment = captureRuntimeEnvironmentCall( environmentId, expectedEnvironmentPairingRevision @@ -1124,7 +1130,7 @@ export async function refreshWebRuntimeSessionTabsSnapshot( applyWebSessionTabsSnapshot, applyWebSessionTabsStorePatch, decideWebSessionTabsSnapshot - } = await import('./web-session-tabs-sync') + } = webSessionTabsSync if (getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision) { return } @@ -1140,7 +1146,18 @@ export async function refreshWebRuntimeSessionTabsSnapshot( : state return patch === state ? state : patch }, - { frames: [{ environmentId, worktreeId: snapshot.worktree, decision }] }, + { + frames: [ + { + environmentId, + worktreeId: snapshot.worktree, + decision, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration + } + ] + }, snapshot ) settleMirror() diff --git a/src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx b/src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx index 4d9ab464273..187fb213b3f 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx +++ b/src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx @@ -49,6 +49,7 @@ import { useWebSessionTabsSync, WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS } from './web-session-tabs-sync' +import { clearRuntimeEnvironmentConnectionGenerationsForTests } from '@/store/slices/runtime-status' import { WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_BACKOFF_LIMIT, WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS @@ -59,7 +60,7 @@ const ENV_B = 'env-b' const WORKTREE = 'repo-a::worktree-a' const REVISION_A = 101 const REVISION_B = 201 -const MIRROR_KEY = `${ENV_A}\u0001runtime-a\u00011\u0001${REVISION_A}\u0000${ENV_B}\u0001runtime-b\u00012\u0001${REVISION_B}` +const MIRROR_KEY = `${ENV_A}\u0001runtime-a\u00010\u0001${REVISION_A}\u0000${ENV_B}\u0001runtime-b\u00010\u0001${REVISION_B}` const initialState = useAppStore.getInitialState() type RuntimeSubscribe = typeof window.api.runtimeEnvironments.subscribe @@ -238,6 +239,7 @@ describe('useWebSessionTabsSync window visibility', () => { useAppStore.setState(initialState, true) replaceRuntimeEnvironmentRevisions([]) resetWebSessionTabsSnapshotFreshnessForTests() + clearRuntimeEnvironmentConnectionGenerationsForTests() resetStaleDocumentVisibilityForTesting() setDocumentVisibility('visible') vi.useRealTimers() @@ -627,7 +629,7 @@ describe('useWebSessionTabsSync window visibility', () => { vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS) }) mocks.runtimeSessionMirrorEnvironmentKey.mockReturnValue( - MIRROR_KEY.replace('runtime-b\u00012', 'runtime-b\u00013') + MIRROR_KEY.replace('runtime-b\u00010', 'runtime-b\u00013') ) hook.rerender() await act(settle) @@ -795,7 +797,7 @@ describe('useWebSessionTabsSync window visibility', () => { expect(useAppStore.getState().browserTabsByWorktree[WORKTREE]).toBeUndefined() mocks.runtimeSessionMirrorEnvironmentKey.mockReturnValue( - MIRROR_KEY.replace('runtime-b\u00012', 'runtime-b\u00013') + MIRROR_KEY.replace('runtime-b\u00010', 'runtime-b\u00013') ) hook.rerender() await act(settle) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 994b3638476..073263be99a 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -135,11 +135,16 @@ export const WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS = 100 type SessionTabsStreamEvent = | (RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' }) - | { type: 'snapshots'; snapshots: RuntimeMobileSessionTabsResult[] } + | { + type: 'snapshots' + snapshots: RuntimeMobileSessionTabsResult[] + authoritative?: boolean + } | { type: 'end' } type SessionTabsListAllResult = { snapshots: RuntimeMobileSessionTabsResult[] + authoritative?: boolean } type SnapshotFreshness = { @@ -181,6 +186,7 @@ const latestSessionTabsRemovalFenceByWorktree = new Map() const trackedSessionTabsWorktreeIdsByEnvironment = new Map>() const sessionTabsEnvironmentsByWorktree = new Map>() +const sessionTabsTrackingGenerationByEnvironment = new Map() const lastHostTerminalTabCountByWorktree = new Map() const hostSessionTabIdByLocalKey = new Map() const hostSessionTabMappingKeysByEnvironmentAndWorktree = new Map< @@ -811,6 +817,10 @@ export function clearWebSessionTabsTrackingForEnvironment(environmentId: string) return } const keyPrefix = `${trimmedEnvironmentId}:` + sessionTabsTrackingGenerationByEnvironment.set( + trimmedEnvironmentId, + (sessionTabsTrackingGenerationByEnvironment.get(trimmedEnvironmentId) ?? 0) + 1 + ) for (const key of latestSessionTabsSnapshotByWorktree.keys()) { if (key.startsWith(keyPrefix)) { latestSessionTabsSnapshotByWorktree.delete(key) @@ -862,6 +872,10 @@ export function clearWebSessionTabsTrackingForEnvironment(environmentId: string) clearAllWebRuntimeWakeTerminalRespawn() } +export function getWebSessionTabsTrackingGeneration(environmentId: string): number { + return sessionTabsTrackingGenerationByEnvironment.get(environmentId.trim()) ?? 0 +} + function hostSessionTabMappingKey(args: { environmentId: string worktreeId: string @@ -3976,6 +3990,9 @@ export type HostSessionMirrorPatchFrame = { environmentId: string worktreeId: string decision: WebSessionTabsSnapshotDecision + expectedEnvironmentConnectionGeneration?: number + expectedEnvironmentPairingRevision?: number + expectedTrackingGeneration?: number } /** @@ -3990,7 +4007,14 @@ export type HostSessionMirrorPatchFrame = { */ export type HostSessionMirrorPatchVerdict = { frames: readonly HostSessionMirrorPatchFrame[] - fullInventory?: { environmentId: string; publishedSnapshotCount: number } + fullInventory?: { + environmentId: string + publishedSnapshotCount: number + authoritative?: boolean + expectedEnvironmentConnectionGeneration?: number + expectedEnvironmentPairingRevision?: number + expectedTrackingGeneration?: number + } } /** @@ -4005,16 +4029,52 @@ export type HostSessionMirrorPatchVerdict = { * `none` settles; `live` and `unverifiable` leave waiters for the next * inventory or per-worktree frame. */ -function settleEmptyHostInventoryOnlyIfHostHasNoTerminals(environmentId: string): void { - const probedGeneration = getRuntimeEnvironmentConnectionGeneration(environmentId) - void probeHostLiveTerminals(environmentId, undefined, probedGeneration).then((verdict) => { +type HostSessionMirrorSettleFence = { + environmentId: string + connectionGeneration: number + pairingRevision?: number + trackingGeneration: number +} + +function captureHostSessionMirrorSettleFence( + environmentId: string, + expected: { + connectionGeneration?: number + pairingRevision?: number + trackingGeneration?: number + } = {} +): HostSessionMirrorSettleFence { + return { + environmentId, + connectionGeneration: + expected.connectionGeneration ?? getRuntimeEnvironmentConnectionGeneration(environmentId), + pairingRevision: expected.pairingRevision ?? getRuntimeEnvironmentRevision(environmentId), + trackingGeneration: + expected.trackingGeneration ?? getWebSessionTabsTrackingGeneration(environmentId) + } +} + +function hostSessionMirrorSettleFenceIsCurrent(fence: HostSessionMirrorSettleFence): boolean { + return ( + getRuntimeEnvironmentConnectionGeneration(fence.environmentId) === fence.connectionGeneration && + getRuntimeEnvironmentRevision(fence.environmentId) === fence.pairingRevision && + getWebSessionTabsTrackingGeneration(fence.environmentId) === fence.trackingGeneration + ) +} + +function settleEmptyHostInventoryOnlyIfHostHasNoTerminals( + fence: HostSessionMirrorSettleFence +): void { + void probeHostLiveTerminals( + fence.environmentId, + undefined, + fence.connectionGeneration, + fence.pairingRevision + ).then((verdict) => { // Why: the probe is a round trip, and a reconnect in between would make its // answer speak for a connection whose PTYs nobody listed. - if ( - verdict === 'none' && - getRuntimeEnvironmentConnectionGeneration(environmentId) === probedGeneration - ) { - markHostSessionMirrorHydrated(environmentId) + if (verdict === 'none' && hostSessionMirrorSettleFenceIsCurrent(fence)) { + markHostSessionMirrorHydrated(fence.environmentId) } }) } @@ -4022,19 +4082,51 @@ function settleEmptyHostInventoryOnlyIfHostHasNoTerminals(environmentId: string) function createHostSessionMirrorSettle( verdict: HostSessionMirrorPatchVerdict ): HostSessionMirrorSettle { + const fenceByEnvironment = new Map() + for (const frame of verdict.frames) { + fenceByEnvironment.set( + frame.environmentId, + captureHostSessionMirrorSettleFence(frame.environmentId, { + connectionGeneration: frame.expectedEnvironmentConnectionGeneration, + pairingRevision: frame.expectedEnvironmentPairingRevision, + trackingGeneration: frame.expectedTrackingGeneration + }) + ) + } + if (verdict.fullInventory) { + fenceByEnvironment.set( + verdict.fullInventory.environmentId, + captureHostSessionMirrorSettleFence(verdict.fullInventory.environmentId, { + connectionGeneration: verdict.fullInventory.expectedEnvironmentConnectionGeneration, + pairingRevision: verdict.fullInventory.expectedEnvironmentPairingRevision, + trackingGeneration: verdict.fullInventory.expectedTrackingGeneration + }) + ) + } return () => { const { frames, fullInventory } = verdict const settles = frames.filter(({ decision }) => decision.settlesHostMirror) if (fullInventory && settles.length === fullInventory.publishedSnapshotCount) { + const fence = fenceByEnvironment.get(fullInventory.environmentId) + if (!fence || !hostSessionMirrorSettleFenceIsCurrent(fence)) { + return + } if (fullInventory.publishedSnapshotCount === 0) { - settleEmptyHostInventoryOnlyIfHostHasNoTerminals(fullInventory.environmentId) + if (fullInventory.authoritative) { + markHostSessionMirrorHydrated(fullInventory.environmentId) + } else { + settleEmptyHostInventoryOnlyIfHostHasNoTerminals(fence) + } return } markHostSessionMirrorHydrated(fullInventory.environmentId) return } for (const { environmentId, worktreeId } of settles) { - markHostSessionMirrorWorktreeHydrated(environmentId, worktreeId) + const fence = fenceByEnvironment.get(environmentId) + if (fence && hostSessionMirrorSettleFenceIsCurrent(fence)) { + markHostSessionMirrorWorktreeHydrated(environmentId, worktreeId) + } } } } @@ -4048,11 +4140,22 @@ function createHostSessionMirrorSettle( function hostSessionMirrorSettleForPatchlessFrame( decision: WebSessionTabsSnapshotDecision, environmentId: string, - worktreeId: string + worktreeId: string, + expected: { + connectionGeneration?: number + pairingRevision?: number + trackingGeneration?: number + } = {} ): HostSessionMirrorSettle | null { - return decision.settlesHostMirror - ? () => markHostSessionMirrorWorktreeHydrated(environmentId, worktreeId) - : null + if (!decision.settlesHostMirror) { + return null + } + const fence = captureHostSessionMirrorSettleFence(environmentId, expected) + return () => { + if (hostSessionMirrorSettleFenceIsCurrent(fence)) { + markHostSessionMirrorWorktreeHydrated(environmentId, worktreeId) + } + } } export function applyWebSessionTabsStorePatch( @@ -4217,7 +4320,9 @@ export function applyWebSessionTabsStorePatch( function loadInitialWebSessionTabs( environmentId: string, + expectedEnvironmentConnectionGeneration: number, expectedEnvironmentPairingRevision: number | undefined, + expectedTrackingGeneration: number, isCurrent: () => boolean ): void { // Why: only a conclusion that reached the store may settle the mirror, so @@ -4294,10 +4399,17 @@ function loadInitialWebSessionTabs( frames: applicable.map((snapshot, position) => ({ environmentId, worktreeId: snapshot.worktree, - decision: decisions[position]! + decision: decisions[position]!, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration })), fullInventory: { environmentId, + authoritative: result.authoritative === true, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration, // Why: a workspace the mirror never writes is not part of the // inventory the environment-wide verdict has to account for. publishedSnapshotCount: result.snapshots.filter((snapshot) => @@ -4406,9 +4518,11 @@ export function useWebSessionTabsSync(): void { ? runtimeSessionMirrorEnvironmentKey .split('\u0000') .map((entry) => { - const [environmentId = '', , , rawRevision = ''] = entry.split('\u0001') + const [environmentId = '', , rawConnectionGeneration = '0', rawRevision = ''] = + entry.split('\u0001') return { environmentId, + expectedEnvironmentConnectionGeneration: Number(rawConnectionGeneration), expectedEnvironmentPairingRevision: rawRevision === '' ? undefined : Number(rawRevision) } @@ -4453,6 +4567,9 @@ export function useWebSessionTabsSync(): void { inventoryReceived: boolean latestInventoryReceivedFrame: number pendingMissingWorktrees: Set + expectedEnvironmentConnectionGeneration: number + expectedEnvironmentPairingRevision?: number + expectedTrackingGeneration: number } type VisibilityResumeMissing = { environmentId: string @@ -4630,7 +4747,13 @@ export function useWebSessionTabsSync(): void { frames: decided.map(({ environmentId, snapshot, decision }) => ({ environmentId, worktreeId: snapshot.worktree, - decision + decision, + expectedEnvironmentConnectionGeneration: + batch.environments.get(environmentId)?.expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision: + batch.environments.get(environmentId)?.expectedEnvironmentPairingRevision, + expectedTrackingGeneration: + batch.environments.get(environmentId)?.expectedTrackingGeneration })) }, operations.map(({ snapshot }) => snapshot) @@ -4800,7 +4923,15 @@ export function useWebSessionTabsSync(): void { trackedWorktrees, inventoryReceived: false, latestInventoryReceivedFrame: 0, - pendingMissingWorktrees: new Set() + pendingMissingWorktrees: new Set(), + expectedEnvironmentConnectionGeneration: + environments.find((environment) => environment.environmentId === environmentId) + ?.expectedEnvironmentConnectionGeneration ?? + getRuntimeEnvironmentConnectionGeneration(environmentId), + expectedEnvironmentPairingRevision: environments.find( + (environment) => environment.environmentId === environmentId + )?.expectedEnvironmentPairingRevision, + expectedTrackingGeneration: getWebSessionTabsTrackingGeneration(environmentId) }) } } @@ -4819,7 +4950,11 @@ export function useWebSessionTabsSync(): void { } // Why: the stream's initial snapshot can land after first render, so a one-shot fetch makes initial parity deterministic. - for (const { environmentId, expectedEnvironmentPairingRevision } of environments) { + for (const { + environmentId, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision + } of environments) { if ( !shouldSyncAllRuntimeSessionTabs({ activeRuntimeEnvironmentId: environmentId, @@ -4829,6 +4964,7 @@ export function useWebSessionTabsSync(): void { continue } let requestedInitialLoad = false + const expectedTrackingGeneration = getWebSessionTabsTrackingGeneration(environmentId) environmentIdBySubscriptionSpec.push(environmentId) subscriptionSpecs.push({ subscribe: (isCurrent, { visibilityGeneration }) => { @@ -4836,7 +4972,13 @@ export function useWebSessionTabsSync(): void { let awaitingVisibilityResumeInventory = isVisibilityRestart if (!requestedInitialLoad) { requestedInitialLoad = true - loadInitialWebSessionTabs(environmentId, expectedEnvironmentPairingRevision, isCurrent) + loadInitialWebSessionTabs( + environmentId, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration, + isCurrent + ) } return window.api.runtimeEnvironments.subscribe( { @@ -4957,10 +5099,17 @@ export function useWebSessionTabsSync(): void { frames: applicable.map(({ snapshot }, position) => ({ environmentId, worktreeId: snapshot.worktree, - decision: decisions[position]! + decision: decisions[position]!, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration })), fullInventory: { environmentId, + authoritative: event.authoritative === true, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration, // Why: a workspace the mirror never writes is not // part of the inventory this verdict accounts for. publishedSnapshotCount: event.snapshots.filter((snapshot) => @@ -5049,7 +5198,16 @@ export function useWebSessionTabsSync(): void { settleHydration = applyWebSessionTabsStorePatch( (state) => applyWebSessionTabsSnapshot(state, recovered, environmentId), { - frames: [{ environmentId, worktreeId: recovered.worktree, decision }] + frames: [ + { + environmentId, + worktreeId: recovered.worktree, + decision, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration + } + ] }, recovered, event.type === 'updated' && !replayed @@ -5059,7 +5217,12 @@ export function useWebSessionTabsSync(): void { settleHydration = hostSessionMirrorSettleForPatchlessFrame( decision, environmentId, - recovered.worktree + recovered.worktree, + { + connectionGeneration: expectedEnvironmentConnectionGeneration, + pairingRevision: expectedEnvironmentPairingRevision, + trackingGeneration: expectedTrackingGeneration + } ) } } @@ -5137,6 +5300,7 @@ export function useWebSessionTabsSync(): void { ) { return } + const expectedTrackingGeneration = getWebSessionTabsTrackingGeneration(environmentId) let requestedInitialTerminal = false let requestedRespawnAfterWake = false @@ -5146,7 +5310,8 @@ export function useWebSessionTabsSync(): void { event: RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' }, response: RuntimeRpcResponse, isCurrent: () => boolean, - receivedFrame: number + receivedFrame: number, + expectedTrackingGeneration: number ): Promise => { const recovered = await recoverWebSessionTerminalOrphansBeforeApply( useAppStore.getState(), @@ -5193,13 +5358,29 @@ export function useWebSessionTabsSync(): void { // Why: a rejected frame settles only on the accepted view that outranked it. let settleMirror: HostSessionMirrorSettle | null = fresh ? null - : hostSessionMirrorSettleForPatchlessFrame(decision, environmentId, recovered.worktree) + : hostSessionMirrorSettleForPatchlessFrame(decision, environmentId, recovered.worktree, { + connectionGeneration: activeWorktreeRuntimeConnectionGeneration, + pairingRevision: expectedEnvironmentPairingRevision, + trackingGeneration: expectedTrackingGeneration + }) try { if (fresh) { const replayed = isRuntimeSubscriptionReplayResponse(response) settleMirror = applyWebSessionTabsStorePatch( (state) => applyWebSessionTabsSnapshot(state, recovered, environmentId), - { frames: [{ environmentId, worktreeId: recovered.worktree, decision }] }, + { + frames: [ + { + environmentId, + worktreeId: recovered.worktree, + decision, + expectedEnvironmentConnectionGeneration: + activeWorktreeRuntimeConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration + } + ] + }, recovered, event.type === 'updated' && !replayed ) @@ -5278,7 +5459,13 @@ export function useWebSessionTabsSync(): void { event.worktree, receivedFrame ) - void applyActiveSnapshot(event, response, isCurrent, receivedFrame) + void applyActiveSnapshot( + event, + response, + isCurrent, + receivedFrame, + expectedTrackingGeneration + ) .catch((error) => { if (isCurrent()) { console.warn( diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts index 0e2d300bef3..a3f860f1ea1 100644 --- a/src/renderer/src/store/slices/runtime-status.ts +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -84,6 +84,13 @@ export function getRuntimeEnvironmentConnectionGeneration(environmentId: string) export const clearRuntimeEnvironmentConnectionGenerationsForTests = (): void => connectionGenerationByEnvironment.clear() +export const setRuntimeEnvironmentConnectionGenerationForTests = ( + environmentId: string, + generation: number +): void => { + connectionGenerationByEnvironment.set(environmentId, generation) +} + function advanceRuntimeEnvironmentConnectionGeneration(environmentId: string): number { const next = getRuntimeEnvironmentConnectionGeneration(environmentId) + 1 connectionGenerationByEnvironment.set(environmentId, next) diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 0c4e45b5bcd..bfd58f181e1 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -106,6 +106,8 @@ export const ACCOUNT_IMPORT_RUNTIME_CAPABILITY = 'accounts.import-host-credentia export const TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = 'terminal.create-idempotency.v2' as const export const SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY = 'session-tabs.close-intent.v1' as const +export const SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY = + 'session-tabs.authoritative-inventory.v1' as const export const AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY = 'agent-session.session-boundary.v1' as const export { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update' @@ -189,6 +191,7 @@ export const RUNTIME_CAPABILITIES = [ WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, REMOTE_SERVER_UPDATE_CAPABILITY, AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY, diff --git a/src/shared/remote-runtime-client-capabilities.ts b/src/shared/remote-runtime-client-capabilities.ts index 5efaf0aed9d..b705e279b33 100644 --- a/src/shared/remote-runtime-client-capabilities.ts +++ b/src/shared/remote-runtime-client-capabilities.ts @@ -2,6 +2,7 @@ import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, type RuntimeCapability @@ -15,6 +16,7 @@ export function remoteRuntimeClientCapabilities( return Array.from( new Set([ SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, SKILL_INSTALL_RESULT_V2_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, diff --git a/tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts b/tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts index 1fbe42bddb2..9855d8d92b0 100644 --- a/tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts +++ b/tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts @@ -408,6 +408,15 @@ test('foregrounds a preserved daemon PTY after the paired host relaunches', asyn .toBe(true) await expectTerminalInteractive(client, target, 'x') target.handle = await findTerminalHandle(client, worktreeId, target.parentTabId) + const restored = await callRuntime<{ terminal: { ptyId: string | null } }>( + client.page, + client.environmentId, + 'terminal.show', + { terminal: target.handle } + ) + expect(restored.terminal.ptyId, 'host inventory must preserve the daemon PTY identity').toBe( + target.ptyId + ) const reconnectControl = await createHostTerminal(client, worktreeId, 'reconnect-control') terminals.push(reconnectControl) diff --git a/tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts b/tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts new file mode 100644 index 00000000000..c630d0420fc --- /dev/null +++ b/tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts @@ -0,0 +1,236 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SubprocessHandle } from '../../src/main/daemon/session-subprocess-handle' +import { TerminalHost } from '../../src/main/daemon/terminal-host' + +vi.mock('../../src/renderer/src/store', () => ({ + useAppStore: { + setState: vi.fn(), + getState: vi.fn(() => ({})), + subscribe: vi.fn(() => () => {}) + } +})) + +import { clearRuntimeEnvironmentConnectionGenerationsForTests } from '../../src/renderer/src/store/slices/runtime-status' +import { + hasHostSessionMirrorHydrated, + parkUntilHostSessionMirrorHydrates, + resetHostSessionMirrorHydrationForTests +} from '../../src/renderer/src/runtime/host-session-mirror-hydration' +import { clearHostLiveTerminalProbesForTests } from '../../src/renderer/src/runtime/host-live-terminal-probe' +import { replaceRuntimeEnvironmentRevisions } from '../../src/renderer/src/runtime/runtime-environment-revision' +import { applyWebSessionTabsStorePatch } from '../../src/renderer/src/runtime/web-session-tabs-sync' + +const ENVIRONMENT_ID = 'env-live-unpublished' +const WORKTREE = 'repo1::/path/wt1' + +type WriterSubprocess = SubprocessHandle & { + write: ReturnType void>> + exit: () => void +} + +function createWriterSubprocess(pid: number): WriterSubprocess { + let onExit: ((code: number) => void) | null = null + const write = vi.fn<(data: string) => void>() + return { + pid, + getForegroundProcess: () => 'codex', + write, + resize: vi.fn(), + kill: vi.fn(), + terminateOwnedTree: () => 'unavailable', + forceKill: vi.fn(), + signal: vi.fn(), + onData: vi.fn(), + onExit: (listener) => { + onExit = listener + }, + dispose: vi.fn(), + exit: () => onExit?.(0) + } +} + +describe('unpublished empty inventory daemon oracle', () => { + beforeEach(() => { + resetHostSessionMirrorHydrationForTests() + clearRuntimeEnvironmentConnectionGenerationsForTests() + clearHostLiveTerminalProbesForTests() + replaceRuntimeEnvironmentRevisions([]) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('keeps the resume dispatch parked and exactly one daemon writer live', async () => { + const subprocesses: ReturnType[] = [] + const host = new TerminalHost({ + spawnSubprocess: () => { + const subprocess = createWriterSubprocess(90_000 + subprocesses.length) + subprocesses.push(subprocess) + return subprocess + } + }) + await host.createOrAttach({ + sessionId: 'original-live-session', + cols: 80, + rows: 24, + launchAgent: 'codex', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + const call = vi.fn(async () => ({ + ok: true, + result: { + terminals: host.listSessions().map(({ sessionId }) => ({ + handle: sessionId, + worktreeId: WORKTREE, + connected: true + })), + totalCount: host.listSessions().length, + truncated: false, + hostScope: { hostIds: ['runtime:env'], omittedHostIds: [] } + } + })) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call } } }) + let resumeSweeps = 0 + let replacement: Promise | null = null + parkUntilHostSessionMirrorHydrates(ENVIRONMENT_ID, WORKTREE, () => { + resumeSweeps += 1 + replacement = host.createOrAttach({ + sessionId: 'replacement-resume-session', + cols: 80, + rows: 24, + launchAgent: 'codex', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + }) + + applyWebSessionTabsStorePatch(() => ({}), { + frames: [], + fullInventory: { environmentId: ENVIRONMENT_ID, publishedSnapshotCount: 0 } + })() + await new Promise((resolve) => setTimeout(resolve, 0)) + await (replacement ?? Promise.resolve()) + + const liveSessionIds = host.listSessions().map(({ sessionId }) => sessionId) + for (const sessionId of liveSessionIds) { + host.write(sessionId, `writer:${sessionId}`) + } + + expect({ + resumeSweeps, + mirrorHydrated: hasHostSessionMirrorHydrated(ENVIRONMENT_ID, WORKTREE), + liveSessionIds, + subprocessPids: subprocesses.map(({ pid }) => pid), + writerCalls: subprocesses.map(({ write }) => write.mock.calls) + }).toEqual({ + resumeSweeps: 0, + mirrorHydrated: false, + liveSessionIds: ['original-live-session'], + subprocessPids: [90_000], + writerCalls: [[['writer:original-live-session']]] + }) + expect(call).toHaveBeenCalledOnce() + for (const subprocess of subprocesses) { + subprocess.exit() + } + await host.dispose() + }) + + it('settles an authoritative empty inventory and releases the dispatch without a probe', async () => { + const call = vi.fn() + vi.stubGlobal('window', { api: { runtimeEnvironments: { call } } }) + let resumeSweeps = 0 + parkUntilHostSessionMirrorHydrates(ENVIRONMENT_ID, WORKTREE, () => { + resumeSweeps += 1 + }) + + applyWebSessionTabsStorePatch(() => ({}), { + frames: [], + fullInventory: { + environmentId: ENVIRONMENT_ID, + publishedSnapshotCount: 0, + authoritative: true + } + })() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect({ + resumeSweeps, + mirrorHydrated: hasHostSessionMirrorHydrated(ENVIRONMENT_ID, WORKTREE), + probeCalls: call.mock.calls.length + }).toEqual({ resumeSweeps: 1, mirrorHydrated: true, probeCalls: 0 }) + }) + + it('records legacy unconditional empty hydration releasing the resume dispatch', async () => { + const subprocesses: ReturnType[] = [] + const host = new TerminalHost({ + spawnSubprocess: () => { + const subprocess = createWriterSubprocess(90_000 + subprocesses.length) + subprocesses.push(subprocess) + return subprocess + } + }) + await host.createOrAttach({ + sessionId: 'original-live-session', + cols: 80, + rows: 24, + launchAgent: 'codex', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + // Legacy hosts answer the liveness probe with a scoped zero-terminal census. + const call = vi.fn(async () => ({ + ok: true, + result: { + terminals: [], + totalCount: 0, + truncated: false, + hostScope: { hostIds: ['runtime:env'], omittedHostIds: [] } + } + })) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call } } }) + let resumeSweeps = 0 + let replacement: Promise | null = null + parkUntilHostSessionMirrorHydrates(ENVIRONMENT_ID, WORKTREE, () => { + resumeSweeps += 1 + replacement = host.createOrAttach({ + sessionId: 'replacement-resume-session', + cols: 80, + rows: 24, + launchAgent: 'codex', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + }) + + applyWebSessionTabsStorePatch(() => ({}), { + frames: [], + fullInventory: { environmentId: ENVIRONMENT_ID, publishedSnapshotCount: 0 } + })() + await new Promise((resolve) => setTimeout(resolve, 0)) + await (replacement ?? Promise.resolve()) + + const liveSessionIds = host.listSessions().map(({ sessionId }) => sessionId) + for (const sessionId of liveSessionIds) { + host.write(sessionId, `writer:${sessionId}`) + } + + expect({ + resumeSweeps, + mirrorHydrated: hasHostSessionMirrorHydrated(ENVIRONMENT_ID, WORKTREE), + liveSessionIds, + subprocessPids: subprocesses.map(({ pid }) => pid), + writerCalls: subprocesses.map(({ write }) => write.mock.calls) + }).toEqual({ + resumeSweeps: 1, + mirrorHydrated: true, + liveSessionIds: ['original-live-session', 'replacement-resume-session'], + subprocessPids: [90_000, 90_001], + writerCalls: [[['writer:original-live-session']], [['writer:replacement-resume-session']]] + }) + expect(call).toHaveBeenCalledOnce() + for (const subprocess of subprocesses) { + subprocess.exit() + } + await host.dispose() + }) +}) From 3d8b9eeeb02e4cc7366c198ec7acadfaa8d452b2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:31:39 -0700 Subject: [PATCH 042/152] fix(crash-reporting): stop discarding the whole module list on POSIX dumps (#16687) --- .../minidump-crash-signature.test.ts | 47 +++++++++++++++++++ .../minidump-crash-signature.ts | 7 ++- .../crash-reporting/minidump-stream-reader.ts | 2 +- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/main/crash-reporting/minidump-crash-signature.test.ts b/src/main/crash-reporting/minidump-crash-signature.test.ts index 2d1fbb90972..8e878cf3d23 100644 --- a/src/main/crash-reporting/minidump-crash-signature.test.ts +++ b/src/main/crash-reporting/minidump-crash-signature.test.ts @@ -194,6 +194,22 @@ function buildDump(options: { return { dump: builder.build(streams) } } +/** Offset of a stream's directory entry: `{type, size, rva}`. */ +function streamEntry(dump: Buffer, type: number): number { + const directoryRva = dump.readUInt32LE(12) + for (let index = 0; index < dump.readUInt32LE(8); index += 1) { + const entry = directoryRva + index * 12 + if (dump.readUInt32LE(entry) === type) { + return entry + } + } + throw new Error(`no stream of type ${type}`) +} + +function moduleListRva(dump: Buffer): number { + return dump.readUInt32LE(streamEntry(dump, STREAM_TYPE_MODULE_LIST) + 8) +} + const FATAL_LINE = '[8104:1234:0815/143022.123456:FATAL:render_frame_impl.cc(4821)] Check failed: !is_detached_.' @@ -340,6 +356,37 @@ describe('parseMinidumpCrashSignature', () => { expect(signature?.faultingModuleOffset).toBe('0x1234') }) + it('resolves a faulting module past index 1024 on a real macOS image count', () => { + // A measured macOS renderer carries 1042 loaded images; a cap below that + // dropped the whole module list, so no macOS report could name a module. + const modules = Array.from({ length: 1042 }, (_, index) => ({ + base: 0x1_0000_0000n + BigInt(index) * 0x1_0000n, + size: 0x1000, + name: `/Applications/Orca.app/Contents/Frameworks/lib${index}.dylib` + })) + const { dump } = buildDump({ + exception: { code: 11, address: 0x1_0000_0000n + 1030n * 0x1_0000n + 0x24n }, + modules + }) + + const signature = parseMinidumpCrashSignature(dump) + + expect(signature?.faultingModule).toBe('lib1030.dylib') + expect(signature?.faultingModuleOffset).toBe('0x24') + }) + + it('still drops the module list when the claimed module count is absurd', () => { + const { dump } = buildDump({ + exception: { code: 11, address: 0x7ff7_0000_0010n }, + modules: [{ base: 0x7ff7_0000_0000n, size: 0x1000, name: '/opt/orca/orca' }] + }) + const corrupt = Buffer.from(dump) + corrupt.writeUInt32LE(0xffff_ffff, moduleListRva(corrupt)) + + expect(() => parseMinidumpCrashSignature(corrupt)).not.toThrow() + expect(parseMinidumpCrashSignature(corrupt)?.faultingModule).toBeUndefined() + }) + it('omits the faulting module when no image range covers the address', () => { const { dump } = buildDump({ exception: { code: 11, address: 0x10n }, diff --git a/src/main/crash-reporting/minidump-crash-signature.ts b/src/main/crash-reporting/minidump-crash-signature.ts index 3e246d4b77a..fe4e930504d 100644 --- a/src/main/crash-reporting/minidump-crash-signature.ts +++ b/src/main/crash-reporting/minidump-crash-signature.ts @@ -11,13 +11,16 @@ // rather than throwing: a truncated dump must degrade, not break crash // reporting. -import { findStream, isMinidump, MAX_MODULES, MinidumpView } from './minidump-stream-reader' +import { findStream, isMinidump, MinidumpView } from './minidump-stream-reader' import { readCrashpadAnnotations } from './minidump-crashpad-annotations' const STREAM_TYPE_MODULE_LIST = 4 const STREAM_TYPE_EXCEPTION = 6 const MODULE_RECORD_SIZE = 108 +// 8x headroom over a measured 1042-image macOS renderer, whose whole list a +// 1_024 cap dropped; a dump claiming more than this is corrupt. +const MAX_MODULE_LIST_MODULES = 8_192 const MODULE_BASE_OFFSET = 0 const MODULE_SIZE_OFFSET = 8 const MODULE_NAME_RVA_OFFSET = 20 @@ -70,7 +73,7 @@ function readModules(view: MinidumpView): ModuleRecord[] { return [] } const count = view.u32(stream.rva) - if (count === null || count > MAX_MODULES) { + if (count === null || count > MAX_MODULE_LIST_MODULES) { return [] } const modules: ModuleRecord[] = [] diff --git a/src/main/crash-reporting/minidump-stream-reader.ts b/src/main/crash-reporting/minidump-stream-reader.ts index 1230a6f9c48..4b9f32a07a3 100644 --- a/src/main/crash-reporting/minidump-stream-reader.ts +++ b/src/main/crash-reporting/minidump-stream-reader.ts @@ -13,7 +13,7 @@ const DIRECTORY_ENTRY_SIZE = 12 // A dump claiming an absurd stream count is corrupt; cap before iterating. const MAX_STREAMS = 4_096 export const MAX_ANNOTATION_VALUE_BYTES = 8_192 -// Shared cap: both the MINIDUMP_MODULE_LIST and Crashpad's per-module info list. +// Cap for Crashpad's per-module info list; the MINIDUMP_MODULE_LIST is capped separately. export const MAX_MODULES = 1_024 export type LocationDescriptor = { From 762fb05caa6e252e34768ca80d9d41946c41e1cf Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:31:57 -0700 Subject: [PATCH 043/152] fix(agents): one-shot submit-retry Enter for codex (STA-5379) (#16689) Codex silently discards Enter for ~75-150ms after its composer glyph first renders, and the boundary widens with prompt size and machine load, so no fixed first-Enter delay is provably safe on slow hosts. Submit success is not verifiable from PTY output, but a redundant Enter is a measured no-op on codex in both post-submit states, so send one blind retry after the first Enter. - tui-agent-config: new optional submitRetryDelayMs knob, set to 1200 on codex only; every other agent is byte-identical to today. - agent-paste-draft: after the post-paste '\r', wait the configured gap and send exactly one more '\r' inside the same PTY input transaction, so a concurrent paste cannot interleave. The retry is best-effort and never downgrades the first Enter's result. - Retry tests live in a new file to keep agent-paste-draft.test.ts under the max-lines budget. active-agent-note-send is deliberately exempt: its Enter rides the terminal.send RPC (different transport, server-side sendable guard), has no local agent identity to read the config from, and only fires on an already-running agent, where the codex cold-boot submit gate cannot occur. --- .../agent-paste-draft-submit-retry.test.ts | 197 ++++++++++++++++++ .../src/lib/agent-paste-draft.test.ts | 4 +- src/renderer/src/lib/agent-paste-draft.ts | 29 ++- src/shared/tui-agent-config.ts | 5 +- 4 files changed, 227 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts diff --git a/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts b/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts new file mode 100644 index 00000000000..5b763aa5c6d --- /dev/null +++ b/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts @@ -0,0 +1,197 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES, + pasteDraftWhenAgentReady, + POST_PASTE_SUBMIT_DELAY_MS, + sendAgentDraftPasteContent +} from './agent-paste-draft' +import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' + +const testState = vi.hoisted(() => ({ + appState: { + settings: {} as Record, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } as Record, + runtimePaneTitlesByTabId: {}, + tabsByWorktree: {} as Record, + repos: [] as { id: string; connectionId: string | null; executionHostId?: string | null }[], + worktreesByRepo: {} as Record + }, + ptyObserver: null as ((data: string) => void) | null, + unsubscribe: vi.fn(), + subscribeToPtyData: vi.fn(), + replayPreHandlerPtyData: vi.fn(), + isRemoteRuntimePtyId: vi.fn(), + sendRuntimePtyInputVerified: vi.fn(), + inspectRuntimeTerminalProcess: vi.fn(), + subscribeToRuntimeTerminalData: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => testState.appState, + subscribe: () => () => {} + } +})) + +vi.mock('@/components/terminal-pane/pty-data-sidecar-subscriptions', () => ({ + subscribeToPtyData: testState.subscribeToPtyData +})) + +vi.mock('@/components/terminal-pane/pty-pre-handler-buffer', () => ({ + replayPreHandlerPtyData: testState.replayPreHandlerPtyData +})) + +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + isRemoteRuntimePtyId: testState.isRemoteRuntimePtyId, + sendRuntimePtyInputVerified: testState.sendRuntimePtyInputVerified, + inspectRuntimeTerminalProcess: testState.inspectRuntimeTerminalProcess +})) + +vi.mock('@/runtime/runtime-terminal-stream', () => ({ + subscribeToRuntimeTerminalData: testState.subscribeToRuntimeTerminalData +})) + +const DECSET_BRACKETED_PASTE = '\x1b[?2004h' +const CODEX_COMPOSER_PROMPT_RENDER = '\x1b[1m›\x1b[0m Ask Codex to do anything' +const RENDER_QUIET_MS = 1500 +const ISSUE_URL = 'https://github.com/stablyai/orca/issues/123' +const PASTED_ISSUE_URL = `\x1b[200~${ISSUE_URL}\x1b[201~` +const CODEX_SUBMIT_RETRY_DELAY_MS = TUI_AGENT_CONFIG.codex.submitRetryDelayMs ?? 0 + +describe('post-paste submit retry Enter', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.stubGlobal('window', { + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout + }) + testState.appState.settings = {} + testState.appState.ptyIdsByTabId = { 'tab-1': ['pty-1'] } + testState.appState.tabsByWorktree = {} + testState.appState.repos = [] + testState.appState.worktreesByRepo = {} + testState.ptyObserver = null + testState.unsubscribe.mockReset() + testState.subscribeToPtyData.mockReset() + testState.subscribeToPtyData.mockImplementation( + (_ptyId: string, observer: (data: string) => void) => { + testState.ptyObserver = observer + return testState.unsubscribe + } + ) + testState.replayPreHandlerPtyData.mockReset() + testState.isRemoteRuntimePtyId.mockReset() + testState.isRemoteRuntimePtyId.mockReturnValue(false) + testState.sendRuntimePtyInputVerified.mockReset() + testState.sendRuntimePtyInputVerified.mockResolvedValue(true) + testState.inspectRuntimeTerminalProcess.mockReset() + testState.subscribeToRuntimeTerminalData.mockReset() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + it('sends one retry Enter after the configured gap for agents that can eat the first Enter', async () => { + const promise = startCodexSubmit() + await signalCodexComposerReady() + await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS) + + expect(enterWrites()).toHaveLength(1) + await vi.advanceTimersByTimeAsync(CODEX_SUBMIT_RETRY_DELAY_MS - 1) + expect(enterWrites()).toHaveLength(1) + await vi.advanceTimersByTimeAsync(1) + + await expect(promise).resolves.toBe(true) + expect(enterWrites()).toHaveLength(2) + expect(testState.sendRuntimePtyInputVerified).toHaveBeenLastCalledWith({}, 'pty-1', '\r') + expect(vi.getTimerCount()).toBe(0) + }) + + it('sends exactly one Enter for agents without a submit retry delay', async () => { + const promise = pasteDraftWhenAgentReady({ + tabId: 'tab-1', + content: ISSUE_URL, + agent: 'gemini', + submit: true + }) + await flushMicrotasks() + testState.ptyObserver?.(DECSET_BRACKETED_PASTE) + await vi.advanceTimersByTimeAsync(RENDER_QUIET_MS) + await flushMicrotasks() + await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS + CODEX_SUBMIT_RETRY_DELAY_MS) + + await expect(promise).resolves.toBe(true) + expect(enterWrites()).toHaveLength(1) + expect(vi.getTimerCount()).toBe(0) + }) + + it('holds the PTY input transaction across the retry Enter', async () => { + const writes: string[] = [] + testState.sendRuntimePtyInputVerified.mockImplementation( + async (_settings: unknown, _ptyId: string, data: string) => { + writes.push(data) + return true + } + ) + + const promise = startCodexSubmit() + await signalCodexComposerReady() + await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS) + // Competing paste on the same PTY: it must not open a frame the retry can land in. + const competing = sendAgentDraftPasteContent( + {}, + 'pty-1', + 'y'.repeat(AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES + 1) + ) + await flushMicrotasks(10) + expect(writes).toEqual([PASTED_ISSUE_URL, '\r']) + + await vi.advanceTimersByTimeAsync(CODEX_SUBMIT_RETRY_DELAY_MS) + await expect(promise).resolves.toBe(true) + await expect(competing).resolves.toBe(true) + + expect(writes.slice(0, 3)).toEqual([PASTED_ISSUE_URL, '\r', '\r']) + expect(writes.at(3)).toBe('\x1b[200~') + }) + + it('keeps a successful submit successful when the retry Enter is rejected', async () => { + testState.sendRuntimePtyInputVerified + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockRejectedValueOnce(new Error('terminal_not_writable')) + + const promise = startCodexSubmit() + await signalCodexComposerReady() + await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS + CODEX_SUBMIT_RETRY_DELAY_MS) + + await expect(promise).resolves.toBe(true) + expect(enterWrites()).toHaveLength(2) + }) +}) + +function enterWrites(): unknown[][] { + return testState.sendRuntimePtyInputVerified.mock.calls.filter((call) => call[2] === '\r') +} + +function startCodexSubmit(): Promise { + return pasteDraftWhenAgentReady({ + tabId: 'tab-1', + content: ISSUE_URL, + agent: 'codex', + submit: true + }) +} + +async function signalCodexComposerReady(): Promise { + await flushMicrotasks() + testState.ptyObserver?.(`${DECSET_BRACKETED_PASTE}${CODEX_COMPOSER_PROMPT_RENDER}`) + await flushMicrotasks() +} + +async function flushMicrotasks(iterations = 2): Promise { + for (let index = 0; index < iterations; index += 1) { + await Promise.resolve() + } +} diff --git a/src/renderer/src/lib/agent-paste-draft.test.ts b/src/renderer/src/lib/agent-paste-draft.test.ts index a09c88901c4..c8a660f2576 100644 --- a/src/renderer/src/lib/agent-paste-draft.test.ts +++ b/src/renderer/src/lib/agent-paste-draft.test.ts @@ -13,6 +13,7 @@ import { sendBracketedPasteToRunningAgent, submitPromptToAgentPty } from './agent-paste-draft' +import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' const testState = vi.hoisted(() => ({ appState: { @@ -70,6 +71,7 @@ const CODEX_COMPOSER_PROMPT_RENDER = '\x1b[1m›\x1b[0m Ask Codex to do anything const CODEX_DYNAMIC_COMPOSER_PROMPT_RENDER = '\x1b[?1049h\x1b[1m›\x1b[0m Implement {feature}' const ISSUE_URL = 'https://github.com/stablyai/orca/issues/123' const PASTED_ISSUE_URL = `\x1b[200~${ISSUE_URL}\x1b[201~` +const CODEX_SUBMIT_RETRY_DELAY_MS = TUI_AGENT_CONFIG.codex.submitRetryDelayMs ?? 0 describe('pasteDraftWhenAgentReady', () => { beforeEach(() => { @@ -169,7 +171,7 @@ describe('pasteDraftWhenAgentReady', () => { 'pty-1', PASTED_ISSUE_URL ) - await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS) + await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS + CODEX_SUBMIT_RETRY_DELAY_MS) await expect(promise).resolves.toBe(true) expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(2, {}, 'pty-1', '\r') expect(testState.unsubscribe).toHaveBeenCalledTimes(1) diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index f078383333d..72e467ee59e 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -129,7 +129,8 @@ export async function pasteDraftWhenAgentReady(args: { settings, ptyId, content, - submit: submit === true + submit: submit === true, + agent }) } @@ -168,7 +169,8 @@ export async function pasteDraftToAgentPtyWhenReady(args: { settings, ptyId, content, - submit: submit === true + submit: submit === true, + agent }) } @@ -197,11 +199,13 @@ async function sendBracketedPasteToAgent(args: { ptyId: string content: string submit: boolean + agent?: TuiAgent }): Promise { - const { settings = useAppStore.getState().settings, ptyId, content, submit } = args + const { settings = useAppStore.getState().settings, ptyId, content, submit, agent } = args + const submitRetryDelayMs = agent ? TUI_AGENT_CONFIG[agent]?.submitRetryDelayMs : undefined try { - // Why: paste + Enter must be one transaction, or a concurrent paste on this PTY - // can slip between them and submit a half-written prompt. + // Why: paste + Enter (+ retry Enter) must be one transaction, or a concurrent + // paste on this PTY can slip between them and submit a half-written prompt. return await runTerminalPtyInputTransaction(ptyId, async () => { const pasted = await sendAgentDraftPasteContentNow(settings, ptyId, content) if (!pasted || !submit) { @@ -212,7 +216,20 @@ async function sendBracketedPasteToAgent(args: { // Enter arrive in the same PTY write. Split the submit into the next turn so // the TUI processes bracketed-paste termination before handling Enter. await new Promise((resolve) => window.setTimeout(resolve, POST_PASTE_SUBMIT_DELAY_MS)) - return await sendRuntimePtyInputVerified(settings, ptyId, '\r') + const submitted = await sendRuntimePtyInputVerified(settings, ptyId, '\r') + + if (submitRetryDelayMs !== undefined) { + // Why: agents that render their composer before Enter is live silently eat + // the first Enter; the retry is best-effort and never downgrades `submitted`. + await new Promise((resolve) => window.setTimeout(resolve, submitRetryDelayMs)) + try { + await sendRuntimePtyInputVerified(settings, ptyId, '\r') + } catch { + // Why: a rejected retry leaves the first Enter's verdict untouched. + } + } + + return submitted }) } catch { return false diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index de3c79a68b1..4a89e1cb13b 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -42,6 +42,8 @@ export type TuiAgentConfig = { draftPasteReadySignal?: DraftPasteReadySignal /** Hard deadline for the agent's composer readiness signal. */ draftPasteReadyTimeoutMs?: number + /** Delay before one extra blind submit Enter, for agents that render their composer before Enter is live (codex); a no-op if the first Enter landed. */ + submitRetryDelayMs?: number /** Windows Shift+Enter encoding override; omitted agents keep the legacy Esc+CR path. */ windowsShiftEnterEncoding?: 'csi-u' /** Paste newlines for TUIs that read Windows console input records instead of VT paste frames. */ @@ -90,7 +92,8 @@ export const TUI_AGENT_CONFIG: Record = { windowsInputRecordPasteNewline: 'alt-enter', preflightTrust: 'codex', draftPasteReadySignal: 'codex-composer-prompt', - draftPasteReadyTimeoutMs: 20_000 + draftPasteReadyTimeoutMs: 20_000, + submitRetryDelayMs: 1200 }, autohand: { detectCmd: 'autohand', From 6e28c9a9b7183844ebe1dd4872040a0c0d305b18 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:33:01 -0700 Subject: [PATCH 044/152] fix(editor): constrain image previews before load instead of laying out at natural size (#16686) --- .../components/editor/ImageViewer.test.tsx | 23 +++++++++++++++++++ .../src/components/editor/ImageViewer.tsx | 4 +++- .../components/editor/ImageViewerPopup.tsx | 6 ++++- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/editor/ImageViewer.test.tsx b/src/renderer/src/components/editor/ImageViewer.test.tsx index cdd067ae0e3..8845cf33bd5 100644 --- a/src/renderer/src/components/editor/ImageViewer.test.tsx +++ b/src/renderer/src/components/editor/ImageViewer.test.tsx @@ -239,3 +239,26 @@ describe('ImageViewer preview source retry', () => { expect(findElementsByType(rendered, 'img')).toHaveLength(0) }) }) + +describe('ImageViewer pre-load layout box', () => { + beforeEach(() => { + reactHookRuntime.states = [] + reactHookRuntime.index = 0 + vi.clearAllMocks() + }) + + // Why: the scroll surface's inner box is w-max/h-max, so percentage maxes resolve + // to none and a 6000x4000 image lays out at natural resolution before onLoad. + it('bounds both scroll surfaces with viewport units before the image loads', async () => { + const images = findElementsByType(await renderExpandedImageViewer(pngBase64(1)), 'img') + + expect(images).toHaveLength(2) + for (const image of images) { + const className = String(image.props.className) + expect(className).toContain('max-h-[100vh]') + expect(className).toContain('max-w-[100vw]') + expect(className).not.toContain('max-h-full') + expect(className).not.toContain('max-w-full') + } + }) +}) diff --git a/src/renderer/src/components/editor/ImageViewer.tsx b/src/renderer/src/components/editor/ImageViewer.tsx index 81917eed2bb..b3e86bdfc81 100644 --- a/src/renderer/src/components/editor/ImageViewer.tsx +++ b/src/renderer/src/components/editor/ImageViewer.tsx @@ -291,7 +291,9 @@ export default function ImageViewer({ ? 'block h-auto max-h-none max-w-full' : inlineImageLayoutSize ? 'block h-full w-full' - : 'block max-h-full max-w-full' + : // Why: the w-max/h-max scroll box makes percentage maxes resolve to + // none, so only viewport units bound the image before onLoad. + 'block max-h-[100vh] max-w-[100vw]' )} onLoad={(event) => { const img = event.currentTarget diff --git a/src/renderer/src/components/editor/ImageViewerPopup.tsx b/src/renderer/src/components/editor/ImageViewerPopup.tsx index 6eb3ac99d58..4cce2b699a3 100644 --- a/src/renderer/src/components/editor/ImageViewerPopup.tsx +++ b/src/renderer/src/components/editor/ImageViewerPopup.tsx @@ -61,7 +61,11 @@ export default function ImageViewerPopup({ alt={filename} className={cn( 'object-contain', - imageLayoutSize ? 'block h-full w-full' : 'block max-h-full max-w-full' + imageLayoutSize + ? 'block h-full w-full' + : // Why: the w-max/h-max scroll box makes percentage maxes resolve to + // none, so only viewport units bound the image before onLoad. + 'block max-h-[100vh] max-w-[100vw]' )} /> From 9c01e09ecc9d3c1203968ace9945d16edfb35dd2 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:42:37 -0700 Subject: [PATCH 045/152] Revert "fix(codex): launch WSL accounts from direct homes" and "refactor(codex): remove WSL runtime mirror machinery" (#16722) This reverts commit ebcd637db981b0ce272c8e183794a8fc7caaa7be (#16504) and dependent commit 673842db35b37557006d9f20d04e077780253d82 (#16505). Launch-blocker rationale (findings-counsel validated): - P0 Data Loss (F06-1): The WSL legacy auth drain deleteSource=1 path deletes intact source auth without re-validating the destination after concurrent destination rewrites, permanently corrupting auth credentials on upgrade. - P1 Workflow Regression (FC-01): Pre-upgrade sessions under ~/.local/share/orca/codex-runtime-home/home are not linked into direct homes, breaking /resume in the Codex CLI for upgrading WSL users. --- .../agent-auth-restart-preservation.test.ts | 126 ++--- src/main/agent-auth-restart-preservation.ts | 49 +- ...sl-runtime-auth-drain-apply-script.test.ts | 170 ------ .../legacy-wsl-runtime-auth-drain.test.ts | 212 ------- .../legacy-wsl-runtime-auth-drain.ts | 319 ----------- .../codex-accounts/runtime-home-service.ts | 529 ++++++++++++------ .../runtime-home-wsl-managed-accounts.test.ts | 239 +++++++- .../runtime-home-wsl-session-bridge.test.ts | 128 ++++- .../runtime-home-wsl-system-default.test.ts | 47 +- .../wsl-codex-auth-batch-reader.ts | 70 --- src/main/codex/codex-pane-account-registry.ts | 9 - .../codex/codex-pane-launch-account.test.ts | 2 +- src/main/codex/codex-pane-launch-account.ts | 17 +- .../codex/codex-stale-pane-accounts.test.ts | 31 - src/main/pty/codex-home-wsl-env.ts | 4 +- .../__fixtures__/wsl-invocation-allowlist.txt | 1 + .../child-process-import-allowlist.txt | 1 + 17 files changed, 788 insertions(+), 1166 deletions(-) delete mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts delete mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts delete mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts delete mode 100644 src/main/codex-accounts/wsl-codex-auth-batch-reader.ts diff --git a/src/main/agent-auth-restart-preservation.test.ts b/src/main/agent-auth-restart-preservation.test.ts index b94bcc2435d..441ec609624 100644 --- a/src/main/agent-auth-restart-preservation.test.ts +++ b/src/main/agent-auth-restart-preservation.test.ts @@ -15,7 +15,8 @@ describe('preserveAgentAuthBeforeRestart', () => { codexRuntimeHome: { syncForCurrentSelection: vi.fn(() => { calls.push('codex') - }) + }), + syncActiveWslSelectionsBeforeRestart: vi.fn() }, claudeRuntimeAuth: { syncForCurrentSelection: vi.fn(async () => { @@ -32,13 +33,35 @@ describe('preserveAgentAuthBeforeRestart', () => { expect(calls).toEqual(['codex', 'claude', 'flush']) }) - it('runs Claude preservation after Codex and before the store flush', async () => { + it('runs WSL Codex preservation through the runtime service', async () => { + const syncForCurrentSelection = vi.fn() + const syncActiveWslSelectionsBeforeRestart = vi.fn() + + await preserveAgentAuthBeforeRestart({ + codexRuntimeHome: { + syncForCurrentSelection, + syncActiveWslSelectionsBeforeRestart + }, + store: { + flushPendingOrThrowAsync: vi.fn() + } + }) + + expect(syncForCurrentSelection).toHaveBeenCalledTimes(1) + expect(syncForCurrentSelection).toHaveBeenNthCalledWith(1) + expect(syncActiveWslSelectionsBeforeRestart).toHaveBeenCalledTimes(1) + }) + + it('runs Claude preservation before WSL Codex preservation', async () => { const calls: string[] = [] await preserveAgentAuthBeforeRestart({ codexRuntimeHome: { syncForCurrentSelection: vi.fn(() => { calls.push('codex-host') + }), + syncActiveWslSelectionsBeforeRestart: vi.fn(() => { + calls.push('codex-wsl') }) }, claudeRuntimeAuth: { @@ -53,80 +76,26 @@ describe('preserveAgentAuthBeforeRestart', () => { } }) - expect(calls).toEqual(['codex-host', 'claude', 'flush']) + expect(calls).toEqual(['codex-host', 'claude', 'codex-wsl', 'flush']) }) - it('drains retained WSL Codex auth before flushing the store', async () => { - const calls: string[] = [] - - await preserveAgentAuthBeforeRestart({ - codexRuntimeHome: { - syncForCurrentSelection: vi.fn(() => { - calls.push('codex-host') - }), - syncActiveWslSelectionsBeforeRestart: vi.fn(async () => { - calls.push('codex-wsl') - }) - }, - store: { - flushPendingOrThrowAsync: vi.fn(async () => { - calls.push('flush') - }) - } - }) - - expect(calls).toEqual(['codex-host', 'codex-wsl', 'flush']) - }) - - it('does not release restart while the bounded WSL drain is still running', async () => { - vi.useFakeTimers() - let finishWslDrain!: () => void - let settled = false - const flushPendingOrThrowAsync = vi.fn() - - const preservation = preserveAgentAuthBeforeRestart({ - codexRuntimeHome: { - syncForCurrentSelection: vi.fn(), - syncActiveWslSelectionsBeforeRestart: vi.fn( - () => - new Promise((resolve) => { - finishWslDrain = resolve - }) - ) - }, - store: { flushPendingOrThrowAsync } - }).then(() => { - settled = true - }) - - await vi.advanceTimersByTimeAsync(2_000) - await vi.advanceTimersByTimeAsync(1) - expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(1) - expect(settled).toBe(false) - - finishWslDrain() - await preservation - expect(settled).toBe(true) - }) - - it('continues after the bounded WSL drain fails without logging secrets', async () => { + it('continues after WSL Codex preservation fails', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const flushPendingOrThrowAsync = vi.fn() await preserveAgentAuthBeforeRestart({ codexRuntimeHome: { syncForCurrentSelection: vi.fn(), - syncActiveWslSelectionsBeforeRestart: vi.fn(async () => { + syncActiveWslSelectionsBeforeRestart: vi.fn(() => { throw new Error('wsl-token-secret') }) }, - store: { flushPendingOrThrowAsync } + store: { + flushPendingOrThrowAsync + } }) expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(1) - expect(warn).toHaveBeenCalledWith( - '[agent-auth-restart] Codex auth preservation failed (Error); continuing restart/update' - ) expect(JSON.stringify(warn.mock.calls)).not.toContain('token-secret') }) @@ -147,7 +116,8 @@ describe('preserveAgentAuthBeforeRestart', () => { codexRuntimeHome: { syncForCurrentSelection: vi.fn(() => { throw new Error('codex-token-secret') - }) + }), + syncActiveWslSelectionsBeforeRestart: vi.fn() }, claudeRuntimeAuth: { syncForCurrentSelection: vi.fn(async () => { @@ -200,36 +170,6 @@ describe('preserveAgentAuthBeforeRestart', () => { expect(calls).toEqual(['claude-start', 'flush', 'claude-finish']) }) - it('gives store persistence its own timeout after auth preservation expires', async () => { - vi.useFakeTimers() - let finishStoreFlush!: () => void - let settled = false - - const preservation = preserveAgentAuthBeforeRestart({ - claudeRuntimeAuth: { - syncForCurrentSelection: vi.fn(() => new Promise(() => {})) - }, - store: { - flushPendingOrThrowAsync: vi.fn( - () => - new Promise((resolve) => { - finishStoreFlush = resolve - }) - ) - } - }).then(() => { - settled = true - }) - - await vi.advanceTimersByTimeAsync(2_000) - await vi.advanceTimersByTimeAsync(1) - expect(settled).toBe(false) - - finishStoreFlush() - await preservation - expect(settled).toBe(true) - }) - it('bounds a store flush that never settles', async () => { vi.useFakeTimers() const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/src/main/agent-auth-restart-preservation.ts b/src/main/agent-auth-restart-preservation.ts index e351bfee334..eaae45f331b 100644 --- a/src/main/agent-auth-restart-preservation.ts +++ b/src/main/agent-auth-restart-preservation.ts @@ -4,8 +4,10 @@ import type { Store } from './persistence' const AUTH_PRESERVATION_TIMEOUT_MS = 2_000 -type CodexRuntimeAuthSync = Pick & - Partial> +type CodexRuntimeAuthSync = Pick< + CodexRuntimeHomeService, + 'syncForCurrentSelection' | 'syncActiveWslSelectionsBeforeRestart' +> type ClaudeRuntimeAuthSync = Pick type ShutdownStore = Pick @@ -25,26 +27,35 @@ export async function preserveAgentAuthBeforeRestart({ claudeRuntimeAuth, store }: AgentAuthRestartPreservationOptions): Promise { - runCodexPreservationStep(codexRuntimeHome) - // Why: the drain owns guest-process timeouts; a shared 2s cutoff can relaunch before promotion. - const wslCodexPreservation = runWslCodexPreservationStep(codexRuntimeHome) + const startedAt = Date.now() - if (claudeRuntimeAuth) { + runCodexPreservationStep(codexRuntimeHome) + + const remainingMs = Math.max(0, AUTH_PRESERVATION_TIMEOUT_MS - (Date.now() - startedAt)) + if (claudeRuntimeAuth && remainingMs > 0) { await runWithinLifecycleTimeout( 'Claude auth preservation', () => claudeRuntimeAuth.syncForCurrentSelection(), - AUTH_PRESERVATION_TIMEOUT_MS + remainingMs ) + } else if (claudeRuntimeAuth) { + logStepTimeout('Claude auth preservation', 0) } - const storePreservation = store - ? runWithinLifecycleTimeout( - 'Store persistence', - () => store.flushPendingOrThrowAsync(), - AUTH_PRESERVATION_TIMEOUT_MS - ) - : Promise.resolve() - await Promise.all([wslCodexPreservation, storePreservation]) + if (codexRuntimeHome && Date.now() - startedAt < AUTH_PRESERVATION_TIMEOUT_MS) { + runWslCodexPreservationStep(codexRuntimeHome) + } else if (codexRuntimeHome) { + logStepTimeout('Codex auth preservation', 0) + } + + if (store) { + const storeRemainingMs = Math.max(0, AUTH_PRESERVATION_TIMEOUT_MS - (Date.now() - startedAt)) + await runWithinLifecycleTimeout( + 'Store persistence', + () => store.flushPendingOrThrowAsync(), + storeRemainingMs + ) + } } function runCodexPreservationStep(codexRuntimeHome: CodexRuntimeAuthSync | null | undefined): void { @@ -55,11 +66,11 @@ function runCodexPreservationStep(codexRuntimeHome: CodexRuntimeAuthSync | null } } -async function runWslCodexPreservationStep( +function runWslCodexPreservationStep( codexRuntimeHome: CodexRuntimeAuthSync | null | undefined -): Promise { +): void { try { - await codexRuntimeHome?.syncActiveWslSelectionsBeforeRestart?.() + codexRuntimeHome?.syncActiveWslSelectionsBeforeRestart() } catch (error) { logStepFailure('Codex auth preservation', error) } @@ -78,7 +89,7 @@ async function runWithinLifecycleTimeout( }) // Why: this timeout only releases the restart/update path. It does not - // cancel a sync that already started. + // cancel a sync that already started, and Codex sync is synchronous today. const timeoutResult = new Promise<'timeout'>((resolve) => { timeout = setTimeout(() => resolve('timeout'), timeoutMs) }) diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts deleted file mode 100644 index 44ce6a6e00f..00000000000 --- a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { createHash } from 'node:crypto' -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { _internals } from './legacy-wsl-runtime-auth-drain' - -const isWindows = process.platform === 'win32' - -const SOURCE_AUTH = '{"tokens":{"expires_at":2000}}\n' -const TARGET_AUTH = '{"tokens":{"expires_at":1000}}\n' -const NEWER_AUTH = '{"tokens":{"expires_at":3000}}\n' -// Codex truncates before it writes, so a read landing mid-rotation sees this. -const TORN_AUTH = '{"tokens":{"exp' -const SOURCE_CREDENTIALS = '{"server":{"access_token":"source"}}\n' -const TORN_CREDENTIALS = '{"server":' - -function sha256(contents: string): string { - return createHash('sha256').update(contents).digest('hex') -} - -/** - * Runs the real guest script under `sh`, with `sha256sum` shimmed so a chosen - * hash call can rewrite the source underneath the script. That is the only way - * to land Codex's in-place rotation inside the window the script itself opens. - */ -function runApplyScript( - options: { - rewriteSourceAfterHashCall?: number - rewriteBytes?: string - rewriteTarget?: 'source-auth' | 'source-credentials' | 'target-auth' - sourceCredentials?: string - } = {} -): { - legacyAuth: string - status: number - targetAuth: string - targetCredentials: string | null -} { - const root = mkdtempSync(join(tmpdir(), 'orca-drain-apply-')) - const legacyHome = join(root, 'legacy') - const targetHome = join(root, 'account') - const binDir = join(root, 'bin') - for (const dir of [legacyHome, targetHome, binDir]) { - mkdirSync(dir, { recursive: true }) - } - const legacyAuthPath = join(legacyHome, 'auth.json') - const targetAuthPath = join(targetHome, 'auth.json') - const legacyCredentialsPath = join(legacyHome, '.credentials.json') - const targetCredentialsPath = join(targetHome, '.credentials.json') - writeFileSync(legacyAuthPath, SOURCE_AUTH) - writeFileSync(targetAuthPath, TARGET_AUTH) - if (options.sourceCredentials !== undefined) { - writeFileSync(legacyCredentialsPath, options.sourceCredentials) - } - - const counterPath = join(root, 'hash-calls') - writeFileSync(counterPath, '0') - const shimPath = join(binDir, 'sha256sum') - writeFileSync( - shimPath, - `#!/usr/bin/env node -const { createHash } = require('node:crypto') -const fs = require('node:fs') -const file = process.argv[process.argv.length - 1] -process.stdout.write( - createHash('sha256').update(fs.readFileSync(file)).digest('hex') + ' ' + file + '\\n' -) -const calls = Number(fs.readFileSync(process.env.HASH_COUNTER, 'utf8')) + 1 -fs.writeFileSync(process.env.HASH_COUNTER, String(calls)) -if (process.env.REWRITE_AFTER && calls === Number(process.env.REWRITE_AFTER)) { - fs.writeFileSync(process.env.REWRITE_TARGET, process.env.REWRITE_BYTES) -} -` - ) - chmodSync(shimPath, 0o755) - - let status = 0 - try { - execFileSync( - '/bin/sh', - [ - '-c', - _internals.applyLegacyAuthScript, - 'sh', - legacyHome, - join(root, 'absent-active-home'), - join(root, 'absent-marker.json'), - targetHome, - sha256(SOURCE_AUTH), - sha256(TARGET_AUTH), - '1', - '0', - options.sourceCredentials === undefined ? 'missing' : sha256(options.sourceCredentials) - ], - { - encoding: 'utf8', - env: { - ...process.env, - HASH_COUNTER: counterPath, - PATH: `${binDir}:${process.env.PATH ?? ''}`, - REWRITE_AFTER: options.rewriteSourceAfterHashCall - ? String(options.rewriteSourceAfterHashCall) - : '', - REWRITE_BYTES: options.rewriteBytes ?? TORN_AUTH, - REWRITE_TARGET: - options.rewriteTarget === 'source-credentials' - ? legacyCredentialsPath - : options.rewriteTarget === 'target-auth' - ? targetAuthPath - : legacyAuthPath - }, - stdio: ['ignore', 'pipe', 'pipe'], - timeout: 20_000 - } - ) - } catch (error) { - status = (error as { status?: number }).status ?? -1 - } - return { - legacyAuth: readFileSync(legacyAuthPath, 'utf8'), - status, - targetAuth: readFileSync(targetAuthPath, 'utf8'), - targetCredentials: existsSync(targetCredentialsPath) - ? readFileSync(targetCredentialsPath, 'utf8') - : null - } -} - -describe.skipIf(isWindows)('legacy WSL auth drain apply script', () => { - it('promotes the validated source into the account home', () => { - const outcome = runApplyScript() - expect(outcome.targetAuth).toBe(SOURCE_AUTH) - }) - - it('leaves the legacy home untouched while promoting', () => { - // One-directional: the promote step must never write back to the old home. - expect(runApplyScript().legacyAuth).toBe(SOURCE_AUTH) - }) - - it('refuses torn bytes and leaves the account home intact', () => { - // Hash call 1 is the source pre-check; rotating right after it means `cp` - // reads bytes freshness never judged. Pre-guard, those reached the target. - const outcome = runApplyScript({ rewriteSourceAfterHashCall: 1 }) - expect(outcome.status).toBe(42) - expect(outcome.targetAuth).toBe(TARGET_AUTH) - }) - - it('refuses MCP credentials that changed after host validation', () => { - const outcome = runApplyScript({ - rewriteSourceAfterHashCall: 2, - rewriteBytes: TORN_CREDENTIALS, - rewriteTarget: 'source-credentials', - sourceCredentials: SOURCE_CREDENTIALS - }) - expect(outcome.status).toBe(43) - expect(outcome.targetCredentials).toBeNull() - }) - - it('does not overwrite auth changed after the destination hash check', () => { - const outcome = runApplyScript({ - rewriteBytes: NEWER_AUTH, - rewriteSourceAfterHashCall: 4, - rewriteTarget: 'target-auth' - }) - expect(outcome.status).toBe(39) - expect(outcome.targetAuth).toBe(NEWER_AUTH) - }) -}) diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts deleted file mode 100644 index 0c0caa1b669..00000000000 --- a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { runWslProcessMock } = vi.hoisted(() => ({ - runWslProcessMock: vi.fn() -})) - -vi.mock('../wsl/wsl-runner', () => ({ - runWslProcess: runWslProcessMock -})) - -import { - _internals, - drainLegacyWslRuntimeAuth, - startLegacyWslRuntimeAuthDrain -} from './legacy-wsl-runtime-auth-drain' -import { readWslCodexAuths } from './wsl-codex-auth-batch-reader' - -const SOURCE_AUTH = '{"tokens":{"expires_at":2000}}\n' -const STALE_AUTH = '{"tokens":{"expires_at":1000}}\n' -const NEWER_AUTH = '{"tokens":{"expires_at":3000}}\n' - -function inspection(auth: string, credentials?: string): string { - return [ - Buffer.from(auth).toString('base64'), - credentials === undefined ? 'missing' : 'present', - credentials === undefined ? '' : Buffer.from(credentials).toString('base64') - ].join('\n') -} - -function result(code: number, stdout = '') { - return { - code, - stdout, - stderr: '', - timedOut: false, - environmentResolved: true - } -} - -describe('legacy WSL runtime auth drain', () => { - beforeEach(() => { - runWslProcessMock.mockReset() - _internals.resetDrainQueue() - }) - - it('promotes fresher auth guest-side while a legacy pane remains', async () => { - runWslProcessMock - .mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH))) - .mockResolvedValueOnce(result(0)) - const resolveDestination = vi.fn(() => ({ - authContents: STALE_AUTH, - linuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home' - })) - - await drainLegacyWslRuntimeAuth({ - distro: 'Ubuntu', - guestHomeLinuxPath: '/home/alice', - legacyPanePresent: true, - resolveDestination - }) - - expect(resolveDestination).toHaveBeenCalledWith(SOURCE_AUTH) - expect(runWslProcessMock).toHaveBeenCalledTimes(2) - expect(runWslProcessMock.mock.calls[1]?.[0].args.slice(3)).toEqual([ - '/home/alice/.local/share/orca/codex-accounts/account-1/home', - expect.any(String), - expect.any(String), - '1', - '0', - 'missing' - ]) - expect(runWslProcessMock.mock.calls[1]?.[0].script).toContain('readlink -f') - expect(runWslProcessMock.mock.calls[1]?.[0].script).toContain('source_credentials=') - expect(runWslProcessMock.mock.calls[1]?.[0].script).toContain('chmod 600') - }) - - it('does not write when freshness cannot be proven', async () => { - runWslProcessMock.mockResolvedValueOnce(result(0, inspection('{"tokens":{}}\n'))) - - await drainLegacyWslRuntimeAuth({ - distro: 'Ubuntu', - guestHomeLinuxPath: '/home/alice', - legacyPanePresent: false, - resolveDestination: () => ({ - authContents: '{"tokens":{}}\n', - linuxHomePath: '/home/alice/.codex' - }) - }) - - expect(runWslProcessMock).toHaveBeenCalledTimes(1) - }) - - it('refuses a source with no unique destination', async () => { - runWslProcessMock.mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH))) - - await drainLegacyWslRuntimeAuth({ - distro: 'Ubuntu', - guestHomeLinuxPath: '/home/alice', - legacyPanePresent: false, - resolveDestination: () => null - }) - - expect(runWslProcessMock).toHaveBeenCalledTimes(1) - }) - - it('retires stale legacy auth only after the last recorded pane exits', async () => { - runWslProcessMock - .mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH))) - .mockResolvedValueOnce(result(0)) - - await drainLegacyWslRuntimeAuth({ - distro: 'Ubuntu', - guestHomeLinuxPath: '/home/alice', - legacyPanePresent: false, - resolveDestination: () => ({ - authContents: NEWER_AUTH, - linuxHomePath: '/home/alice/.codex' - }) - }) - - expect(runWslProcessMock.mock.calls[1]?.[0].args.slice(-3)).toEqual(['0', '1', 'missing']) - }) - - it('keeps an absent source retryable while a legacy pane remains', async () => { - runWslProcessMock.mockResolvedValueOnce(result(21)) - - await drainLegacyWslRuntimeAuth({ - distro: 'Ubuntu', - guestHomeLinuxPath: '/home/alice', - legacyPanePresent: true, - resolveDestination: vi.fn() - }) - - expect(runWslProcessMock).toHaveBeenCalledTimes(1) - }) - - it('does nothing after the guest-side completion marker is present', async () => { - runWslProcessMock.mockResolvedValueOnce(result(20)) - const resolveDestination = vi.fn() - - await drainLegacyWslRuntimeAuth({ - distro: 'Ubuntu', - guestHomeLinuxPath: '/home/alice', - legacyPanePresent: false, - resolveDestination - }) - - expect(resolveDestination).not.toHaveBeenCalled() - expect(runWslProcessMock).toHaveBeenCalledTimes(1) - }) - - it('marks an absent source complete after every legacy pane exits', async () => { - runWslProcessMock.mockResolvedValueOnce(result(21)).mockResolvedValueOnce(result(0)) - - await drainLegacyWslRuntimeAuth({ - distro: 'Ubuntu', - guestHomeLinuxPath: '/home/alice', - legacyPanePresent: false, - resolveDestination: vi.fn() - }) - - expect(runWslProcessMock).toHaveBeenCalledTimes(2) - expect(runWslProcessMock.mock.calls[1]?.[0].args).toEqual([ - '/home/alice/.local/share/orca/codex-runtime-home/home', - '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home', - '/home/alice/.local/share/orca/codex-runtime-home/direct-home-auth-drain-v1.json' - ]) - }) - - it('coalesces concurrent drain triggers instead of queueing every poll', async () => { - runWslProcessMock.mockImplementation(() => new Promise(() => {})) - const options = { - distro: 'Ubuntu', - guestHomeLinuxPath: '/home/alice', - legacyPanePresent: true, - resolveDestination: () => null - } - - startLegacyWslRuntimeAuthDrain(options) - startLegacyWslRuntimeAuthDrain(options) - await Promise.resolve() - - expect(runWslProcessMock).toHaveBeenCalledTimes(1) - }) - - it('reads every candidate home in one bounded guest process', async () => { - runWslProcessMock.mockResolvedValueOnce( - result( - 0, - [`present:${Buffer.from(SOURCE_AUTH).toString('base64')}`, 'missing', 'unreadable'].join( - '\n' - ) - ) - ) - - await expect( - readWslCodexAuths('Ubuntu', ['/home/alice/.codex-a', '/home/alice/.codex-b', '/bad']) - ).resolves.toEqual([ - { kind: 'present', contents: SOURCE_AUTH }, - { kind: 'missing' }, - { kind: 'unreadable' } - ]) - expect(runWslProcessMock).toHaveBeenCalledTimes(1) - expect(runWslProcessMock).toHaveBeenCalledWith( - expect.objectContaining({ - args: ['/home/alice/.codex-a', '/home/alice/.codex-b', '/bad'], - maxOutputBytes: 2 * 1024 * 1024, - timeoutMs: 5_000 - }) - ) - }) -}) diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts deleted file mode 100644 index bde5d4c9ebc..00000000000 --- a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { createHash } from 'node:crypto' -import { posix as pathPosix } from 'node:path' -import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env' -import { runWslProcess } from '../wsl/wsl-runner' -import { compareCodexAuthFreshness, codexAuthIsFresher } from './codex-auth-identity' -import { decodeWslBase64Payload } from './wsl-codex-auth-batch-reader' - -const DRAIN_MARKER_NAME = 'direct-home-auth-drain-v1.json' -const MARKER_PRESENT_EXIT = 20 -const SOURCE_AUTH_ABSENT_EXIT = 21 - -export type LegacyWslRuntimeAuthDestination = { - authContents: string - linuxHomePath: string -} - -type LegacyWslRuntimeInspection = { - authContents: string - credentials: { kind: 'missing' } | { kind: 'present'; contents: string } -} - -type LegacyWslRuntimeAuthDrainOptions = { - distro: string - guestHomeLinuxPath: string - legacyPanePresent: boolean - resolveDestination: ( - runtimeAuthContents: string - ) => LegacyWslRuntimeAuthDestination | null | Promise -} - -const drainQueueByDistro = new Map>() -const completedDistroKeys = new Set() - -export function startLegacyWslRuntimeAuthDrain( - options: LegacyWslRuntimeAuthDrainOptions -): Promise { - const key = options.distro.trim().toLowerCase() - if (completedDistroKeys.has(key)) { - return Promise.resolve() - } - // Coalesce launch/rate-limit callers while a drain is in flight. Queuing a - // new pass for every poll can otherwise build an unbounded promise chain - // while a legacy pane keeps the migration pending. - const inFlight = drainQueueByDistro.get(key) - if (inFlight) { - return inFlight - } - const next = drainLegacyWslRuntimeAuth(options) - .then((status) => { - if (status === 'complete') { - completedDistroKeys.add(key) - } - }) - .catch((error) => { - console.warn('[codex-wsl-auth-drain] Failed to drain legacy runtime auth:', error) - }) - drainQueueByDistro.set(key, next) - void next.finally(() => { - if (drainQueueByDistro.get(key) === next) { - drainQueueByDistro.delete(key) - } - }) - return next -} - -export async function drainLegacyWslRuntimeAuth( - options: LegacyWslRuntimeAuthDrainOptions -): Promise<'complete' | 'pending'> { - const paths = resolveLegacyRuntimePaths(options.guestHomeLinuxPath) - const inspection = await runWslProcess({ - distro: options.distro, - loginPath: 'none', - script: INSPECT_LEGACY_AUTH_SCRIPT, - args: [paths.runtimeHome, paths.activeHome, paths.marker], - timeoutMs: 5_000, - maxOutputBytes: 2 * 1024 * 1024 - }) - if (inspection.code === MARKER_PRESENT_EXIT) { - return 'complete' - } - if (inspection.code === SOURCE_AUTH_ABSENT_EXIT) { - if (!options.legacyPanePresent) { - await finalizeAbsentLegacyAuth(options.distro, paths) - return 'complete' - } - return 'pending' - } - assertSuccessfulDrainStep('inspect', inspection) - - const inspected = parseLegacyRuntimeInspection(inspection.stdout) - if (!inspected) { - return 'pending' - } - const destination = await options.resolveDestination(inspected.authContents) - if (!destination) { - return 'pending' - } - const freshness = compareCodexAuthFreshness(inspected.authContents, destination.authContents) - if (freshness === null) { - return 'pending' - } - const promoteAuth = codexAuthIsFresher(inspected.authContents, destination.authContents) - const result = await runWslProcess({ - distro: options.distro, - loginPath: 'none', - script: APPLY_LEGACY_AUTH_SCRIPT, - args: [ - paths.runtimeHome, - paths.activeHome, - paths.marker, - destination.linuxHomePath, - sha256(inspected.authContents), - sha256(destination.authContents), - promoteAuth ? '1' : '0', - options.legacyPanePresent ? '0' : '1', - inspected.credentials.kind === 'present' ? sha256(inspected.credentials.contents) : 'missing' - ], - timeoutMs: 5_000, - maxOutputBytes: 16 * 1024 - }) - assertSuccessfulDrainStep('apply', result) - return options.legacyPanePresent ? 'pending' : 'complete' -} - -function parseLegacyRuntimeInspection(stdout: string): LegacyWslRuntimeInspection | null { - const [authBase64, credentialsKind, credentialsBase64] = stdout.split('\n') - const authContents = decodeWslBase64Payload(authBase64 ?? '') - if (authContents === null) { - return null - } - if (credentialsKind === 'missing') { - return { authContents, credentials: { kind: 'missing' } } - } - if (credentialsKind !== 'present') { - return null - } - const credentialsContents = decodeWslBase64Payload(credentialsBase64 ?? '') - if (!credentialsContents || !isJsonObject(credentialsContents)) { - return null - } - return { authContents, credentials: { kind: 'present', contents: credentialsContents } } -} - -function isJsonObject(contents: string): boolean { - try { - const value = JSON.parse(contents) as unknown - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) - } catch { - return false - } -} - -function resolveLegacyRuntimePaths(guestHomeLinuxPath: string): { - activeHome: string - marker: string - runtimeHome: string -} { - const runtimeHome = pathPosix.join(guestHomeLinuxPath, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS) - const runtimeRoot = pathPosix.dirname(runtimeHome) - return { - activeHome: pathPosix.join(runtimeRoot, 'active', 'wsl', 'home'), - marker: pathPosix.join(runtimeRoot, DRAIN_MARKER_NAME), - runtimeHome - } -} - -async function finalizeAbsentLegacyAuth( - distro: string, - paths: ReturnType -): Promise { - const result = await runWslProcess({ - distro, - loginPath: 'none', - script: FINALIZE_ABSENT_AUTH_SCRIPT, - args: [paths.runtimeHome, paths.activeHome, paths.marker], - timeoutMs: 5_000, - maxOutputBytes: 16 * 1024 - }) - assertSuccessfulDrainStep('finalize', result) -} - -function assertSuccessfulDrainStep( - step: string, - result: { code: number | null; stderr: string; timedOut: boolean } -): void { - if (result.code === 0 && !result.timedOut) { - return - } - const detail = result.stderr.trim() - throw new Error( - `Legacy WSL auth drain ${step} failed (${result.timedOut ? 'timeout' : `exit ${result.code}`})${detail ? `: ${detail}` : ''}` - ) -} - -function sha256(contents: string): string { - return createHash('sha256').update(contents).digest('hex') -} - -const RESOLVE_LEGACY_HOME_SCRIPT = ` -legacy_home="$1" -legacy_home_resolved=0 -if [ -e "$1" ] || [ -L "$1" ]; then - legacy_home=$(readlink -f -- "$1") || exit 30 - legacy_home_resolved=1 -fi -if [ -e "$2" ] || [ -L "$2" ]; then - active_home=$(readlink -f -- "$2") || exit 31 - if [ "$legacy_home_resolved" = 1 ]; then - [ "$active_home" = "$legacy_home" ] || exit 32 - else - legacy_home="$active_home" - fi -fi -` - -const INSPECT_LEGACY_AUTH_SCRIPT = ` -set -eu -[ ! -f "$3" ] || exit ${MARKER_PRESENT_EXIT} -${RESOLVE_LEGACY_HOME_SCRIPT} -source_auth="$legacy_home/auth.json" -[ -f "$source_auth" ] || exit ${SOURCE_AUTH_ABSENT_EXIT} -encode_file() { - encoded=$(base64 < "$1") || return 1 - printf '%s' "$encoded" | tr -d '\n' -} -encode_file "$source_auth" -printf '\n' -source_credentials="$legacy_home/.credentials.json" -if [ -f "$source_credentials" ]; then - printf 'present\n' - encode_file "$source_credentials" - printf '\n' -elif [ ! -e "$source_credentials" ] && [ ! -L "$source_credentials" ]; then - printf 'missing\n\n' -else - exit 44 -fi -` - -const APPLY_LEGACY_AUTH_SCRIPT = ` -set -eu -[ ! -f "$3" ] || exit 0 -${RESOLVE_LEGACY_HOME_SCRIPT} -target_home=$(readlink -f -- "$4") || exit 33 -[ "$legacy_home" != "$target_home" ] || exit 34 -source_auth="$legacy_home/auth.json" -target_auth="$target_home/auth.json" -[ -f "$source_auth" ] || exit 35 -[ -f "$target_auth" ] || exit 36 -hash_file() { sha256sum -- "$1" | cut -d ' ' -f 1; } -[ "$(hash_file "$source_auth")" = "$5" ] || exit 37 -[ "$(hash_file "$target_auth")" = "$6" ] || exit 38 -umask 077 -temporary_auth="$target_auth.orca-drain-$$" -temporary_credentials="$target_home/.credentials.json.orca-drain-$$" -temporary_previous_auth="$target_auth.orca-drain-previous-$$" -temporary_marker="$3.orca-drain-$$" -cleanup() { rm -f -- "$temporary_auth" "$temporary_credentials" "$temporary_previous_auth" "$temporary_marker"; } -trap cleanup EXIT HUP INT TERM -source_credentials="$legacy_home/.credentials.json" -target_credentials="$target_home/.credentials.json" -if [ -f "$source_credentials" ] && [ ! -e "$target_credentials" ] && [ ! -L "$target_credentials" ]; then - [ "$9" != missing ] || exit 43 - [ "$(hash_file "$source_credentials")" = "$9" ] || exit 43 - cp -- "$source_credentials" "$temporary_credentials" - chmod 600 "$temporary_credentials" - [ "$(hash_file "$temporary_credentials")" = "$9" ] || exit 43 - [ "$(hash_file "$source_credentials")" = "$9" ] || exit 43 - mv -n -- "$temporary_credentials" "$target_credentials" -elif [ "$9" = missing ] && [ ! -e "$target_credentials" ] && [ ! -L "$target_credentials" ]; then - [ ! -e "$source_credentials" ] && [ ! -L "$source_credentials" ] || exit 43 -fi -if [ "$7" = 1 ]; then - cp -- "$source_auth" "$temporary_auth" - chmod 600 "$temporary_auth" - # Codex rewrites auth.json in place, so this copy is a second read: verify the - # bytes being promoted, not the ones freshness was judged on. - [ "$(hash_file "$temporary_auth")" = "$5" ] || exit 42 - [ "$(hash_file "$target_auth")" = "$6" ] || exit 39 - # The hard link keeps the destination inode observable without creating a - # missing-path crash window. In-place writers update both names. - ln -- "$target_auth" "$temporary_previous_auth" - [ "$(hash_file "$temporary_previous_auth")" = "$6" ] || exit 39 - mv -f -- "$temporary_auth" "$target_auth" - if [ "$(hash_file "$temporary_previous_auth")" != "$6" ]; then - mv -f -- "$temporary_previous_auth" "$target_auth" - exit 39 - fi - rm -- "$temporary_previous_auth" -fi -if [ "$8" = 1 ]; then - [ "$(hash_file "$source_auth")" = "$5" ] || exit 40 - rm -- "$source_auth" - printf '%s\n' '{"completed":true}' > "$temporary_marker" - chmod 600 "$temporary_marker" - mv -f -- "$temporary_marker" "$3" -fi -` - -const FINALIZE_ABSENT_AUTH_SCRIPT = ` -set -eu -[ ! -f "$3" ] || exit 0 -${RESOLVE_LEGACY_HOME_SCRIPT} -[ ! -e "$legacy_home/auth.json" ] && [ ! -L "$legacy_home/auth.json" ] || exit 41 -umask 077 -temporary_marker="$3.orca-drain-$$" -trap 'rm -f -- "$temporary_marker"' EXIT HUP INT TERM -printf '%s\n' '{"completed":true}' > "$temporary_marker" -chmod 600 "$temporary_marker" -mv -f -- "$temporary_marker" "$3" -` - -export const _internals = { - applyLegacyAuthScript: APPLY_LEGACY_AUTH_SCRIPT, - resetDrainQueue: (): void => { - drainQueueByDistro.clear() - completedDistroKeys.clear() - } -} diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 7975c7172ad..cf3a0105d63 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -1,4 +1,5 @@ /* eslint-disable max-lines -- Why: keeps Codex's whole runtime-home contract in one place so account-switch semantics don't drift across launch/login/quota paths. */ +import { quotePosixShell } from '../../shared/wsl-login-shell-command' import { appendFileSync, copyFileSync, @@ -17,6 +18,7 @@ import { unlinkSync } from 'node:fs' import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { execFileSync } from 'node:child_process' import { dirname, extname, @@ -31,6 +33,7 @@ import { app } from 'electron' import type { CodexManagedAccount } from '../../shared/managed-account-types' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import type { Store } from '../persistence' +import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env' import { recoverInterruptedGuardedFileOperation, removeFileAtomicallyIfUnchanged, @@ -52,13 +55,16 @@ import { resolveWslCodexSessionSourceHome } from '../codex/codex-session-source-home' import { startWslCodexSessionBridgeInBackground } from '../codex/wsl-codex-session-bridge' -import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' +import { + prepareSystemConfigForFreshRuntimeMirror, + syncSystemConfigIntoManagedCodexHome +} from '../codex/codex-config-mirror' import { parseWslUncPath, toLinuxPath } from '../../shared/wsl-paths' import { - getCodexSelectionLaneKey, getWslSelectionKey, getSelectedCodexAccountIdForTarget, normalizeCodexRuntimeSelection, + setSelectedCodexAccountIdForTarget, type CodexAccountSelectionTarget } from './runtime-selection' import { getDefaultWslDistro, getWslHome } from '../wsl' @@ -85,16 +91,10 @@ import { CodexCredentialAbsenceGrace } from './codex-credential-absence-grace' import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility' import { getCodexPaneAccount, - hasRecordedLegacyWslCodexPane, hasRecordedLegacySharedCodexPane, type CodexPaneHomeRoute } from '../codex/codex-pane-account-registry' import { isShellStartupEnvProbeSupported } from '../pty/shell-startup-env' -import { - startLegacyWslRuntimeAuthDrain, - type LegacyWslRuntimeAuthDestination -} from './legacy-wsl-runtime-auth-drain' -import { readWslCodexAuths, type WslCodexAuthRead } from './wsl-codex-auth-batch-reader' type CodexSystemDefaultSnapshot = { authJson: string | null @@ -130,6 +130,7 @@ type CodexRuntimeLogoutMarkerStatus = | { kind: 'applies' } | { kind: 'system-default-changed'; systemDefaultAuthJson: string | null } +type CodexReadBackResult = 'unchanged' | 'persisted' | 'rejected' type CodexReadBackMatch = | { kind: 'matched' @@ -194,6 +195,11 @@ export class CodexRuntimeHomeService { private lastSyncedAccountId: string | null = null // Last auth.json Orca wrote to the runtime home; a later diff signals an out-of-band change (Codex token refresh, or external login to adopt). private lastWrittenAuthJson: string | null = null + // Why: WSL terminals have per-distro runtime homes; sharing the host baseline can make stale WSL auth look newer than managed storage. + private readonly lastWrittenWslAuthJsonByDistro = new Map() + private readonly lastSyncedWslAccountIdByDistro = new Map() + private readonly wslRuntimeHomePathByDistro = new Map() + private skipNextReadBackForAccountId: string | null = null // Why: a managed host account refreshes auth in its own home. Remember that // provenance so a later deselect never adopts stale shared bytes. private lastHostAccountUsedSelfContainedHome = false @@ -238,11 +244,11 @@ export class CodexRuntimeHomeService { ): string | null { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) - const homePath = this.getWslCodexHomePathForSelection(wslTarget) - this.startLegacyWslAuthDrain(wslTarget) - this.syncWslConfigAndGlobalInstructionsForLaunch(wslTarget, homePath) - this.startWslSessionBridgeForLaunch(wslTarget, homePath) - return homePath + const syncedRuntimeHomePath = this.syncWslRuntimeForCurrentSelection(wslTarget) + this.syncWslConfigAndGlobalInstructionsForLaunch(wslTarget, syncedRuntimeHomePath) + const runtimeHomePath = syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) + this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath) + return runtimeHomePath } const selfContainedAccount = this.getSelfContainedManagedHostAccount() if (selfContainedAccount) { @@ -718,6 +724,26 @@ export class CodexRuntimeHomeService { syncLegacySharedCodexConfigForRetainedPanes() } + syncActiveWslSelectionsBeforeRestart(): void { + if (process.platform !== 'win32') { + return + } + + const settings = this.store.getSettings() + for (const [selectedDistroKey, accountId] of Object.entries( + normalizeCodexRuntimeSelection(settings).wsl + )) { + if (!accountId) { + continue + } + const account = this.getActiveAccount(settings.codexManagedAccounts, accountId) + if (!account || account.managedHomeRuntime !== 'wsl') { + continue + } + this.safeReadBackActiveWslAccountBeforeRestart(account, selectedDistroKey) + } + } + private getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null { if (process.platform !== 'win32') { return null @@ -764,9 +790,10 @@ export class CodexRuntimeHomeService { prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): CodexRateLimitHomeResolution { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) + const syncedRuntimeHomePath = this.getPreparedWslRateLimitHomePath(wslTarget) return { kind: 'ready', - codexHomePath: this.getPreparedWslRateLimitHomePath(wslTarget) + codexHomePath: syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) } } const selfContainedAccount = this.getSelfContainedManagedHostAccount() @@ -806,7 +833,7 @@ export class CodexRuntimeHomeService { launchEnv?: NodeJS.ProcessEnv ): void { if (target?.runtime === 'wsl') { - this.startLegacyWslAuthDrain(this.resolveWslDefaultTarget(target)) + this.syncWslRuntimeForCurrentSelection(target) return } @@ -853,6 +880,7 @@ export class CodexRuntimeHomeService { // distro-local runtime home, so the host mirror only drops its baseline. this.lastSyncedAccountId = null this.lastWrittenAuthJson = null + this.skipNextReadBackForAccountId = null return } if (normalizeCodexRuntimeSelection(settings).host) { @@ -894,13 +922,71 @@ export class CodexRuntimeHomeService { } } - // Why: re-auth/add-account writes fresh host tokens, invalidating the shared mirror baseline. + // Why: re-auth/add-account write fresh managed tokens, so skip the next read-back to avoid clobbering them with stale runtime tokens. clearLastWrittenAuthJson( accountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host ): void { if (accountId === normalizeCodexRuntimeSelection(this.store.getSettings()).host) { this.lastWrittenAuthJson = null } + this.skipNextReadBackForAccountId = accountId + } + + private readBackRefreshedTokensFromPath( + runtimeAuthPath: string, + options: { + updateLastWrittenAuthJson: boolean + lastWrittenAuthJson?: string | null + setLastWrittenAuthJson?: (contents: string) => void + expectedAccountId?: string + } + ): CodexReadBackResult { + try { + if (!existsSync(runtimeAuthPath)) { + return 'unchanged' + } + + const lastWrittenAuthJson = + options.lastWrittenAuthJson === undefined + ? this.lastWrittenAuthJson + : options.lastWrittenAuthJson + const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8') + if (lastWrittenAuthJson !== null && runtimeContents === lastWrittenAuthJson) { + return 'unchanged' + } + + const match = this.findManagedAccountForRuntimeAuth( + runtimeContents, + options.expectedAccountId + ) + if (match.kind !== 'matched') { + if (match.kind === 'ambiguous') { + console.warn('[codex-runtime-home] Refusing ambiguous Codex auth read-back') + } + return 'rejected' + } + // Why: after restart there's no last-written baseline, so identity alone can't prove runtime auth is newer than managed storage. + if ( + lastWrittenAuthJson === null && + !this.runtimeAuthIsFresher(runtimeContents, match.managedAuthContents) + ) { + return 'rejected' + } + + writeFileAtomically(match.managedAuthPath, runtimeContents, { mode: 0o600 }) + if (options.updateLastWrittenAuthJson) { + if (options.setLastWrittenAuthJson) { + options.setLastWrittenAuthJson(runtimeContents) + } else { + this.lastWrittenAuthJson = runtimeContents + } + } + return 'persisted' + } catch (error) { + // Why: read-back is best-effort; a transient fs error must not block the forward sync — worst case is one more stale-token cycle. + console.warn('[codex-runtime-home] Failed to read back refreshed tokens:', error) + return 'rejected' + } } // Why: which ~/.codex bytes the mirror was seeded from, and whether the system @@ -965,152 +1051,223 @@ export class CodexRuntimeHomeService { } private getPreparedWslRateLimitHomePath(target: CodexAccountSelectionTarget): string | null { - return this.getWslCodexHomePathForSelection(target) - } - - private getWslCodexHomePathForSelection(target: CodexAccountSelectionTarget): string | null { - const settings = this.store.getSettings() - const account = this.getActiveAccount( - settings.codexManagedAccounts, - getSelectedCodexAccountIdForTarget(settings, target) - ) - if (account) { - const managedHome = parseWslUncPath(account.managedHomePath) - const targetDistro = this.resolveWslDefaultTarget(target).wslDistro?.trim() - // Persisted selections can outlive an account's runtime metadata. Never - // hand a host home (or another distro's UNC home) to a WSL launch. - const accountDistro = account.wslDistro?.trim() - const distroMatches = - (!accountDistro || - !targetDistro || - accountDistro.toLowerCase() === targetDistro.toLowerCase()) && - (!managedHome || - !targetDistro || - managedHome.distro.toLowerCase() === targetDistro.toLowerCase()) - // Older persisted fixtures may not carry a UNC spelling, but the - // explicit WSL runtime marker still proves the lane; reject unmarked - // host paths while retaining those legacy WSL records. - if (distroMatches && (account.managedHomeRuntime === 'wsl' || managedHome)) { - return account.managedHomePath + const distro = target.wslDistro?.trim() + if (distro) { + const settings = this.store.getSettings() + const selectedAccountId = getSelectedCodexAccountIdForTarget(settings, target) + if (selectedAccountId === null) { + // Why: the system-default account changes outside Orca, so read its real home directly to avoid a stale cached runtime copy. + return this.getWslSystemCodexHomePath(target) + } + const cachedRuntimeHomePath = this.wslRuntimeHomePathByDistro.get(distro) + if ( + cachedRuntimeHomePath && + this.lastSyncedWslAccountIdByDistro.has(distro) && + this.lastSyncedWslAccountIdByDistro.get(distro) === selectedAccountId + ) { + // Why: RateLimitService resolves provenance twice per poll; stay path-only so it doesn't block main on UNC reads and a wsl.exe probe. + return cachedRuntimeHomePath } } - return this.getWslSystemCodexHomePath(target) + return this.syncWslRuntimeForCurrentSelection(target) } - private startLegacyWslAuthDrain(target: CodexAccountSelectionTarget): Promise { + private syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null { if (process.platform !== 'win32') { - return Promise.resolve() + return null } - const distro = target.wslDistro?.trim() || getDefaultWslDistro() + + const wslTarget = this.resolveWslDefaultTarget(target) + const settings = this.store.getSettings() + const activeAccount = this.getActiveAccount( + settings.codexManagedAccounts, + getSelectedCodexAccountIdForTarget(settings, wslTarget) + ) + const distro = wslTarget.wslDistro?.trim() || activeAccount?.wslDistro || getDefaultWslDistro() if (!distro) { - return Promise.resolve() + return null } - const guestHome = getWslHome(distro) - const parsedGuestHome = guestHome ? parseWslUncPath(guestHome) : null - if (!parsedGuestHome) { - return Promise.resolve() + + const runtimeHomePath = this.getWslRuntimeHomePath(distro) + if (!runtimeHomePath) { + return null } - let legacyPanePresent: boolean - try { - legacyPanePresent = hasRecordedLegacyWslCodexPane(getCodexSelectionLaneKey(target)) - } catch (error) { - console.warn('[codex-wsl-auth-drain] Pane registry unavailable; deferring drain:', error) - return Promise.resolve() + this.wslRuntimeHomePathByDistro.set(distro, runtimeHomePath) + + mkdirSync(runtimeHomePath, { recursive: true }) + this.safeMigrateLegacyWslActiveHomePointer(distro, runtimeHomePath) + this.seedWslRuntimeHome(runtimeHomePath, activeAccount, distro) + + const runtimeAuthPath = join(runtimeHomePath, 'auth.json') + const previousWslAccountId = this.lastSyncedWslAccountIdByDistro.get(distro) ?? null + if (previousWslAccountId) { + if (this.skipNextReadBackForAccountId === previousWslAccountId) { + this.skipNextReadBackForAccountId = null + } else { + const previousWslAccount = this.getActiveAccount( + settings.codexManagedAccounts, + previousWslAccountId + ) + if (previousWslAccount) { + this.readBackRefreshedTokensFromPath(runtimeAuthPath, { + updateLastWrittenAuthJson: true, + lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null, + setLastWrittenAuthJson: (contents) => { + this.lastWrittenWslAuthJsonByDistro.set(distro, contents) + }, + expectedAccountId: previousWslAccount.id + }) + } + } } - return startLegacyWslRuntimeAuthDrain({ - distro, - guestHomeLinuxPath: parsedGuestHome.linuxPath, - legacyPanePresent, - resolveDestination: (runtimeAuthContents) => - this.resolveLegacyWslAuthDestination(distro, runtimeAuthContents) - }) + + const activeAuthPath = activeAccount ? join(activeAccount.managedHomePath, 'auth.json') : null + if (activeAccount && activeAuthPath && existsSync(activeAuthPath)) { + const activeAuth = readFileSync(activeAuthPath, 'utf-8') + this.writeRuntimeAuthAtPath(runtimeAuthPath, activeAuth) + this.lastWrittenWslAuthJsonByDistro.set(distro, activeAuth) + this.lastSyncedWslAccountIdByDistro.set(distro, activeAccount.id) + return runtimeHomePath + } + if (activeAccount && activeAuthPath) { + console.warn( + '[codex-runtime-home] Active WSL managed account is missing auth.json, restoring system default' + ) + this.store.updateSettings({ + activeCodexManagedAccountId: settings.activeCodexManagedAccountId, + activeCodexManagedAccountIdsByRuntime: setSelectedCodexAccountIdForTarget( + normalizeCodexRuntimeSelection(settings), + null, + wslTarget + ) + }) + } + + const systemAuthPath = this.getWslSystemCodexAuthPath({ runtime: 'wsl', wslDistro: distro }) + if (systemAuthPath && existsSync(systemAuthPath)) { + const systemAuth = readFileSync(systemAuthPath, 'utf-8') + const mirroredSystemDefaultAuth = this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null + const runtimeAuth = existsSync(runtimeAuthPath) + ? readFileSync(runtimeAuthPath, 'utf-8') + : null + if ( + runtimeAuth !== null && + runtimeAuth !== systemAuth && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, systemAuth) && + ((mirroredSystemDefaultAuth !== null && systemAuth === mirroredSystemDefaultAuth) || + (mirroredSystemDefaultAuth === null && + this.runtimeAuthIsFresher(runtimeAuth, systemAuth))) + ) { + // Why: WSL baselines are lost on restart, so a same-identity fresher runtime auth is a token refresh; copy it back before mirroring ~/.codex. + this.writeRuntimeAuthAtPath(systemAuthPath, runtimeAuth) + this.lastWrittenWslAuthJsonByDistro.set(distro, runtimeAuth) + this.lastSyncedWslAccountIdByDistro.set(distro, null) + return runtimeHomePath + } + this.writeRuntimeAuthAtPath(runtimeAuthPath, systemAuth) + this.lastWrittenWslAuthJsonByDistro.set(distro, systemAuth) + this.lastSyncedWslAccountIdByDistro.set(distro, null) + return runtimeHomePath + } + + rmSync(runtimeAuthPath, { force: true }) + this.lastWrittenWslAuthJsonByDistro.set(distro, null) + this.lastSyncedWslAccountIdByDistro.set(distro, null) + return runtimeHomePath } - /** Preserve refreshed auth from retained legacy WSL panes before restart. */ - async syncActiveWslSelectionsBeforeRestart(): Promise { - if (process.platform !== 'win32') { + private getWslRuntimeHomePath(distro: string): string | null { + const home = getWslHome(distro) + return home ? this.joinWslPath(home, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS) : null + } + + private safeReadBackActiveWslAccountBeforeRestart( + account: CodexManagedAccount, + selectedDistroKey: string + ): void { + try { + this.readBackActiveWslAccountBeforeRestart(account, selectedDistroKey) + } catch (error) { + console.warn('[codex-runtime-home] Failed to preserve WSL Codex auth before restart:', error) + } + } + + private readBackActiveWslAccountBeforeRestart( + account: CodexManagedAccount, + selectedDistroKey: string + ): void { + const distro = + selectedDistroKey === getWslSelectionKey(null) + ? account.wslDistro?.trim() + : selectedDistroKey.trim() || account.wslDistro?.trim() + if (!distro) { return } - const settings = this.store.getSettings() - const drains: Promise[] = [] - for (const [selectedDistroKey, accountId] of Object.entries( - normalizeCodexRuntimeSelection(settings).wsl - )) { - if (!accountId) { - continue - } - const account = this.getActiveAccount(settings.codexManagedAccounts, accountId) - if (!account || account.managedHomeRuntime !== 'wsl') { - continue - } - const distro = - selectedDistroKey === getWslSelectionKey(null) - ? account.wslDistro?.trim() || null - : selectedDistroKey.trim() || null - if (distro) { - drains.push(this.startLegacyWslAuthDrain({ runtime: 'wsl', wslDistro: distro })) - } + + const runtimeHomePath = this.wslRuntimeHomePathByDistro.get(distro) + if (!runtimeHomePath) { + return } - await Promise.all(drains) + + this.readBackRefreshedTokensFromPath(join(runtimeHomePath, 'auth.json'), { + updateLastWrittenAuthJson: true, + lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null, + setLastWrittenAuthJson: (contents) => { + this.lastWrittenWslAuthJsonByDistro.set(distro, contents) + }, + expectedAccountId: account.id + }) } - private async resolveLegacyWslAuthDestination( - distro: string, - runtimeAuthContents: string - ): Promise { - const accounts = this.store.getSettings().codexManagedAccounts.filter((account) => { - const parsedHome = parseWslUncPath(account.managedHomePath) - return parsedHome?.distro.toLowerCase() === distro.toLowerCase() - }) - const accountHomes = accounts.flatMap((account) => { - const parsedHome = parseWslUncPath(account.managedHomePath) - return parsedHome ? [{ account, linuxPath: parsedHome.linuxPath }] : [] - }) - const systemHome = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) - const parsedSystemHome = systemHome ? parseWslUncPath(systemHome) : null - let reads: WslCodexAuthRead[] + private safeMigrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void { try { - reads = await readWslCodexAuths(distro, [ - ...accountHomes.map(({ linuxPath }) => linuxPath), - ...(parsedSystemHome ? [parsedSystemHome.linuxPath] : []) - ]) - } catch { - reads = accountHomes.map(() => ({ kind: 'unreadable' })) - if (parsedSystemHome) { - reads.push({ kind: 'unreadable' }) - } - } - const authReads = new Map( - accountHomes.map(({ account }, index) => [account.id, reads[index] ?? { kind: 'unreadable' }]) - ) - const match = this.findManagedAccountForRuntimeAuth(runtimeAuthContents, undefined, { - accounts, - authReads - }) - if (match.kind === 'ambiguous') { - return null - } - if (match.kind === 'matched') { - const parsedHome = parseWslUncPath(match.account.managedHomePath) - if (!parsedHome || parsedHome.distro.toLowerCase() !== distro.toLowerCase()) { - return null - } - return { - authContents: match.managedAuthContents, - linuxHomePath: parsedHome.linuxPath - } + this.migrateLegacyWslActiveHomePointer(distro, runtimeHomePath) + } catch (error) { + console.warn('[codex-runtime-home] Failed to migrate legacy WSL active Codex home:', error) } + } - if (!systemHome || !parsedSystemHome) { - return null + private migrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void { + const runtimeWsl = parseWslUncPath(runtimeHomePath) + if (!runtimeWsl?.linuxPath.endsWith('/codex-runtime-home/home')) { + return } - const systemAuth = reads[accountHomes.length] ?? { kind: 'unreadable' } - if (systemAuth.kind !== 'present') { - return null - } - return this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemAuth.contents) - ? { authContents: systemAuth.contents, linuxHomePath: parsedSystemHome.linuxPath } - : null + const activeLinuxPath = runtimeWsl.linuxPath.replace( + /\/codex-runtime-home\/home$/, + '/codex-runtime-home/active/wsl/home' + ) + const nextLinuxPath = `${activeLinuxPath}.next-${process.pid}-${Date.now()}` + const activeLinuxParentPath = this.dirnameLinuxPath(activeLinuxPath) + // Why: login-shell cleanup turns `exit 0` into status 1, so fall through. + execFileSync( + 'wsl.exe', + [ + '-d', + distro, + '--exec', + 'bash', + '-lc', + [ + 'set -e', + `if [ ! -e ${quotePosixShell(activeLinuxPath)} ] && [ ! -L ${quotePosixShell(activeLinuxPath)} ]; then :`, + `elif [ -e ${quotePosixShell(activeLinuxPath)} ] && [ ! -L ${quotePosixShell(activeLinuxPath)} ]; then :`, + 'else', + `mkdir -p ${quotePosixShell(activeLinuxParentPath)}`, + `rm -rf -- ${quotePosixShell(nextLinuxPath)}`, + `ln -s -- ${quotePosixShell(runtimeWsl.linuxPath)} ${quotePosixShell(nextLinuxPath)}`, + `mv -Tf -- ${quotePosixShell(nextLinuxPath)} ${quotePosixShell(activeLinuxPath)}`, + 'fi' + ].join('\n') + ], + // wsl.exe is console-subsystem: without this a GUI-launched Orca flashes + // a conhost and steals foreground for up to the timeout (#10488). + { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000, windowsHide: true } + ) + } + + private dirnameLinuxPath(value: string): string { + const index = value.lastIndexOf('/') + return index > 0 ? value.slice(0, index) : '/' } private joinWslPath(basePath: string, ...segments: string[]): string { @@ -1129,13 +1286,40 @@ export class CodexRuntimeHomeService { return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : target } + private getWslSystemCodexAuthPath(target: CodexAccountSelectionTarget): string | null { + const home = this.getWslSystemCodexHomePath(target) + return home ? this.joinWslPath(home, 'auth.json') : null + } + + private seedWslRuntimeHome( + runtimeHomePath: string, + activeAccount: CodexManagedAccount | null, + distro: string + ): void { + const runtimeConfigPath = join(runtimeHomePath, 'config.toml') + if (existsSync(runtimeConfigPath)) { + return + } + + const candidateHomes = [ + activeAccount?.managedHomePath, + this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) + ].filter((value): value is string => Boolean(value)) + for (const homePath of candidateHomes) { + const configPath = join(homePath, 'config.toml') + if (existsSync(configPath)) { + writeFileAtomically( + runtimeConfigPath, + prepareWslRuntimeSeedConfig(readFileSync(configPath, 'utf-8'), homePath) + ) + return + } + } + } + private findManagedAccountForRuntimeAuth( runtimeAuthContents: string, - expectedAccountId?: string, - options?: { - accounts: readonly CodexManagedAccount[] - authReads: ReadonlyMap - } + expectedAccountId?: string ): CodexReadBackMatch { const matches: { account: CodexManagedAccount @@ -1143,17 +1327,18 @@ export class CodexRuntimeHomeService { managedAuthContents: string }[] = [] let unreadableHomeCouldOwnRuntimeAuth = false - for (const account of options?.accounts ?? this.store.getSettings().codexManagedAccounts) { + for (const account of this.store.getSettings().codexManagedAccounts) { if (expectedAccountId && account.id !== expectedAccountId) { continue } const managedAuthPath = join(account.managedHomePath, 'auth.json') - let managedAuthContents: string - const suppliedRead = options?.authReads.get(account.id) - if (suppliedRead?.kind === 'missing') { + if (!existsSync(managedAuthPath)) { continue } - if (suppliedRead?.kind === 'unreadable') { + let managedAuthContents: string + try { + managedAuthContents = readFileSync(managedAuthPath, 'utf-8') + } catch { // Why: an unreadable home can never be compared, but letting the read // throw abandons the scan for every other account — dropping a refresh // the runtime home holds for one of them. Only its record can rule it @@ -1166,24 +1351,6 @@ export class CodexRuntimeHomeService { } continue } - if (suppliedRead?.kind === 'present') { - managedAuthContents = suppliedRead.contents - } else { - if (!existsSync(managedAuthPath)) { - continue - } - try { - managedAuthContents = readFileSync(managedAuthPath, 'utf-8') - } catch { - if ( - !expectedAccountId && - codexAuthCouldBelongToManagedAccount(runtimeAuthContents, account) - ) { - unreadableHomeCouldOwnRuntimeAuth = true - } - continue - } - } if (codexAuthMatchesManagedAccount(runtimeAuthContents, account, managedAuthContents)) { matches.push({ account, managedAuthPath, managedAuthContents }) } @@ -1205,6 +1372,10 @@ export class CodexRuntimeHomeService { return codexAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemDefaultAuthContents) } + private runtimeAuthIsFresher(runtimeAuthContents: string, managedAuthContents: string): boolean { + return codexAuthIsFresher(runtimeAuthContents, managedAuthContents) + } + private safeMigrateLegacySharedAuth(): void { const settings = this.store.getSettings() try { @@ -1911,6 +2082,15 @@ export class CodexRuntimeHomeService { return true } + private writeRuntimeAuthAtPath(authPath: string, contents: string): void { + if (this.fileContentsEqual(authPath, contents)) { + this.ensureOwnerOnlyMode(authPath) + return + } + mkdirSync(dirname(authPath), { recursive: true }) + writeFileAtomically(authPath, contents, { mode: 0o600 }) + } + /** * `true`/`false` only when the bytes were actually read; `null` when the file * could not be read at all. The old `catch { return false }` reported "these @@ -2217,3 +2397,14 @@ export class CodexRuntimeHomeService { rmSync(this.getSystemDefaultSnapshotPath(), { force: true }) } } + +// Why: Codex reads this config inside WSL, so relative path settings must anchor to the Linux-side home (verbatim copy breaks load, os error 2). +export function prepareWslRuntimeSeedConfig( + configContents: string, + sourceHomePath: string +): string { + return prepareSystemConfigForFreshRuntimeMirror( + configContents, + parseWslUncPath(sourceHomePath)?.linuxPath ?? sourceHomePath + ) +} diff --git a/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts b/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts index 81593c46013..efc7ea3f2b9 100644 --- a/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { createSettings } from './runtime-home-settings-test-fixtures' import { @@ -87,16 +87,18 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"host-system"}\n') expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslManagedHomePath + wslRuntimeHomePath + ) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe( + '{"account":"wsl"}\n' ) - expect(existsSync(join(wslRuntimeHomePath, 'auth.json'))).toBe(false) expect(service.prepareForRateLimitFetch()).toEqual({ kind: 'ready', codexHomePath: getRuntimeCodexHomePath() }) expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toEqual({ kind: 'ready', - codexHomePath: wslManagedHomePath + codexHomePath: wslRuntimeHomePath }) } finally { if (originalPlatform) { @@ -105,7 +107,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('keeps a selected WSL managed home when auth.json is temporarily missing', async () => { + it('clears a selected WSL managed account when auth.json is missing', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -159,11 +161,13 @@ describe('CodexRuntimeHomeService', () => { ) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - managedHomePath + wslRuntimeHomePath ) - expect(store.updateSettings).not.toHaveBeenCalled() - expect(existsSync(join(wslRuntimeHomePath, 'auth.json'))).toBe(false) - expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth) + expect(store.updateSettings).toHaveBeenCalledWith({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + }) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -171,7 +175,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('launches WSL system default against its existing config without a runtime seed', async () => { + it('seeds the WSL runtime config with rewritten paths and no system hook trust', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -210,18 +214,21 @@ describe('CodexRuntimeHomeService', () => { ) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - systemCodexHomePath + wslRuntimeHomePath ) const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml') - expect(existsSync(runtimeConfigPath)).toBe(false) - const systemConfigPath = join(systemCodexHomePath, 'config.toml') - const systemConfig = readFileSync(systemConfigPath, 'utf-8') - expect(systemConfig).toContain('model_instructions_file = "instructions.md"') - expect(systemConfig).toContain('[hooks.state.') + const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8') + expect(runtimeConfig).toContain( + `model_instructions_file = '${join(systemCodexHomePath, 'instructions.md')}'` + ) + expect(runtimeConfig).toContain('[projects."/home/alice/repo"]') + expect(runtimeConfig).not.toContain('[hooks.state.') - writeFileSync(systemConfigPath, `${systemConfig}\n[projects."/tmp/x"]\n`, 'utf-8') + // Why: WSL runtime configs are seeded once; Codex writes trust into them + // afterwards, so a relaunch must not clobber the seeded file. + writeFileSync(runtimeConfigPath, `${runtimeConfig}\n[projects."/tmp/x"]\n`, 'utf-8') service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - expect(readFileSync(systemConfigPath, 'utf-8')).toContain('[projects."/tmp/x"]') + expect(readFileSync(runtimeConfigPath, 'utf-8')).toContain('[projects."/tmp/x"]') } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -229,7 +236,26 @@ describe('CodexRuntimeHomeService', () => { } }) - it('switches WSL accounts by selecting each account home directly', async () => { + it('anchors WSL seed rewrites to the Linux-side home parsed from the UNC source', async () => { + const { prepareWslRuntimeSeedConfig } = await import('./runtime-home-service') + + // Why: real UNC sources cannot back live fs operations in tests, so pin + // the UNC -> Linux-side anchor translation on the extracted seed function. + expect( + prepareWslRuntimeSeedConfig( + 'model_instructions_file = "instructions.md"\n', + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex' + ) + ).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'") + expect( + prepareWslRuntimeSeedConfig( + 'model_instructions_file = "instructions.md"\n', + '\\\\wsl$\\Ubuntu\\home\\alice\\.codex' + ) + ).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'") + }) + + it('switches WSL accounts by rewriting one stable WSL runtime home', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -291,17 +317,16 @@ describe('CodexRuntimeHomeService', () => { 'home' ) - expect(service.prepareForCodexLaunch(target)).toBe(firstManagedHomePath) - expect(existsSync(join(wslRuntimeHomePath, 'auth.json'))).toBe(false) + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(firstAuth) store.updateSettings({ activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-2' } } }) service.syncForCurrentSelection(target) - expect(service.prepareForCodexLaunch(target)).toBe(secondManagedHomePath) - expect(readFileSync(join(firstManagedHomePath, 'auth.json'), 'utf-8')).toBe(firstAuth) - expect(readFileSync(join(secondManagedHomePath, 'auth.json'), 'utf-8')).toBe(secondAuth) + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(secondAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -309,7 +334,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('ignores stale retired runtime auth when launching a managed WSL account', async () => { + it('does not use host auth baseline to accept stale WSL runtime auth', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -317,6 +342,7 @@ describe('CodexRuntimeHomeService', () => { getDefaultWslDistro: () => 'Ubuntu', getWslHome: () => wslHome })) + const hostAuth = createCodexAuthJson('host@example.com', 'acct-host', 'host-token') const wslManagedAuth = createCodexAuthJson( 'wsl@example.com', 'acct-wsl', @@ -329,6 +355,7 @@ describe('CodexRuntimeHomeService', () => { 'runtime-stale', 1_000 ) + const hostManagedHomePath = createManagedAuth(testState.userDataDir, 'host-account', hostAuth) const wslManagedHomePath = createManagedAuth( testState.userDataDir, 'wsl-account', @@ -347,6 +374,17 @@ describe('CodexRuntimeHomeService', () => { const store = createStore( createSettings({ codexManagedAccounts: [ + { + id: 'host-account', + email: 'host@example.com', + managedHomePath: hostManagedHomePath, + providerAccountId: 'acct-host', + workspaceLabel: null, + workspaceAccountId: 'acct-host', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, { id: 'wsl-account', email: 'wsl@example.com', @@ -357,13 +395,14 @@ describe('CodexRuntimeHomeService', () => { providerAccountId: 'acct-wsl', workspaceLabel: null, workspaceAccountId: 'acct-wsl', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 } ], + activeCodexManagedAccountId: 'host-account', activeCodexManagedAccountIdsByRuntime: { - host: null, + host: 'host-account', wsl: { Ubuntu: 'wsl-account' } } }) @@ -374,10 +413,148 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslManagedHomePath + wslRuntimeHomePath ) expect(readFileSync(join(wslManagedHomePath, 'auth.json'), 'utf-8')).toBe(wslManagedAuth) - expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(staleWslRuntimeAuth) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(wslManagedAuth) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('does not clobber fresh WSL tokens after clearLastWrittenAuthJson', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } + const originalAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'original', 1_000) + const staleRuntimeAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'stale', 1_500) + const reauthedAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'reauthed', 2_000) + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) + const managedAuthPath = join(managedHomePath, 'auth.json') + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + const runtimeAuthPath = join(wslRuntimeHomePath, 'auth.json') + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'wsl@example.com', + managedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home', + providerAccountId: 'acct-wsl', + workspaceLabel: null, + workspaceAccountId: 'acct-wsl', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'account-1' } + } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + writeFileSync(runtimeAuthPath, staleRuntimeAuth, 'utf-8') + writeFileSync(managedAuthPath, reauthedAuth, 'utf-8') + + service.clearLastWrittenAuthJson('account-1') + service.syncForCurrentSelection(target) + + expect(readFileSync(managedAuthPath, 'utf-8')).toBe(reauthedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(reauthedAuth) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('reads active WSL token refreshes back before restart using the selected distro', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const managedAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'managed', 1_000) + const refreshedAuth = createCodexAuthJson( + 'wsl@example.com', + 'acct-wsl', + 'runtime-refreshed', + 2_000 + ) + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) + const managedAuthPath = join(managedHomePath, 'auth.json') + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'wsl@example.com', + managedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: null, + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home', + providerAccountId: 'acct-wsl', + workspaceLabel: null, + workspaceAccountId: 'acct-wsl', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'account-1' } + } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + const runtimeAuthPath = join(wslRuntimeHomePath, 'auth.json') + + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') + + service.syncActiveWslSelectionsBeforeRestart() + + expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) diff --git a/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts b/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts index fe91d223a40..05cfa12e240 100644 --- a/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import type * as WslPaths from '../../shared/wsl-paths' import { createSettings } from './runtime-home-settings-test-fixtures' @@ -34,7 +34,62 @@ describe('CodexRuntimeHomeService', () => { teardownRuntimeHomeTest() }) - it('skips WSL session bridging when system default already uses its direct home', async () => { + it('builds a valid WSL legacy active-home migration shell command', async () => { + const execFileSyncMock = vi.fn() + vi.doMock('node:child_process', () => ({ execFileSync: execFileSyncMock })) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService( + createStore(createSettings()) as never + ) as unknown as { + migrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void + } + + service.migrateLegacyWslActiveHomePointer( + 'Ubuntu', + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home' + ) + + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + const firstCall = execFileSyncMock.mock.calls[0] + expect(firstCall).toBeDefined() + const [command, args] = firstCall as [string, string[]] + expect(command).toBe('wsl.exe') + expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--exec', 'bash', '-lc']) + expect(args).toHaveLength(6) + + const shellCommand = args[5] + expect(shellCommand).toContain( + "if [ ! -e '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ] && [ ! -L '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ]; then :" + ) + expect(shellCommand).toContain( + "elif [ -e '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ] && [ ! -L '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ]; then :" + ) + expect(shellCommand).toContain( + "mkdir -p '/home/alice/.local/share/orca/codex-runtime-home/active/wsl'" + ) + expect(shellCommand).toContain( + "ln -s -- '/home/alice/.local/share/orca/codex-runtime-home/home' '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home.next-" + ) + expect(shellCommand).toContain( + "mv -Tf -- '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home.next-" + ) + expect(shellCommand).toContain( + "' '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home'" + ) + expect(shellCommand).not.toContain('[! -L') + expect(shellCommand).not.toContain('mv -Tf--') + expect(shellCommand).not.toContain('$1') + expect(shellCommand).not.toContain('$2') + expect(shellCommand).not.toContain('$3') + expect(shellCommand).not.toContain('exit 0') + } finally { + vi.doUnmock('node:child_process') + } + }) + + it('starts WSL session bridging after materializing the WSL launch home', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve()) @@ -69,13 +124,17 @@ describe('CodexRuntimeHomeService', () => { ) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslSystemHomePath + wslRuntimeHomePath ) - expect(startWslCodexSessionBridgeInBackground).not.toHaveBeenCalled() - expect(readFileSync(join(wslSystemHomePath, 'AGENTS.md'), 'utf-8')).toBe( - '# WSL instructions\n' - ) - expect(existsSync(join(wslRuntimeHomePath, 'AGENTS.md'))).toBe(false) + expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1) + expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ + distro: 'Ubuntu', + systemCodexHomePath: wslSystemHomePath, + managedCodexHomePath: wslRuntimeHomePath + }) + const runtimeAgentsPath = join(wslRuntimeHomePath, 'AGENTS.md') + expect(readFileSync(runtimeAgentsPath, 'utf-8')).toBe('# WSL instructions\n') + expect(lstatSync(runtimeAgentsPath).isSymbolicLink()).toBe(false) } finally { vi.doUnmock('../codex/wsl-codex-session-bridge') vi.doUnmock('../wsl') @@ -85,7 +144,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('keeps WSL in-Codex setting changes in the direct system home', async () => { + it('promotes WSL in-Codex setting changes on the next Codex launch', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) vi.doMock('../codex/wsl-codex-session-bridge', () => ({ @@ -118,20 +177,32 @@ describe('CodexRuntimeHomeService', () => { 'home' ) + // First launch seeds the runtime config and records the per-distro baseline. expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - join(wslHome, '.codex') + wslRuntimeHomePath ) const baselinePath = join(wslRuntimeHomePath, '.orca-config-settings-baseline.json') - expect(existsSync(baselinePath)).toBe(false) + expect(existsSync(baselinePath)).toBe(true) + // A direct WSL Codex edit wins and is mirrored into Orca's runtime before + // the baseline advances, so later in-Orca changes remain promotable. + const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml') writeFileSync(wslSystemConfigPath, 'model = "outside-edit"\n', 'utf-8') service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - expect(readFileSync(wslSystemConfigPath, 'utf-8')).toBe('model = "outside-edit"\n') + expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('model = "outside-edit"\n') + expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"outside-edit\\""') + + // Codex now persists a /model change inside Orca's reconciled runtime. + writeFileSync( + runtimeConfigPath, + readFileSync(runtimeConfigPath, 'utf-8').replace('model = "outside-edit"', 'model = "o4"'), + 'utf-8' + ) - writeFileSync(wslSystemConfigPath, 'model = "o4"\n', 'utf-8') service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) expect(readFileSync(wslSystemConfigPath, 'utf-8')).toBe('model = "o4"\n') - expect(existsSync(baselinePath)).toBe(false) + // Baseline advances so the promoted value is not re-promoted forever. + expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"o4\\""') } finally { vi.doUnmock('../codex/wsl-codex-session-bridge') vi.doUnmock('../wsl') @@ -165,13 +236,22 @@ describe('CodexRuntimeHomeService', () => { try { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1) expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ distro: 'Ubuntu', systemCodexHomePath: '/home/me/.config/codex', - managedCodexHomePath: join(wslHome, '.codex') + managedCodexHomePath: wslRuntimeHomePath }) } finally { vi.doUnmock('../codex/wsl-codex-session-bridge') @@ -182,7 +262,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('starts WSL session bridging for the selected direct account home', async () => { + it('starts WSL session bridging for the distro used by the materialized runtime home', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve()) @@ -190,6 +270,14 @@ describe('CodexRuntimeHomeService', () => { startWslCodexSessionBridgeInBackground })) const wslHome = join(testState.userDataDir, 'debian-wsl-home') + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) vi.doMock('../wsl', () => ({ getDefaultWslDistro: () => null, getWslHome: (distro: string) => (distro === 'Debian' ? wslHome : null) @@ -199,10 +287,10 @@ describe('CodexRuntimeHomeService', () => { return { ...actual, parseWslUncPath: (candidate: string) => - candidate.includes('codex-accounts/debian-account/home') + candidate === wslRuntimeHomePath ? { distro: 'Debian', - linuxPath: '/home/alice/.local/share/orca/codex-accounts/debian-account/home' + linuxPath: '/home/alice/.local/share/orca/codex-runtime-home/home' } : null } @@ -240,12 +328,12 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: null })).toBe( - managedHomePath + wslRuntimeHomePath ) expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ distro: 'Debian', systemCodexHomePath: join(wslHome, '.codex'), - managedCodexHomePath: managedHomePath + managedCodexHomePath: wslRuntimeHomePath }) } finally { vi.doUnmock('../codex/wsl-codex-session-bridge') diff --git a/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts b/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts index 8e8965f6a40..a1ad86a0a5f 100644 --- a/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts @@ -53,6 +53,15 @@ describe('CodexRuntimeHomeService', () => { try { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) + const syncWslRuntime = vi.spyOn( + service as unknown as { + syncWslRuntimeForCurrentSelection: (target: { + runtime: 'wsl' + wslDistro?: string | null + }) => string | null + }, + 'syncWslRuntimeForCurrentSelection' + ) const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } const expectedHome = join(wslHome, '.codex') @@ -64,6 +73,7 @@ describe('CodexRuntimeHomeService', () => { kind: 'ready', codexHomePath: expectedHome }) + expect(syncWslRuntime).not.toHaveBeenCalled() } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -83,6 +93,15 @@ describe('CodexRuntimeHomeService', () => { const debianAuth = createCodexAuthJson('debian@example.com', 'acct-debian', 'debian-token') const ubuntuHomePath = createManagedAuth(testState.userDataDir, 'ubuntu-account', ubuntuAuth) const debianHomePath = createManagedAuth(testState.userDataDir, 'debian-account', debianAuth) + const runtimeAuthPath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home', + 'auth.json' + ) const store = createStore( createSettings({ codexManagedAccounts: [ @@ -128,9 +147,9 @@ describe('CodexRuntimeHomeService', () => { expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: null })).toEqual({ kind: 'ready', - codexHomePath: ubuntuHomePath + codexHomePath: join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home') }) - expect(readFileSync(join(ubuntuHomePath, 'auth.json'), 'utf-8')).toBe(ubuntuAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(ubuntuAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -209,7 +228,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('keeps WSL system-default token refreshes in its direct home', async () => { + it('reads WSL system-default token refreshes back to WSL system auth', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -238,11 +257,21 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - expect(service.prepareForCodexLaunch(target)).toBe(systemCodexHomePath) - writeFileSync(join(systemCodexHomePath, 'auth.json'), refreshedAuth, 'utf-8') + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) - expect(service.prepareForCodexLaunch(target)).toBe(systemCodexHomePath) + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + writeFileSync(join(wslRuntimeHomePath, 'auth.json'), refreshedAuth, 'utf-8') + + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -250,7 +279,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('does not overwrite direct WSL system auth from the retired runtime on restart', async () => { + it('preserves WSL system-default token refreshes after app restart', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -290,8 +319,8 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - expect(service.prepareForCodexLaunch(target)).toBe(systemCodexHomePath) - expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth) + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) } finally { if (originalPlatform) { diff --git a/src/main/codex-accounts/wsl-codex-auth-batch-reader.ts b/src/main/codex-accounts/wsl-codex-auth-batch-reader.ts deleted file mode 100644 index 40870fcd5ef..00000000000 --- a/src/main/codex-accounts/wsl-codex-auth-batch-reader.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { runWslProcess } from '../wsl/wsl-runner' - -export type WslCodexAuthRead = - | { kind: 'missing' | 'unreadable' } - | { kind: 'present'; contents: string } - -export async function readWslCodexAuths( - distro: string, - linuxHomePaths: readonly string[] -): Promise { - if (linuxHomePaths.length === 0) { - return [] - } - const result = await runWslProcess({ - distro, - loginPath: 'none', - script: READ_AUTHS_SCRIPT, - args: linuxHomePaths, - timeoutMs: 5_000, - maxOutputBytes: 2 * 1024 * 1024 - }) - if (result.code !== 0 || result.timedOut) { - return linuxHomePaths.map(() => ({ kind: 'unreadable' })) - } - const rows = result.stdout.split('\n') - return linuxHomePaths.map((_, index) => parseAuthReadRow(rows[index] ?? '')) -} - -export function decodeWslBase64Payload(encoded: string): string | null { - try { - const decoded = Buffer.from(encoded, 'base64') - const canonical = decoded.toString('base64').replace(/=+$/, '') - return canonical === encoded.replace(/=+$/, '') ? decoded.toString('utf8') : null - } catch { - return null - } -} - -function parseAuthReadRow(row: string): WslCodexAuthRead { - if (row === 'missing' || row === 'unreadable') { - return { kind: row } - } - if (!row.startsWith('present:')) { - return { kind: 'unreadable' } - } - const contents = decodeWslBase64Payload(row.slice('present:'.length)) - return contents === null ? { kind: 'unreadable' } : { kind: 'present', contents } -} - -const READ_AUTHS_SCRIPT = ` -set -eu -for home_path in "$@"; do - auth_path="$home_path/auth.json" - if [ ! -f "$auth_path" ]; then - if [ ! -e "$auth_path" ] && [ ! -L "$auth_path" ]; then - printf 'missing\n' - else - printf 'unreadable\n' - fi - continue - fi - if encoded=$(base64 < "$auth_path"); then - printf 'present:' - printf '%s' "$encoded" | tr -d '\n' - printf '\n' - else - printf 'unreadable\n' - fi -done -` diff --git a/src/main/codex/codex-pane-account-registry.ts b/src/main/codex/codex-pane-account-registry.ts index 4a4467bd219..449c11ba711 100644 --- a/src/main/codex/codex-pane-account-registry.ts +++ b/src/main/codex/codex-pane-account-registry.ts @@ -266,15 +266,6 @@ export function hasRecordedLegacySharedCodexPane(): boolean { ) } -/** True when a retained WSL pane may still read the retired per-distro runtime home. */ -export function hasRecordedLegacyWslCodexPane(selectionKey: string): boolean { - return Object.values(readRegistryOrThrow().panes).some( - (record) => - (record.selectionKey === selectionKey || record.selectionKey === 'wsl:__default__') && - (record.homeRoute === undefined || record.homeRoute === 'wsl-home') - ) -} - /** True when startup may need to repair hooks for a retained managed host pane. */ export function hasRecordedManagedHostCodexPane(): boolean { return Object.values(readRegistry().panes).some( diff --git a/src/main/codex/codex-pane-launch-account.test.ts b/src/main/codex/codex-pane-launch-account.test.ts index c862099e3ac..456b9bc08be 100644 --- a/src/main/codex/codex-pane-launch-account.test.ts +++ b/src/main/codex/codex-pane-launch-account.test.ts @@ -202,7 +202,7 @@ describe('resolveCodexPaneLaunchAccount', () => { ).toEqual({ selectionKey: 'wsl:Ubuntu', accountId: 'wsl-account', - homeRoute: 'account-home' + homeRoute: 'wsl-home' }) }) diff --git a/src/main/codex/codex-pane-launch-account.ts b/src/main/codex/codex-pane-launch-account.ts index 089c1975db3..b99700da081 100644 --- a/src/main/codex/codex-pane-launch-account.ts +++ b/src/main/codex/codex-pane-launch-account.ts @@ -1,6 +1,5 @@ import type { GlobalSettings } from '../../shared/global-settings-types' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' -import { parseWslUncPath } from '../../shared/wsl-paths' import { getCodexSelectionLaneKey, getCodexSelectionTargetForAccount, @@ -81,6 +80,9 @@ function resolveCodexPaneHomeRoute(args: { settings: CodexPaneLaunchAccountSettings target: CodexAccountSelectionTarget }): CodexPaneHomeRoute { + if (args.target.runtime === 'wsl') { + return 'wsl-home' + } if ( !args.launchCodexHomePath || normalizeRuntimePathForComparison(args.launchCodexHomePath) === @@ -91,19 +93,10 @@ function resolveCodexPaneHomeRoute(args: { const launchHome = normalizeRuntimePathForComparison(args.launchCodexHomePath) const accountOwnsHome = args.settings.codexManagedAccounts?.some( (account) => - getCodexSelectionLaneKey(getCodexSelectionTargetForAccount(account)) === - getCodexSelectionLaneKey(args.target) && + getCodexSelectionTargetForAccount(account).runtime === 'host' && normalizeRuntimePathForComparison(account.managedHomePath) === launchHome ) - if (accountOwnsHome) { - return 'account-home' - } - if (args.target.runtime === 'wsl') { - return parseWslUncPath(args.launchCodexHomePath)?.linuxPath.endsWith('/.codex') - ? 'real-home' - : 'wsl-home' - } - return 'shared-home' + return accountOwnsHome ? 'account-home' : 'shared-home' } /** undefined when no account owns the home; null means the system-default account. */ diff --git a/src/main/codex/codex-stale-pane-accounts.test.ts b/src/main/codex/codex-stale-pane-accounts.test.ts index c5deb104543..bf2f7e5b5ea 100644 --- a/src/main/codex/codex-stale-pane-accounts.test.ts +++ b/src/main/codex/codex-stale-pane-accounts.test.ts @@ -8,7 +8,6 @@ import { forgetCodexPaneAccount, getCodexPaneAccount, hasRecordedLegacySharedCodexPane, - hasRecordedLegacyWslCodexPane, hasRecordedManagedHostCodexPane, isCodexPaneHomeRouteProvenAwayFromSharedHome, reconcileCodexPaneAccountsWithLivePtys, @@ -139,36 +138,6 @@ describe('codex pane account registry', () => { expect(hasRecordedLegacySharedCodexPane()).toBe(true) }) - it('identifies only legacy runtime-home panes on the requested WSL lane', () => { - recordCodexPaneAccount('pty-legacy', { - selectionKey: 'wsl:Ubuntu', - accountId: 'account-old', - homeRoute: 'wsl-home' - }) - recordCodexPaneAccount('pty-direct', { - selectionKey: 'wsl:Ubuntu', - accountId: 'account-new', - homeRoute: 'account-home' - }) - recordCodexPaneAccount('pty-other-distro', { - selectionKey: 'wsl:Debian', - accountId: 'account-debian', - homeRoute: 'wsl-home' - }) - recordCodexPaneAccount('pty-default', { - selectionKey: 'wsl:__default__', - accountId: null, - homeRoute: 'wsl-home' - }) - - expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(true) - forgetCodexPaneAccount('pty-legacy') - expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(true) - forgetCodexPaneAccount('pty-default') - expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(false) - expect(hasRecordedLegacyWslCodexPane('wsl:Debian')).toBe(true) - }) - it('requests startup inventory only for managed host panes', () => { recordCodexPaneAccount('pty-real', { selectionKey: 'host', diff --git a/src/main/pty/codex-home-wsl-env.ts b/src/main/pty/codex-home-wsl-env.ts index 6667e3fd608..f61272de8c3 100644 --- a/src/main/pty/codex-home-wsl-env.ts +++ b/src/main/pty/codex-home-wsl-env.ts @@ -1,4 +1,6 @@ -/** Guest-relative layout of Orca's retired WSL CODEX_HOME, retained for migration reads. */ +/** Guest-relative layout of Orca's managed WSL CODEX_HOME. Must stay in sync + * with getWslRuntimeHomePath (codex-accounts/runtime-home-service.ts), which + * builds the UNC twin of this path. */ export const WSL_CODEX_RUNTIME_HOME_SEGMENTS = [ '.local', 'share', diff --git a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt index e226814ba84..befefb5a2c8 100644 --- a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt +++ b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt @@ -17,6 +17,7 @@ main/agent-hooks/wsl-hook-relay-launch.ts main/browser/wsl-browser-network-relay-launch.ts main/claude-accounts/claude-command-process.ts +main/codex-accounts/runtime-home-service.ts main/codex-accounts/service.ts main/codex/codex-state-db-backfill-recovery.ts main/codex/codex-trust-grant-host.ts diff --git a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt index dbe9c0c2aa4..03e06eb374f 100644 --- a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt +++ b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt @@ -41,6 +41,7 @@ src/main/browser/browser-cookie-import.ts src/main/browser/browser-route-egress-electron-launch.ts src/main/browser/browser-route-persisted-worker-electron-process.ts src/main/claude-accounts/keychain.ts +src/main/codex-accounts/runtime-home-service.ts src/main/codex-accounts/service.ts src/main/codex/codex-app-server-client.ts src/main/codex/codex-app-server-session.ts From aaef5e8c9fbd201cfb8003f268badfc6b7a1610b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:17:34 -0700 Subject: [PATCH 046/152] fix(agent-hooks): deliver hook events that fire while Orca is restarting (STA-5329) (#16685) * fix(agent-hooks): correct durable spool delivery * fix(agent-hooks): spool curl failures after retries * fix(agent-hooks): keep replay out of runtime observations * test(agent-hooks): pin managed hooks inert outside an Orca terminal * fix(agent-hooks): address review findings on the durable spool - claude: pass the literal source; options.agent does not exist (typecheck) - kimi: the windows-local ordering runs its guard pre-stdin and before the function exists, so it no longer spools there (printed command-not-found) - writer: require a readable endpoint file before creating a spool tree - antigravity: carry its out-of-band event name into the record and filter on it - drain: truncate only the bytes consumed, preserving concurrent appends and a torn trailing line * fix(agent-hooks): ignore spool events without pane attribution * fix(agent-hooks): make spool replay and appends robust * test(agent-hooks): type spool replay records * fix(agent-hooks): defer unterminated spool records * fix(agent-hooks): replay spool events through relays * fix(agent-hooks): preserve Codex prompt across child replay * fix(relay): keep startup alive when spool replay fails * fix(relay): simplify spool replay startup guard --- .../hook-script-outside-orca.test.ts | 82 +++++ src/main/agent-hooks/hook-stdin-contract.ts | 46 +++ .../server-relay-listener-replay.test.ts | 1 + src/main/agent-hooks/server.ts | 71 ++++- src/main/agent-hooks/spool.test.ts | 289 ++++++++++++++++++ src/main/antigravity/hook-script.ts | 5 +- src/main/claude/hook-service.ts | 5 +- src/main/codex/hook-service.ts | 9 +- .../command-code-managed-script.ts | 5 +- src/main/copilot/copilot-managed-script.ts | 9 +- src/main/cursor/hook-service.ts | 5 +- src/main/devin/hook-service.ts | 5 +- src/main/droid/hook-service.ts | 5 +- src/main/gemini/hook-service.ts | 5 +- src/main/grok/grok-hook-script.ts | 9 +- src/main/kimi/hook-service.ts | 22 +- src/relay/agent-hook-server.test.ts | 75 ++++- src/relay/agent-hook-server.ts | 40 ++- .../providers/codex-state.ts | 7 + src/shared/agent-hook-spool.ts | 172 +++++++++++ 20 files changed, 844 insertions(+), 23 deletions(-) create mode 100644 src/main/agent-hooks/hook-script-outside-orca.test.ts create mode 100644 src/main/agent-hooks/spool.test.ts create mode 100644 src/shared/agent-hook-spool.ts diff --git a/src/main/agent-hooks/hook-script-outside-orca.test.ts b/src/main/agent-hooks/hook-script-outside-orca.test.ts new file mode 100644 index 00000000000..6e9a27f6acc --- /dev/null +++ b/src/main/agent-hooks/hook-script-outside-orca.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { spawnSync } from 'node:child_process' +import { chmodSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { _internals as codexInternals } from '../codex/hook-service' +import { buildPosixHookSpoolLines } from './hook-stdin-contract' + +/** Managed hooks are installed into the user's agent config, so they also run when the + * agent is launched from a plain terminal. There they must be inert and silent. */ +function runHook(dir: string, extraEnv: NodeJS.ProcessEnv = {}) { + const script = join(dir, 'codex-hook.sh') + writeFileSync(script, codexInternals.getManagedScript('posix')) + chmodSync(script, 0o755) + const clean: NodeJS.ProcessEnv = {} + for (const [k, v] of Object.entries(process.env)) { + if (!k.startsWith('ORCA_')) { + clean[k] = v + } + } + return spawnSync('/bin/sh', [script], { + input: '{"hook_event_name":"SubagentStop","agent_id":"child"}\n', + env: { ...clean, ...extraEnv }, + timeout: 5000, + encoding: 'utf8' + }) +} + +describe('managed hook outside an Orca terminal', () => { + it('no Orca env at all: silent, exit 0, writes nothing', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-outside-')) + const res = runHook(dir) + expect(res.status).toBe(0) + expect(res.stdout).toBe('') + expect(res.stderr).toBe('') + expect(readdirSync(dir)).toEqual(['codex-hook.sh']) + }) + + it('pane key present but no endpoint: still silent and writes nothing', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-outside-partial-')) + const res = runHook(dir, { ORCA_PANE_KEY: 'tab:0', ORCA_TAB_ID: 'tab' }) + expect(res.status).toBe(0) + expect(res.stdout).toBe('') + expect(res.stderr).toBe('') + expect(readdirSync(dir)).toEqual(['codex-hook.sh']) + }) + + it('endpoint points at a path that does not exist: silent, exit 0', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-outside-stale-')) + const res = runHook(dir, { + ORCA_AGENT_HOOK_ENDPOINT: join(dir, 'gone', 'deeper', 'endpoint.env'), + ORCA_PANE_KEY: 'tab:0' + }) + expect(res.status).toBe(0) + expect(res.stdout).toBe('') + expect(res.stderr).toBe('') + // a stale env var must not create a spool tree for an Orca that is not installed here + expect(readdirSync(dir)).toEqual(['codex-hook.sh']) + }) + + it('readable endpoint without a pane key: silent, writes nothing', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-outside-readable-')) + const endpoint = join(dir, 'endpoint.env') + writeFileSync(endpoint, 'ORCA_AGENT_HOOK_PORT=9\nORCA_AGENT_HOOK_TOKEN=stale\n') + const res = runHook(dir, { ORCA_AGENT_HOOK_ENDPOINT: endpoint }) + expect(res.status).toBe(0) + expect(res.stdout).toBe('') + expect(res.stderr).toBe('') + expect(readdirSync(dir).sort()).toEqual(['codex-hook.sh', 'endpoint.env']) + }) +}) + +describe('antigravity out-of-band event name', () => { + it('records hookEventName and filters tool progress on it', () => { + const lines = buildPosixHookSpoolLines('antigravity', 'ORCA_ANTIGRAVITY_EVENT').join('\n') + expect(lines).toContain('"hookEventName":"%s"') + expect(lines).toContain('${ORCA_ANTIGRAVITY_EVENT:-}') + expect(lines).toContain('in PreToolUse|PostToolUse|PostToolUseFailure) return 0') + // payload-based filtering stays the default for every other provider + expect(buildPosixHookSpoolLines('codex').join('\n')).toContain('case "$payload" in') + }) +}) diff --git a/src/main/agent-hooks/hook-stdin-contract.ts b/src/main/agent-hooks/hook-stdin-contract.ts index fb7b3d85487..acba7927650 100644 --- a/src/main/agent-hooks/hook-stdin-contract.ts +++ b/src/main/agent-hooks/hook-stdin-contract.ts @@ -22,6 +22,52 @@ export function buildPosixHookPayloadCapture( ] } +/** Shell-side durable fallback shared by every POSIX managed hook. + * `eventNameVar` is for providers that send the event name out-of-band rather than in the + * payload JSON; without it both the progress filter and replay would miss the event name. */ +export function buildPosixHookSpoolLines(source: string, eventNameVar?: string): string[] { + // Why: the event name must be a printf ARG, not inlined in the single-quoted format, + // where a command substitution would be emitted literally. + const eventFormat = eventNameVar ? '"hookEventName":"%s",' : '' + const eventArg = eventNameVar ? ` "$(spool_json_escape "\${${eventNameVar}:-}")"` : '' + const spoolRecordLine = " { printf '\\n{".concat( + eventFormat, + '"paneKey":"%s","tabId":"%s","worktreeId":"%s","env":"%s","version":"%s","launchToken":"%s","source":"%s","receivedAt":%s,"payload":%s}\\n\'', + eventArg, + ' "$(spool_json_escape "${ORCA_PANE_KEY:-}")" "$(spool_json_escape "${ORCA_TAB_ID:-}")" "$(spool_json_escape "${ORCA_WORKTREE_ID:-}")" "$(spool_json_escape "${ORCA_AGENT_HOOK_ENV:-}")" "$(spool_json_escape "${ORCA_AGENT_HOOK_VERSION:-}")" "$(spool_json_escape "${ORCA_AGENT_LAUNCH_TOKEN:-}")" "$(spool_json_escape "', + source, + '")" "$spool_now" "$payload"; } >> "$spool_file" 2>/dev/null || :' + ) + return [ + 'spool_hook_event() {', + eventNameVar + ? ` case "\${${eventNameVar}:-}" in PreToolUse|PostToolUse|PostToolUseFailure) return 0 ;; esac` + : ' case "$payload" in *\'"PreToolUse"\'*|*\'"PostToolUse"\'*|*\'"PostToolUseFailure"\'*) return 0 ;; esac', + ' [ -n "${ORCA_AGENT_HOOK_ENDPOINT:-}" ] || return 0', + // Why: an endpoint can linger in a parent shell after leaving Orca; without a pane key + // the record is un-attributable and would accumulate as pane-unknown.jsonl. + ' [ -n "${ORCA_PANE_KEY:-}" ] || return 0', + // Why: a stale env var must not create a spool tree for an Orca that is not installed here. + ' [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ] || return 0', + ' spool_base=${ORCA_AGENT_HOOK_ENDPOINT%/*}', + ' spool_dir="$spool_base/spool"', + ' mkdir -p "$spool_dir" 2>/dev/null || return 0', + ' chmod 700 "$spool_dir" 2>/dev/null || :', + " spool_id=$(printf %s \"${ORCA_PANE_KEY:-unknown}\" | tail -c 36 | tr '/:' '__')", + ' spool_file="$spool_dir/pane-$spool_id.jsonl"', + ' if [ -f "$spool_file" ] && find "$spool_file" -mtime +7 -print -quit 2>/dev/null | grep -q .; then : > "$spool_file"; fi', + ' [ -f "$spool_file" ] || : > "$spool_file"', + ' spool_size=$(wc -c < "$spool_file" 2>/dev/null || printf 0)', + ' [ "$spool_size" -lt 5242880 ] || return 0', + ' spool_now=$(date +%s 2>/dev/null || printf 0)', + ' spool_now=$((spool_now * 1000))', + ' spool_json_escape() { printf %s "$1" | sed \'s/\\\\/\\\\\\\\/g; s/"/\\\\"/g; s/[[:cntrl:]]/ /g\'; }', + spoolRecordLine, + ' chmod 600 "$spool_file" 2>/dev/null || :', + '}' + ] +} + export const WINDOWS_HOOK_STDIN_DRAIN_LABEL = 'orca_agent_hook_drain_stdin' // Why: qualify the stdin reader because Windows searches the worktree for // executables before PATH and hook payloads must not reach repo-local code. diff --git a/src/main/agent-hooks/server-relay-listener-replay.test.ts b/src/main/agent-hooks/server-relay-listener-replay.test.ts index 6c96d9c3bb8..0bd9b4b29ef 100644 --- a/src/main/agent-hooks/server-relay-listener-replay.test.ts +++ b/src/main/agent-hooks/server-relay-listener-replay.test.ts @@ -63,6 +63,7 @@ describe('AgentHookServer listener replay', () => { send(restartedRelay, { hook_event_name: 'SubagentStop', agent_id: 'child-a' }) expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', + prompt: 'coordinate reviewers', model: 'gpt-5.4', providerSession: { key: 'session_id', id: 'root-session' }, subagents: [expect.objectContaining({ id: 'child-b' })] diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index e447be40262..ba29d3bc211 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -116,6 +116,12 @@ import { type AgentProviderSessionMetadata } from '../../shared/agent-session-resume' import { isCommandCodeNewTurnWhileWorking } from '../../shared/command-code-turn-boundary' +import { + buildSpoolHookBody, + drainAgentHookSpool, + launchTokenHash, + type SpoolRecord +} from '../../shared/agent-hook-spool' export type { AgentHookSource } @@ -801,6 +807,44 @@ export class AgentHookServer { } } + /** Replay is durable evidence from a prior runtime, not a live observation. */ + private withdrawReplayObservation(paneKey: string): void { + if (this.runtimeObservedStatusPaneKeys.delete(paneKey)) { + this.notifyStatusChangeListeners() + } + } + + private ingestSpoolRecord(record: SpoolRecord): void { + if (!isAgentHookSource(record.source)) { + return + } + const body = this.normalizeHookBodyPaneKeyAlias(buildSpoolHookBody(record)) + const normalized = this.normalizeLocalHookPayload(record.source, body) + if (!normalized.event) { + return + } + const replay = { ...normalized.event, isReplay: true as const } + const statusDisposition = this.getAgentStatusDisposition(replay.paneKey, { + source: record.source, + hookEventName: replay.hookEventName, + isReplay: true, + hasExplicitPrompt: replay.hasExplicitPrompt, + launchToken: replay.launchToken + }) + if (statusDisposition === 'suppress') { + return + } + const event = statusDisposition === 'restart' ? { ...replay, launchToken: undefined } : replay + if (statusDisposition === 'restart') { + this.observations.rebind(event.paneKey) + } + this.recordCurrentAuthorityObservation(event) + this.applyNormalizedStatus(event, normalized.onAccepted) + if (event.payload.state !== 'done') { + this.withdrawReplayObservation(this.resolvePaneKeyAlias(event.paneKey)) + } + } + setPaneStatusClearListener(listener: PaneStatusClearListener | null): void { this.onPaneStatusCleared = listener } @@ -2259,14 +2303,14 @@ export class AgentHookServer { claudeRunningNonAgentTask?: unknown payload: unknown }, - connectionId: string + connectionId: string | null ): void { // Why: wire crosses a trust boundary — re-check/trim so an empty connectionId can't poison caches. - if (typeof connectionId !== 'string') { + if (connectionId !== null && typeof connectionId !== 'string') { return } - const trimmedConnectionId = connectionId.trim() - if (trimmedConnectionId.length === 0) { + const trimmedConnectionId = connectionId?.trim() ?? null + if (trimmedConnectionId !== null && trimmedConnectionId.length === 0) { return } if (!envelope || typeof envelope.paneKey !== 'string') { @@ -2286,6 +2330,14 @@ export class AgentHookServer { if (!parsedPaneKey) { return } + // Why: fence relay spool replay at main so stale generations cannot overwrite hydrated state. + if (envelope.isReplay === true) { + const expectedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(paneKey) + const actualLaunchTokenHash = launchTokenHash(envelope.launchToken) + if (expectedLaunchTokenHash && actualLaunchTokenHash !== expectedLaunchTokenHash) { + return + } + } if (envelope.tabId !== undefined && typeof envelope.tabId !== 'string') { return } @@ -2515,6 +2567,15 @@ export class AgentHookServer { this.hydrateLastStatusFromDisk() } this.captureHydratedAuthorityCommitments() + // Drain before binding the listener so replay cannot race a live hook during startup. + if (this.endpointDir) { + drainAgentHookSpool({ + endpointDir: this.endpointDir, + getPersistedLaunchTokenHash: (paneKey) => + this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), + ingest: (record: SpoolRecord) => this.ingestSpoolRecord(record) + }) + } const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise => { if (req.method !== 'POST') { res.writeHead(404) @@ -3309,6 +3370,8 @@ export class AgentHookServer { restoredUnconfirmed: _restoredUnconfirmed, // Why: same — the sequencer that issued it dies with the process (see PersistedAgentHookEventPayload). observation: _observation, + // Replay provenance is runtime-only and must not survive another restart. + isReplay: _isReplay, launchToken, ...persistedPayload } = enrichedPayload diff --git a/src/main/agent-hooks/spool.test.ts b/src/main/agent-hooks/spool.test.ts new file mode 100644 index 00000000000..3e460fdc3ba --- /dev/null +++ b/src/main/agent-hooks/spool.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, it } from 'vitest' +import { + appendFileSync, + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + statSync, + writeFileSync +} from 'node:fs' +import { execFileSync } from 'node:child_process' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + AGENT_HOOK_SPOOL_MAX_FILES, + drainAgentHookSpool, + launchTokenHash, + readSpoolRecords, + type SpoolRecord +} from '../../shared/agent-hook-spool' +import { AgentHookServer, _internals } from './server' +import { buildBody } from './server.test-fixtures' +import { _internals as codexInternals } from '../codex/hook-service' +import { makePaneKey } from '../../shared/stable-pane-id' +import { buildPosixHookSpoolLines } from './hook-stdin-contract' + +describe('agent hook spool', () => { + it('appends each record with one printf write to prevent concurrent field interleaving', () => { + const spoolLine = buildPosixHookSpoolLines('codex').find((line) => + line.includes('>> "$spool_file"') + ) + expect(spoolLine).toBeDefined() + expect(spoolLine!.match(/printf/g)).toHaveLength(1) + expect(spoolLine).toContain('"$spool_now" "$payload"') + }) + + it('drops torn lines while retaining complete records', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-spool-')) + const file = join(dir, 'pane.jsonl') + writeFileSync( + file, + '\n{"paneKey":"tab:1","source":"codex","receivedAt":1,"payload":{}}\n{"paneKey":' + ) + expect(readSpoolRecords(file, 1)).toHaveLength(1) + }) + + it('waits for a newline before replaying a complete-looking final record', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-spool-unterminated-')) + const spool = join(dir, 'spool') + mkdirSync(spool) + const file = join(spool, 'pane-live.jsonl') + const record = JSON.stringify({ + paneKey: 'tab:live', + source: 'codex', + receivedAt: Date.now(), + payload: { state: 'done' } + }) + writeFileSync(file, record) + const ingested: SpoolRecord[] = [] + const options = { + endpointDir: dir, + getPersistedLaunchTokenHash: () => undefined, + ingest: (value: SpoolRecord) => ingested.push(value) + } + expect(readSpoolRecords(file)).toHaveLength(0) + expect(drainAgentHookSpool(options)).toBe(0) + expect(readFileSync(file, 'utf8')).toBe(record) + + appendFileSync(file, '\n') + expect(drainAgentHookSpool(options)).toBe(1) + expect(ingested).toHaveLength(1) + expect(readFileSync(file)).toHaveLength(0) + }) + + it('does not let historical empty pane files starve newer records', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-spool-empty-files-')) + const spool = join(dir, 'spool') + mkdirSync(spool) + for (let index = 0; index < AGENT_HOOK_SPOOL_MAX_FILES; index += 1) { + writeFileSync(join(spool, `pane-empty-${index}.jsonl`), '') + } + const live = join(spool, 'pane-live.jsonl') + writeFileSync( + live, + `\n${JSON.stringify({ paneKey: 'tab:live', source: 'codex', receivedAt: Date.now(), payload: { state: 'done' } })}\n` + ) + const ingested: SpoolRecord[] = [] + drainAgentHookSpool({ + endpointDir: dir, + getPersistedLaunchTokenHash: () => undefined, + ingest: (record) => ingested.push(record) + }) + expect(ingested).toHaveLength(1) + expect(ingested[0]?.paneKey).toBe('tab:live') + }) + + it('rejects stale launch tokens before ingest and truncates in place', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-spool-')) + const spool = join(dir, 'spool') + mkdirSync(spool) + const file = join(spool, 'pane-1.jsonl') + writeFileSync( + file, + `\n${JSON.stringify({ paneKey: 'tab:1', source: 'codex', launchToken: 'old', receivedAt: Date.now(), payload: { state: 'done' } })}\n` + ) + const inode = statSync(file).ino + const ingested: unknown[] = [] + drainAgentHookSpool({ + endpointDir: dir, + getPersistedLaunchTokenHash: () => launchTokenHash('new')!, + ingest: (record) => ingested.push(record) + }) + expect(ingested).toHaveLength(0) + expect(readFileSync(file)).toHaveLength(0) + expect(statSync(file).ino).toBe(inode) + }) + + it('ingests a record with the matching launch token', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-spool-')) + const spool = join(dir, 'spool') + mkdirSync(spool) + const file = join(spool, 'pane-1.jsonl') + writeFileSync( + file, + `\n${JSON.stringify({ paneKey: 'tab:1', source: 'codex', launchToken: 'same', receivedAt: Date.now(), payload: { state: 'done' } })}\n` + ) + const ingested: unknown[] = [] + drainAgentHookSpool({ + endpointDir: dir, + getPersistedLaunchTokenHash: () => launchTokenHash('same')!, + ingest: (record) => ingested.push(record) + }) + expect(ingested).toHaveLength(1) + expect(ingested[0]).toMatchObject({ paneKey: 'tab:1', isReplay: true }) + }) + + it('replays a spooled Codex SubagentStop through the server after restart', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-spool-e2e-')) + const paneKey = makePaneKey('tab-spool', '00000000-0000-4000-8000-000000000001') + const launchToken = 'generation-token' + const first = new AgentHookServer() + await first.start({ env: 'production', userDataPath }) + const started = _internals.normalizeHookPayload( + 'codex', + buildBody({ hook_event_name: 'SubagentStart', agent_id: 'child-spooled' }), + 'production' + )! + first.ingestRemote( + { + paneKey, + source: 'codex', + hookEventName: 'SubagentStart', + launchToken, + payload: started.payload + }, + 'spool-test' + ) + expect(first.getStatusSnapshot()).toHaveLength(1) + first.flushStatusPersistSync() + first.stop() + const spoolDir = join(userDataPath, 'agent-hooks', 'spool') + mkdirSync(spoolDir, { recursive: true }) + writeFileSync( + join(spoolDir, 'pane-tab-spooled_0.jsonl'), + `\n${JSON.stringify({ paneKey, source: 'codex', hookEventName: 'SubagentStop', launchToken, receivedAt: Date.now(), payload: { hook_event_name: 'SubagentStop', agent_id: 'child-spooled' } })}\n` + ) + const restarted = new AgentHookServer() + await restarted.start({ env: 'production', userDataPath }) + try { + const snapshot = restarted.getStatusSnapshot() + expect(snapshot).toHaveLength(1) + expect(snapshot[0]!.subagents).toBeUndefined() + restarted.flushStatusPersistSync() + expect(readFileSync(restarted.lastStatusPath!, 'utf8')).not.toContain('isReplay') + } finally { + restarted.stop() + } + }) + + it('rejects a stale remote replay against the hydrated launch-token fence', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-spool-remote-fence-')) + const paneKey = makePaneKey('tab-remote-fence', '00000000-0000-4000-8000-000000000003') + const first = new AgentHookServer() + const second = new AgentHookServer() + try { + await first.start({ env: 'production', userDataPath }) + first.ingestRemote( + { + paneKey, + source: 'codex', + launchToken: 'old-generation', + payload: { state: 'working', agentType: 'codex', prompt: 'old' } + }, + 'ssh-1' + ) + first.flushStatusPersistSync() + first.stop() + + await second.start({ env: 'production', userDataPath }) + second.ingestRemote( + { + paneKey, + source: 'codex', + launchToken: 'new-generation', + isReplay: true, + payload: { state: 'done', agentType: 'codex', prompt: 'stale completion' } + }, + 'ssh-2' + ) + expect(second.getStatusSnapshot()[0]).toMatchObject({ + paneKey, + state: 'working', + prompt: 'old' + }) + } finally { + first.stop() + second.stop() + } + }) + + it('spools when the endpoint is present but the receiver is unavailable', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-spool-failure-')) + const endpointDir = join(dir, 'agent-hooks') + mkdirSync(endpointDir, { recursive: true }) + const endpoint = join(endpointDir, 'endpoint.env') + writeFileSync( + endpoint, + 'ORCA_AGENT_HOOK_PORT=9\nORCA_AGENT_HOOK_TOKEN=stale\nORCA_AGENT_HOOK_ENV=production\nORCA_AGENT_HOOK_VERSION=1\n' + ) + const script = join(dir, 'codex-hook.sh') + writeFileSync(script, codexInternals.getManagedScript('posix')) + chmodSync(script, 0o755) + execFileSync('/bin/sh', [script], { + input: '{"hook_event_name":"SubagentStop","agent_id":"child"}\n', + env: { + ...process.env, + ORCA_AGENT_HOOK_ENDPOINT: endpoint, + ORCA_PANE_KEY: 'tab-failure:0', + ORCA_TAB_ID: 'tab-failure', + ORCA_AGENT_LAUNCH_TOKEN: 'generation-token' + }, + timeout: 5000 + }) + const spoolFiles = readdirSync(join(endpointDir, 'spool')) + expect(spoolFiles).toHaveLength(1) + expect(readFileSync(join(endpointDir, 'spool', spoolFiles[0]!), 'utf8')).toContain( + 'SubagentStop' + ) + }) + + it('does not mark a non-terminal downtime replay as runtime-observed', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-spool-observed-')) + const paneKey = makePaneKey('tab-observed', '00000000-0000-4000-8000-000000000002') + const launchToken = 'observed-generation' + const first = new AgentHookServer() + await first.start({ env: 'production', userDataPath }) + const started = _internals.normalizeHookPayload( + 'codex', + buildBody({ hook_event_name: 'SubagentStart', agent_id: 'child-observed' }), + 'production' + )! + first.ingestRemote( + { + paneKey, + source: 'codex', + hookEventName: 'SubagentStart', + launchToken, + payload: started.payload + }, + null + ) + first.flushStatusPersistSync() + first.stop() + const spoolDir = join(userDataPath, 'agent-hooks', 'spool') + mkdirSync(spoolDir, { recursive: true }) + writeFileSync( + join(spoolDir, 'pane-observed.jsonl'), + `\n${JSON.stringify({ paneKey, source: 'codex', hookEventName: 'SubagentStart', launchToken, receivedAt: Date.now(), payload: started.payload })}\n` + ) + const restarted = new AgentHookServer() + await restarted.start({ env: 'production', userDataPath }) + try { + expect(restarted.getStatusChangeSnapshot()[0]?.observedInCurrentRuntime).toBe(false) + } finally { + restarted.stop() + } + }) +}) diff --git a/src/main/antigravity/hook-script.ts b/src/main/antigravity/hook-script.ts index 0645416c005..67f1f639ef9 100644 --- a/src/main/antigravity/hook-script.ts +++ b/src/main/antigravity/hook-script.ts @@ -1,5 +1,6 @@ import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue, WINDOWS_HOOK_STDIN_DRAIN_COMMAND @@ -55,10 +56,12 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string { // Why: some Antigravity events arrive without stdin but still need a // status post, so the shared capture maps empty input to an object. ...buildPosixHookPayloadCapture('empty-object'), + ...buildPosixHookSpoolLines('antigravity', 'ORCA_ANTIGRAVITY_EVENT'), 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Timeout caps best-effort hook posts if the local listener stalls. @@ -76,7 +79,7 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', ' --data-urlencode "hook_event_name=${ORCA_ANTIGRAVITY_EVENT}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 4de81077ab2..3dba1121a20 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -17,6 +17,7 @@ import { import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue, WINDOWS_HOOK_STDIN_DRAIN_LABEL @@ -96,6 +97,7 @@ function getManagedScript( // Why: Claude-compatible permission hooks fail closed on empty stdout (#14818). 'printf "{}\\n"', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('claude'), ...(options.skipWhenDevinImportsClaude ? [ // Why: Devin imports .claude hooks by default; skip Orca's managed hook there so status posts stay attributed to Devin. @@ -115,6 +117,7 @@ function getManagedScript( ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Why: post form fields because path-bearing payloads are unsafe in hand-built JSON. @@ -129,7 +132,7 @@ function getManagedScript( ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 6dc78fdabf1..a25f14bcd89 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -31,6 +31,7 @@ import { } from '../agent-hooks/installer-utils-remote' import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue, POSIX_HOOK_STDIN_DRAIN_COMMAND @@ -818,6 +819,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '#!/bin/sh', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('codex'), // Why: sourcing refreshes PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps reporting after a restart (see claude/hook-service.ts). 'load_hook_endpoint() {', ' endpoint_path="$1"', @@ -844,6 +846,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' load_hook_endpoint "$ORCA_AGENT_HOOK_ENDPOINT"', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', 'post_codex_hook() {', @@ -875,9 +878,13 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { 'if is_wsl_runtime; then', ' windows_curl=$(command -v curl.exe 2>/dev/null || true)', ' if [ -n "$windows_curl" ] && [ -x "$windows_curl" ]; then', - ' post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1 || true', + ' if post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1; then', + ' exit 0', + ' fi', + ' # post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1 || true', ' fi', 'fi', + 'spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/command-code/command-code-managed-script.ts b/src/main/command-code/command-code-managed-script.ts index ca45f2fa579..59ea79b9d3a 100644 --- a/src/main/command-code/command-code-managed-script.ts +++ b/src/main/command-code/command-code-managed-script.ts @@ -1,6 +1,7 @@ import { buildWindowsAgentHookPostCommand } from '../agent-hooks/installer-utils' import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' @@ -36,6 +37,7 @@ export function buildCommandCodeManagedScript( return [ '#!/bin/sh', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('command-code'), '__orca_read_ancestor_var() {', ' __orca_name="$1"', ' __orca_pid="${PPID:-}"', @@ -119,6 +121,7 @@ export function buildCommandCodeManagedScript( ' done', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Timeout caps best-effort hook posts if the local listener stalls. @@ -135,7 +138,7 @@ export function buildCommandCodeManagedScript( ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/copilot/copilot-managed-script.ts b/src/main/copilot/copilot-managed-script.ts index 83b40e51d1b..492caafc045 100644 --- a/src/main/copilot/copilot-managed-script.ts +++ b/src/main/copilot/copilot-managed-script.ts @@ -1,5 +1,8 @@ import { getSharedManagedScriptPath } from '../agent-hooks/installer-utils' -import { buildPosixHookPayloadCapture } from '../agent-hooks/hook-stdin-contract' +import { + buildPosixHookPayloadCapture, + buildPosixHookSpoolLines +} from '../agent-hooks/hook-stdin-contract' export function getManagedScriptFileName(): string { return process.platform === 'win32' ? 'copilot-hook.ps1' : 'copilot-hook.sh' @@ -53,12 +56,14 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string { '#!/bin/sh', "printf '{}\\n'", ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('copilot'), // Why: Copilot consumes stdout for some hooks, so stdout is emitted before // endpoint refresh, stdin parsing, or the network POST can fail. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline @@ -75,7 +80,7 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "hookEventName=${ORCA_COPILOT_HOOK_EVENT}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/cursor/hook-service.ts b/src/main/cursor/hook-service.ts index ab9d07b25eb..310afad939c 100644 --- a/src/main/cursor/hook-service.ts +++ b/src/main/cursor/hook-service.ts @@ -23,6 +23,7 @@ import { } from '../agent-hooks/installer-utils-remote' import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' @@ -76,11 +77,13 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '#!/bin/sh', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('cursor'), // Why: refresh endpoint coordinates so surviving PTYs keep reporting. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Why: post form fields because path-bearing worktree IDs are unsafe in hand-built JSON. @@ -95,7 +98,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/devin/hook-service.ts b/src/main/devin/hook-service.ts index 6c485b0daf5..a5e3667551e 100644 --- a/src/main/devin/hook-service.ts +++ b/src/main/devin/hook-service.ts @@ -13,6 +13,7 @@ import { } from '../agent-hooks/installer-utils-remote' import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' @@ -55,12 +56,14 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '#!/bin/sh', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('devin'), // Why: endpoint file holds the live port/token; PTYs that outlive an Orca restart carry stale env, so source it to reach the new server (else PTY env). // Why: silence the `.` builtin (2>/dev/null + `|| :`) so a TOCTOU race or CRLF-mangled line can't leak shell parse errors into agent transcripts (fail-open). 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Why: worktreeId embeds a filesystem path, so hand-building JSON in shell is unsafe (quotes/newlines); post as form fields instead. @@ -75,7 +78,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/droid/hook-service.ts b/src/main/droid/hook-service.ts index fdef56a2401..3bdb715427f 100644 --- a/src/main/droid/hook-service.ts +++ b/src/main/droid/hook-service.ts @@ -24,6 +24,7 @@ import { } from '../agent-hooks/installer-utils-remote' import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' @@ -94,10 +95,12 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '#!/bin/sh', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('droid'), 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Timeout caps best-effort hook posts if the local listener stalls. @@ -114,7 +117,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/gemini/hook-service.ts b/src/main/gemini/hook-service.ts index 72ee056bd5f..6fd529e96e4 100644 --- a/src/main/gemini/hook-service.ts +++ b/src/main/gemini/hook-service.ts @@ -24,6 +24,7 @@ import { } from '../agent-hooks/installer-utils-remote' import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' @@ -72,11 +73,13 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { // Why: emit `{}` first so Gemini never stalls parsing stdout, even if the guards below exit early. 'printf "{}\\n"', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('gemini'), // Why: source refreshes endpoint coords so a PTY surviving an Orca restart keeps reporting. See claude/hook-service.ts. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Why: worktreeId embeds a path, so post form fields, not hand-built JSON that breaks on quotes/newlines. @@ -91,7 +94,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/grok/grok-hook-script.ts b/src/main/grok/grok-hook-script.ts index cf4c57580b9..997bf7e64d4 100644 --- a/src/main/grok/grok-hook-script.ts +++ b/src/main/grok/grok-hook-script.ts @@ -3,7 +3,10 @@ import { wrapPosixHookCommand, wrapWindowsCmdHookCommand } from '../agent-hooks/installer-utils' -import { buildPosixHookPayloadCapture } from '../agent-hooks/hook-stdin-contract' +import { + buildPosixHookPayloadCapture, + buildPosixHookSpoolLines +} from '../agent-hooks/hook-stdin-contract' import { buildWindowsGrokHookScript, GROK_HOME_ENVELOPE_MAX_LENGTH @@ -33,10 +36,12 @@ export function getGrokManagedScript(target: 'local' | 'posix' = 'local'): strin return [ '#!/bin/sh', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('grok'), 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', 'grok_home=', @@ -54,7 +59,7 @@ export function getGrokManagedScript(target: 'local' | 'posix' = 'local'): strin ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', ' --data-urlencode "grokHome=${grok_home}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/kimi/hook-service.ts b/src/main/kimi/hook-service.ts index a7b954f01cb..e397fa0b026 100644 --- a/src/main/kimi/hook-service.ts +++ b/src/main/kimi/hook-service.ts @@ -24,7 +24,10 @@ import { writeManagedScriptRemote, writeTextFileRemoteAtomic } from '../agent-hooks/installer-utils-remote' -import { buildPosixHookPayloadCapture } from '../agent-hooks/hook-stdin-contract' +import { + buildPosixHookPayloadCapture, + buildPosixHookSpoolLines +} from '../agent-hooks/hook-stdin-contract' import { applyManagedKimiHooks, KIMI_HOOK_EVENTS, @@ -71,14 +74,25 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + // Why: the windows-local ordering runs this guard before stdin is read and before + // spool_hook_event is defined, so only the payload-first ordering may spool here. + ...(windowsLocal ? [] : [' spool_hook_event']), ' exit 0', 'fi' ] return [ '#!/bin/sh', ...(windowsLocal - ? [...endpointRefreshAndGuard, ...buildPosixHookPayloadCapture()] - : [...buildPosixHookPayloadCapture(), ...endpointRefreshAndGuard]), + ? [ + ...endpointRefreshAndGuard, + ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('kimi') + ] + : [ + ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('kimi'), + ...endpointRefreshAndGuard + ]), // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX // shell is not safe once a path contains quotes or newlines. Post the raw // hook payload plus metadata as form fields and let the receiver parse it. @@ -95,7 +109,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index 0e3771aff55..e247706b5dc 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { RelayAgentHookServer } from './agent-hook-server' @@ -82,6 +82,79 @@ describe('RelayAgentHookServer', () => { } }) + it('normalizes and forwards raw spooled hooks on startup', async () => { + const spoolDir = join(dir, 'spool') + const spoolFile = join(spoolDir, 'pane-codex.jsonl') + mkdirSync(spoolDir) + writeFileSync( + spoolFile, + `${JSON.stringify({ + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt-1', + env: 'remote', + version: '1', + launchToken: 'generation-token', + hookEventName: 'SubagentStop', + source: 'codex', + payload: { hook_event_name: 'SubagentStop', agent_id: 'child-spooled' }, + receivedAt: Date.now() + })}\n` + ) + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + + await server.start() + try { + expect(forward).toHaveBeenCalledTimes(1) + expect(forward.mock.calls[0][0]).toMatchObject({ + source: 'codex', + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt-1', + launchToken: 'generation-token', + hookEventName: 'SubagentStop', + isReplay: true, + env: 'remote', + version: '1', + payload: { state: 'working', agentType: 'codex' } + }) + expect(readFileSync(spoolFile)).toHaveLength(0) + } finally { + server.stop() + } + }) + + it('keeps the relay listening when spool replay forwarding fails', async () => { + const spoolDir = join(dir, 'spool') + const spoolFile = join(spoolDir, 'pane-codex.jsonl') + mkdirSync(spoolDir) + writeFileSync( + spoolFile, + `${JSON.stringify({ + paneKey: PANE_KEY, + source: 'codex', + hookEventName: 'SubagentStop', + payload: { hook_event_name: 'SubagentStop', agent_id: 'child-spooled' }, + receivedAt: Date.now() + })}\n` + ) + const server = new RelayAgentHookServer({ + endpointDir: dir, + forward: () => { + throw new Error('receiver unavailable') + } + }) + + try { + await expect(server.start()).resolves.toBeUndefined() + expect(server.getCoordinates().port).toBeGreaterThan(0) + expect(readFileSync(spoolFile, 'utf8')).not.toBe('') + } finally { + server.stop() + } + }) + it('caches before forwarding, schedules retries after forwarding, and responds last', async () => { const order: string[] = [] let server!: RelayAgentHookServer diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 84474cbab00..ec980470dd4 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -24,10 +24,16 @@ import { isHookRequestTruncatedError } from '../shared/agent-hook-transport-interference' import { + isAgentHookSource, REMOTE_AGENT_HOOK_ENV, type AgentHookRelayEnvelope, type AgentHookSource } from '../shared/agent-hook-relay' +import { + buildSpoolHookBody, + drainAgentHookSpool, + type SpoolRecord +} from '../shared/agent-hook-spool' import { buildRelayHookPtyEnv, defaultEndpointDir } from './agent-hook-endpoint-coordinates' import { buildRelayHookEnvelope, hookBodyEnv, hookBodyVersion } from './agent-hook-envelope-build' import { AgentHookResultRetryScheduler } from './agent-hook-result-retry-scheduler' @@ -100,6 +106,19 @@ export class RelayAgentHookServer { this.token = this.fixedToken ?? randomUUID() this.endpointFileWritten = false this.portFallbackApplied = false + try { + drainAgentHookSpool({ + endpointDir: this.endpointDir, + getPersistedLaunchTokenHash: () => undefined, + ingest: (record) => this.ingestSpoolRecord(record) + }) + } catch (err) { + // Why: a downstream relay failure must not prevent the loopback listener from starting; + // the untruncated spool file remains available for retry on the next restart. + process.stderr.write( + `[relay-hook-server] spool replay failed: ${err instanceof Error ? err.message : String(err)}\n` + ) + } try { await this.listenOn(this.preferredPort) } catch (err) { @@ -273,7 +292,8 @@ export class RelayAgentHookServer { event: AgentHookEventPayload, source: AgentHookSource, env?: string, - version?: string + version?: string, + options: { isReplay?: boolean } = {} ): void { if (event.payload.state !== 'done' || event.payload.lastAssistantMessage) { this.retryScheduler.clearAssistantMessageRetry(event.paneKey) @@ -294,6 +314,22 @@ export class RelayAgentHookServer { } this.clearPaneState(oldest) } - this.forward(buildRelayHookEnvelope(event, source, env, version)) + this.forward(buildRelayHookEnvelope(event, source, env, version, options)) + } + + private ingestSpoolRecord(record: SpoolRecord): void { + if (!isAgentHookSource(record.source)) { + return + } + const body = buildSpoolHookBody(record) + const event = normalizeHookPayload(this.state, record.source, body, this.env, { + deferCompactOwnershipToClient: true + }) + if (!event) { + return + } + this.applyEvent(event, record.source, hookBodyEnv(body), hookBodyVersion(body), { + isReplay: true + }) } } diff --git a/src/shared/agent-hook-listener/providers/codex-state.ts b/src/shared/agent-hook-listener/providers/codex-state.ts index c14c1d8a2b1..1131008ca7a 100644 --- a/src/shared/agent-hook-listener/providers/codex-state.ts +++ b/src/shared/agent-hook-listener/providers/codex-state.ts @@ -137,8 +137,15 @@ export function reconcileRemoteCodexState( if (!lead) { return payload } + // Child lifecycle hooks commonly omit the root prompt. Preserve the last known + // turn label while merging their roster/state so relay restarts do not blank it. + const prompt = + agentId && payload.prompt.length === 0 && previous?.agentType === 'codex' + ? previous.prompt + : payload.prompt return { ...payload, + prompt, state: codexRosterEffectiveState(roster, lead.state), model: lead.model ?? payload.model, subagents: codexRosterToSnapshots(roster) diff --git a/src/shared/agent-hook-spool.ts b/src/shared/agent-hook-spool.ts new file mode 100644 index 00000000000..3ad478ed8a5 --- /dev/null +++ b/src/shared/agent-hook-spool.ts @@ -0,0 +1,172 @@ +import { createHash } from 'node:crypto' +import { + closeSync, + fstatSync, + ftruncateSync, + openSync, + readFileSync, + readSync, + readdirSync, + statSync, + writeSync +} from 'node:fs' +import { join } from 'node:path' + +export const AGENT_HOOK_SPOOL_MAX_BYTES = 5 * 1024 * 1024 +export const AGENT_HOOK_SPOOL_MAX_FILES = 1024 +export const AGENT_HOOK_SPOOL_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 + +export type SpoolRecord = { + paneKey: string + tabId?: string + worktreeId?: string + env?: string + version?: string + launchToken?: string + hookEventName?: string + source: string + payload: unknown + receivedAt: number + [key: string]: unknown +} + +/** Restore the HTTP listener body shape from a shell-written spool record. */ +export function buildSpoolHookBody(record: SpoolRecord): Record { + return { + paneKey: record.paneKey, + tabId: record.tabId, + worktreeId: record.worktreeId, + env: record.env, + version: record.version, + launchToken: record.launchToken, + hookEventName: record.hookEventName, + payload: record.payload + } +} + +export function launchTokenHash(token: string | undefined): string | null { + return token?.trim() ? createHash('sha256').update(token.trim()).digest('hex') : null +} + +export function readSpoolRecords(path: string, now = Date.now()): SpoolRecord[] { + return readSpoolFile(path, now).records +} + +/** Records plus the byte offset through the last COMPLETE line. A torn trailing line is + * left unconsumed so a writer still finishing it is not truncated away. */ +export function readSpoolFile( + path: string, + now = Date.now() +): { records: SpoolRecord[]; consumed: number } { + let bytes: Buffer + try { + bytes = readFileSync(path) + } catch { + return { records: [], consumed: 0 } + } + const records: SpoolRecord[] = [] + let consumed = 0 + let start = 0 + for (let end = 0; end <= bytes.length; end += 1) { + if (end !== bytes.length && bytes[end] !== 0x0a) { + continue + } + // A final line without its newline may still be in flight from a hook writer. + // Leave it untouched until the writer terminates the record explicitly. + if (end === bytes.length && (end === 0 || bytes[end - 1] !== 0x0a)) { + break + } + const lineBytes = bytes.subarray(start, end) + if (end !== bytes.length) { + consumed = end + 1 + } + start = end + 1 + if (lineBytes.length === 0) { + continue + } + try { + const value = JSON.parse(lineBytes.toString('utf8')) as Partial + if ( + typeof value.paneKey === 'string' && + typeof value.source === 'string' && + value.payload !== undefined && + typeof value.receivedAt === 'number' && + value.receivedAt >= now - AGENT_HOOK_SPOOL_MAX_AGE_MS + ) { + records.push(value as SpoolRecord) + } + } catch { + // Torn lines are discarded while later complete lines remain replayable. + } + } + return { records, consumed } +} + +export type SpoolDrainOptions = { + endpointDir: string + getPersistedLaunchTokenHash: (paneKey: string) => string | undefined + ingest: (record: SpoolRecord) => void + now?: number +} + +/** Drain JSONL files in place; never replace or unlink an inode held by a hook writer. */ +export function drainAgentHookSpool(options: SpoolDrainOptions): number { + const spoolDir = join(options.endpointDir, 'spool') + let names: string[] + try { + names = readdirSync(spoolDir) + } catch { + return 0 + } + const now = options.now ?? Date.now() + const candidates = names + .map((name) => { + const path = join(spoolDir, name) + try { + const stat = statSync(path) + // Empty files are retained to keep append handles race-safe, but they must not + // consume the bounded replay candidate set ahead of files with durable events. + return stat.isFile() && stat.size > 0 ? { path, mtimeMs: stat.mtimeMs } : null + } catch { + return null + } + }) + .filter((entry): entry is { path: string; mtimeMs: number } => entry !== null) + .sort((a, b) => a.mtimeMs - b.mtimeMs) + .slice(0, AGENT_HOOK_SPOOL_MAX_FILES) + let drained = 0 + for (const candidate of candidates) { + const { records, consumed } = readSpoolFile(candidate.path, now) + for (const record of records) { + const expected = options.getPersistedLaunchTokenHash(record.paneKey) + const actual = launchTokenHash(record.launchToken) + if (expected && actual !== expected) { + continue + } + options.ingest({ ...record, isReplay: true }) + drained += 1 + } + try { + const fd = openSync(candidate.path, 'r+') + try { + // Keep the inode so a concurrent append handle cannot silently orphan writes. + const size = fstatSync(fd).size + if (size > consumed) { + // Why: a hook may have appended between the read and here; shift the unread tail + // to the front instead of truncating to zero, which would erase it. + const tail = Buffer.alloc(size - consumed) + readSync(fd, tail, 0, tail.length, consumed) + writeSync(fd, tail, 0, tail.length, 0) + ftruncateSync(fd, tail.length) + } else { + ftruncateSync(fd, 0) + } + } finally { + closeSync(fd) + } + } catch { + // A concurrently removed or inaccessible file is harmless; the next launch retries it. + } + } + return drained +} From 8d0156ae0b225fe0d5e489c5eac1fd8063efb950 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:30:30 -0700 Subject: [PATCH 047/152] fix(terminal): route Enter to focused split pane (#16705) * fix(terminal): route shortcuts to focused split pane * fix(terminal): synchronize IME fallback pane --- ...keyboard-handlers-ime-enter-keyup.test.tsx | 51 +++++++++- ...inal-keyboard-event-handlers-focus.test.ts | 94 +++++++++++++++++++ .../terminal-keyboard-event-handlers.ts | 14 ++- .../terminal-keyboard-pane-resolution.test.ts | 64 +++++++++++++ .../terminal-keyboard-pane-resolution.ts | 28 ++++++ .../terminal-keyboard-release-handlers.ts | 3 +- 6 files changed, 244 insertions(+), 10 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers-focus.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.ts diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx b/src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx index 2d4f179c753..88396a6be87 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx @@ -28,9 +28,10 @@ function keyboardEvent( return event } -function createHarness(): { +function createHarness(options: { staleActivePane?: boolean } = {}): { deps: KeyboardHandlersDeps sendInput: ReturnType + setActivePane: ReturnType startComposition: () => void terminalInput: HTMLTextAreaElement dispose: () => void @@ -40,7 +41,8 @@ function createHarness(): { const terminalInput = document.createElement('textarea') terminalInput.className = 'xterm-helper-textarea' terminalElement.append(terminalInput) - scope.append(terminalElement) + const staleTerminalElement = document.createElement('div') + scope.append(staleTerminalElement, terminalElement) document.body.append(scope) const sendInput = vi.fn(() => true) @@ -49,7 +51,7 @@ function createHarness(): { sendInput } as unknown as PtyTransport const pane = { - id: 1, + id: options.staleActivePane ? 2 : 1, leafId: '00000000-0000-4000-8000-000000000001', terminal: { element: terminalElement, @@ -57,9 +59,24 @@ function createHarness(): { getSelection: vi.fn(() => '') } } + const stalePane = { + id: 1, + leafId: '00000000-0000-4000-8000-000000000002', + terminal: { + element: staleTerminalElement, + focus: vi.fn(), + getSelection: vi.fn(() => '') + } + } + let activePane = options.staleActivePane ? stalePane : pane + const panes = options.staleActivePane ? [stalePane, pane] : [pane] + const setActivePane = vi.fn((paneId: number) => { + activePane = panes.find((candidate) => candidate.id === paneId) ?? activePane + }) const manager = { - getActivePane: () => pane, - getPanes: () => [pane] + getActivePane: () => activePane, + getPanes: () => panes, + setActivePane } as unknown as PaneManager const route = installTerminalImeCompositionRoute({ terminalElement, @@ -96,6 +113,7 @@ function createHarness(): { return { deps, sendInput, + setActivePane, terminalInput, startComposition: () => { terminalElement.dispatchEvent( @@ -337,6 +355,29 @@ describe('Windows IME Enter-keyup press-time evidence', () => { harness.dispose() }) + it('routes a swallowed Enter keydown fallback to the focused pane and repairs stale active state', () => { + const harness = createHarness({ staleActivePane: true }) + const hook = renderHook(() => useTerminalKeyboardShortcuts(harness.deps)) + harness.startComposition() + + harness.terminalInput.dispatchEvent( + keyboardEvent('keyup', { + key: 'Enter', + code: 'Enter', + keyCode: 13, + timeStamp: 30, + shiftKey: true + }) + ) + vi.runAllTimers() + + expect(harness.setActivePane).toHaveBeenCalledWith(2, { focus: false }) + expect(harness.sendInput).toHaveBeenCalledOnce() + expect(harness.sendInput).toHaveBeenCalledWith('\x1b\r') + hook.unmount() + harness.dispose() + }) + it('does not send a second newline from the keyup of a directly-sent Shift+Enter', () => { const harness = createHarness() const hook = renderHook(() => useTerminalKeyboardShortcuts(harness.deps)) diff --git a/src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers-focus.test.ts b/src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers-focus.test.ts new file mode 100644 index 00000000000..bc1e883647b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers-focus.test.ts @@ -0,0 +1,94 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' +import { createTerminalKeyboardEventHandlers } from './terminal-keyboard-event-handlers' + +describe('terminal keyboard pane ownership', () => { + it('submits Enter through the helper textarea pane when active state is stale', () => { + const scope = document.createElement('div') + const firstElement = document.createElement('div') + const focusedElement = document.createElement('div') + const focusedInput = document.createElement('textarea') + focusedInput.className = 'xterm-helper-textarea' + focusedElement.appendChild(focusedInput) + scope.append(firstElement, focusedElement) + document.body.appendChild(scope) + + const first = { id: 1, leafId: 'leaf-1', terminal: { element: firstElement } } + const focused = { id: 2, leafId: 'leaf-2', terminal: { element: focusedElement } } + let active = first + const setActivePane = vi.fn((paneId: number) => { + active = paneId === focused.id ? focused : first + }) + const sendFocused = vi.fn() + const handlers = createTerminalKeyboardEventHandlers({ + isMac: false, + isWindows: false, + shortcutPlatform: 'linux', + keyboardScopeRef: { current: scope }, + resolveShortcutEvent: () => ({ type: 'sendInput', data: '\r' }), + createCapturedInputSender: (pane) => (pane.id === focused.id ? sendFocused : vi.fn()), + nativeOnlyShortcutTracker: { + prepareKeyDown: vi.fn(), + armKeyDown: vi.fn() + }, + observedEnterKeydownTimeStamps: new Map(), + modifiedEnterChordOwner: { + ownsRedispatchedEnter: () => false, + absorb: () => false, + claim: () => true + }, + deferredNewlineSender: { + absorbRedispatchedEnter: () => false, + defer: vi.fn() + }, + deferredChordSender: { defer: vi.fn() }, + getModifiedEnterChord: () => null, + reconcileHeldImeEnterModifiers: vi.fn(), + optionKittyReleases: { arm: vi.fn(), armNativeDeadKey: vi.fn() }, + terminalImeEnterModifierKeydowns: new Set(), + paneKittyKeyboardModesRef: { current: new Map() }, + managerRef: { + current: { + getActivePane: () => active, + getPanes: () => [first, focused], + setActivePane + } + }, + paneTransportsRef: { current: new Map() }, + panePtyBindingsRef: { current: new Map() }, + paneCwdRef: { current: new Map() }, + tabId: 'tab-1', + worktreeId: 'worktree-1', + fallbackCwd: '', + expandedPaneIdRef: { current: null }, + setExpandedPane: vi.fn(), + restoreExpandedLayout: vi.fn(), + refreshPaneSizes: vi.fn(), + persistLayoutSnapshot: vi.fn(), + toggleExpandPane: vi.fn(), + setSearchOpen: vi.fn(), + onSearchSelectedText: vi.fn(), + onRequestClosePane: vi.fn(), + onClearPaneScrollback: vi.fn(), + onSetTitle: vi.fn(), + onClearPaneTitle: vi.fn(), + searchOpenRef: { current: false }, + searchStateRef: { current: { query: '', caseSensitive: false, regex: false } }, + keybindings: undefined, + terminalShortcutPolicy: 'orca-first', + getKeyboardSplitTelemetrySource: () => 'keyboard' + } as never) + + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: 'Enter' + }) + Object.defineProperty(event, 'keyCode', { value: 13 }) + focusedInput.dispatchEvent(event) + handlers.onKeyDown(event) + + expect(setActivePane).toHaveBeenCalledWith(2, { focus: false }) + expect(sendFocused).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers.ts index c711ff747a4..a66fbf96fe4 100644 --- a/src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers.ts @@ -18,6 +18,7 @@ import { import { dispatchTerminalShortcutAction } from './terminal-keyboard-action-dispatch' import { getLayoutCharacterForCode } from '@/lib/keyboard-layout/layout-base-character' import { createTerminalKeyboardReleaseHandlers } from './terminal-keyboard-release-handlers' +import { synchronizeTerminalKeyboardPane } from './terminal-keyboard-pane-resolution' const MAX_OBSERVED_ENTER_KEYDOWNS_PER_CODE = 8 @@ -111,6 +112,11 @@ export function createTerminalKeyboardEventHandlers(context: EventContext) { return } + // The browser's focused xterm helper is the input owner. A split can retain + // a stale manager activePaneId after focus moved, so repair it before any + // shortcut policy reads pane-scoped host/agent state. + const keyboardPane = synchronizeTerminalKeyboardPane(manager, e.target) + const modifiedEnterChord = isWindows ? getModifiedEnterChord(e) : null if ( e.key === 'Enter' && @@ -127,7 +133,7 @@ export function createTerminalKeyboardEventHandlers(context: EventContext) { return } - const terminalPaneForImeShortcut = manager.getActivePane() ?? manager.getPanes()[0] + const terminalPaneForImeShortcut = keyboardPane const hasPendingImeComposition = hasPendingTerminalImeComposition( terminalPaneForImeShortcut?.terminal.element ) @@ -139,7 +145,7 @@ export function createTerminalKeyboardEventHandlers(context: EventContext) { } if (matchFileSearchShortcut(e, shortcutPlatform, keybindings, terminalShortcutPolicy)) { - const pane = manager.getActivePane() ?? manager.getPanes()[0] + const pane = keyboardPane const selectedText = normalizeSelectedTextForFileSearch(pane?.terminal.getSelection()) if (selectedText) { e.preventDefault() @@ -160,7 +166,7 @@ export function createTerminalKeyboardEventHandlers(context: EventContext) { } e.preventDefault() e.stopImmediatePropagation() - const pane = manager.getActivePane() ?? manager.getPanes()[0] + const pane = keyboardPane if (!pane) { return } @@ -210,7 +216,7 @@ export function createTerminalKeyboardEventHandlers(context: EventContext) { if (action.type === 'sendInput') { e.preventDefault() e.stopImmediatePropagation() - const pane = manager.getActivePane() ?? manager.getPanes()[0] + const pane = keyboardPane if (!pane) { return } diff --git a/src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.test.ts b/src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.test.ts new file mode 100644 index 00000000000..3876f2099e0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.test.ts @@ -0,0 +1,64 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' +import { + resolveTerminalKeyboardPane, + synchronizeTerminalKeyboardPane +} from './terminal-keyboard-pane-resolution' + +function pane(id: number): { + id: number + leafId: string + terminal: { element: HTMLElement } +} { + return { + id, + leafId: `leaf-${id}`, + terminal: { element: document.createElement('div') } + } +} + +describe('resolveTerminalKeyboardPane', () => { + it('uses the pane containing the event target when manager active state is stale', () => { + const active = pane(1) + const focused = pane(2) + const input = document.createElement('textarea') + focused.terminal.element.appendChild(input) + const manager = { + getActivePane: vi.fn(() => active), + getPanes: vi.fn(() => [active, focused]) + } + + expect(resolveTerminalKeyboardPane(manager as never, input)).toBe(focused) + expect(manager.getActivePane).not.toHaveBeenCalled() + }) + + it('falls back to the manager active pane for non-terminal shortcuts', () => { + const active = pane(1) + const manager = { + getActivePane: vi.fn(() => active), + getPanes: vi.fn(() => [active]) + } + + expect(resolveTerminalKeyboardPane(manager as never, document.body)).toBe(active) + }) + + it('repairs stale active state without stealing DOM focus', () => { + const active = pane(1) + const focused = pane(2) + const input = document.createElement('textarea') + focused.terminal.element.appendChild(input) + let activePane = active + const setActivePane = vi.fn((id: number) => { + activePane = id === focused.id ? focused : active + }) + const manager = { + getActivePane: vi.fn(() => activePane), + getPanes: vi.fn(() => [active, focused]), + setActivePane + } + + expect(synchronizeTerminalKeyboardPane(manager as never, input)).toBe(focused) + expect(setActivePane).toHaveBeenCalledWith(focused.id, { focus: false }) + expect(activePane).toBe(focused) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.ts b/src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.ts new file mode 100644 index 00000000000..643b0439741 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.ts @@ -0,0 +1,28 @@ +import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' + +/** Resolve keyboard ownership from the focused xterm surface before using manager state. */ +export function resolveTerminalKeyboardPane( + manager: Pick, + target: EventTarget | null +): ManagedPane | null { + if (typeof Node !== 'undefined' && target instanceof Node) { + const focusedPane = manager + .getPanes() + .find((pane) => pane.terminal.element?.contains(target) === true) + if (focusedPane) { + return focusedPane + } + } + return manager.getActivePane() ?? manager.getPanes()[0] ?? null +} + +export function synchronizeTerminalKeyboardPane( + manager: Pick, + target: EventTarget | null +): ManagedPane | null { + const pane = resolveTerminalKeyboardPane(manager, target) + if (pane && manager.getActivePane()?.id !== pane.id) { + manager.setActivePane(pane.id, { focus: false }) + } + return pane +} diff --git a/src/renderer/src/components/terminal-pane/terminal-keyboard-release-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-keyboard-release-handlers.ts index 2c96347ad00..095c1e33b77 100644 --- a/src/renderer/src/components/terminal-pane/terminal-keyboard-release-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-keyboard-release-handlers.ts @@ -4,6 +4,7 @@ import { isTerminalImeEnterKeyUp } from './terminal-ime-deferred-newline' import { isEditableTarget } from './terminal-keyboard-shortcut-matching' import { hasPendingTerminalImeComposition } from './terminal-ime-composition-route' import { keyboardEventBelongsToScope } from './terminal-keyboard-scope' +import { synchronizeTerminalKeyboardPane } from './terminal-keyboard-pane-resolution' type Runtime = ReturnType type ReleaseContext = KeyboardHandlersDeps & @@ -101,7 +102,7 @@ export function createTerminalKeyboardReleaseHandlers(context: ReleaseContextWit !isEditableTarget(e.target) && (!scope || keyboardEventBelongsToScope(e, scope)) ) { - const pane = manager.getActivePane() ?? manager.getPanes()[0] + const pane = synchronizeTerminalKeyboardPane(manager, e.target) if (pane && hasPendingTerminalImeComposition(pane.terminal.element)) { const action = resolveShortcutEvent({ key: 'Enter', From de6fe8b7eabd220d100e4cc652413461d444d2ba Mon Sep 17 00:00:00 2001 From: Lesley Murfin Date: Thu, 27 Aug 2026 00:35:18 -0600 Subject: [PATCH 048/152] fix(worktrees): resolve id: worktree selectors by path equivalence (#16243) (#16494) * test(worktrees): cover id: selector path-spelling parity with path: (#16243) The renderer can only address a workspace by id (toRuntimeWorktreeSelector always emits id:::), and the runtime matches that id byte for byte while a path: selector has always compared through normalizeRuntimePathForComparison. A stored id that spells its path differently from `git worktree list` therefore resolves for the CLI and answers selector_not_found for the UI, which reads that as a stale local mirror, calls forgetLocal, reports success, and lets the row return on the next catalog refresh: a silent delete. These tests fail on both resolution sites -- the fleet `id:` branch of resolveWorktreeSelector and the scoped resolveScopedWorktreeIdRow a host-qualified removal takes -- and pin what must stay closed: an exact repo id (STA-4343), host qualification, dot segments neither selector canonicalizes, and a refusal rather than a guess when two rows spell one path. 13 failing, 35 passing. * fix(worktrees): resolve id: worktree selectors by path equivalence (#16243) worktreeIdComparisonKey names one repo, one filesystem location, and one folder-workspace instance, folding exactly the path spellings normalizeRuntimePathForComparison already folds for a path: selector -- and nothing more, so dot segments stay unresolved for both shapes. Both id: resolution sites consult it only after an exact match finds nothing: the fleet branch of resolveWorktreeSelector and resolveScopedWorktreeIdRow, which a host-qualified removal takes. runtimeWorktreeIdsEqual now derives from the same key so the runtime has one normalizer rather than a parallel one. Not a pure refactor at that last site: runtimeWorktreeIdsEqual used to normalize-compare ids that parse but carry an empty repoId or an empty path ('::/p', or 'repo::' against 'repo::/'), and worktreeIdComparisonKey returns null for those, so across its call sites (PTY identity, refresh, mutation queue) such ids now compare byte-exact instead. That narrows matching rather than widening it, no real worktree carries such an id, and it is the behavior #15616 guarantees for malformed ids -- but it is a behavior delta, not just a tidy-up. Perf (#14399): the exact match is still tried first and still wins outright, so a resolvable id costs exactly what it did before. Neither site adds a scan -- the fleet branch re-filters the array it had already listed, the scoped lookup re-filters the single owning repo's projected rows -- so an explicit id still never scans every repo. Fail-closed behavior is unchanged: the repo id compares exactly (STA-4343), host qualification is untouched, the folder-workspace instance suffix stays part of the path, and a scoped lookup with two equivalent rows refuses instead of guessing. The bare unprefixed selector branch keeps byte-exact id matching, since only the id: shape reaches a renderer caller. Shares src/shared/worktree/id.ts with the open #15616, which introduces worktreeIdComparisonKey for the same divergence in lineage pruning and authoritative-scan purging; this adopts that helper rather than adding a second one. Complementary to the open #16295, which makes the miss visible; this removes the miss. * chore(worktrees): satisfy oxfmt and oxlint on #16243 tests oxfmt --check flagged both new test files and oxlint's unicorn/no-useless-fallback-in-spread flagged the store mock; the full lint and format gates now match the pre-change baseline. * test(worktrees): pin Windows spellings and malformed-id exactness (#16243) Review found two axes the first pass left unproven at the two id: resolution sites. Both are the invariants the open #15616 guarantees for the shared worktreeIdComparisonKey it introduces for #15598, so violating either here would break a contract a sibling PR depends on. Windows: #15598's whole defect is that one checkout is recorded under both `D:\Agentic\game2` and `D:/Agentic/game2`. The fleet branch, the scoped removal lookup, and the key itself now each resolve the backslash spelling against the forward-slash spelling git reports, and fold drive-letter case -- while a backslash inside a POSIX path stays a filename character and a POSIX root stays case-sensitive, exactly as normalizeRuntimePathForComparison already decides for a path: selector. Malformed ids keep exact matching at both sites: an id with no repo boundary or an empty path still refuses, and the scoped lookup still refuses it without scanning. Four of these fail without the production change (three fleet/removal Windows cases and the scoped one); the malformed-id and POSIX-backslash cases are invariant guards that hold either way. Verified: 58 passed in the three files; 18 fail with the production hunks reverted; orca-runtime.test.ts and worktree-teardown-unstopped-pty.test.ts green (1270 passed | 1 skipped); pnpm tc:node clean. * test(worktrees): pin Windows id: spelling folds and fleet ambiguity refusal (#16243) The Windows backslash spelling now rides the ID_SPELLINGS rows, so it is driven through both id: sites -- resolveWorktreeSelector and the scoped removal target -- and compared against what the same workspace's path: selector resolves, rather than only through worktreeIdComparisonKey. That is the spelling #15598/#15616 found in the wild and the one the owner's Windows client produces. The fleet path's ambiguity refusal had no test: two same-repo rows spelling one directory, an id: matching neither exactly, must reject selector_ambiguous. It is the fail-closed guard on a delete-capable resolver, and the property a later refactor is most likely to turn into a silent pick. Also records two limits at the source instead of leaving them to be rediscovered: a UNC or WSL root never folds into a drive-letter location (while Windows' two WSL UNC aliases do name one location), and a folder-workspace id keeps a trailing slash placed before the ::workspace: suffix, so that spelling stays exact-match-only. Neither behavior changes here. The file docblock overclaimed parity. path: collapses duplicate same-host registrations to the first row while a folded id: refuses them; the contract this file pins is path-spelling parity, not dedup parity, and the divergence is deliberate because this resolver also serves delete. Non-vacuity, verified by temporarily reverting the production hunks: neutralizing both id: fallbacks turns 10 of these tests red, including both new Windows rows and the ambiguity refusal (it degrades to selector_not_found). Making the fleet fallback pick the first folded match instead of collecting all of them turns the ambiguity test red on its own. The remaining cases -- malformed ids, dot segments, the POSIX backslash, the folder-workspace slash -- pass against the pre-fix code too: they guard against future widening rather than proving this fix. Drops the two Windows cases the ID_SPELLINGS row subsumes. The drive-letter case test asserted only the Windows half its name promised; it now also pins that a POSIX root does NOT fold case, since an unconditional lowercase would merge /data/Foo with /data/foo on the platform CI runs on. Fixture paths use the upstream-attested /srv/projects prefix (and a neutral plugin-host leaf) instead of a local install root; the spelling variations the tests exist to pin -- doubled separator, dot segment, trailing slash, uppercase POSIX, cafe NFC/NFD, and the Windows D: rows -- are unchanged in form. * docs(worktrees): trim the id: selector test header and document the comparison key (#16243) --- src/main/runtime/orca-runtime.ts | 22 +- .../repo-worktree-row-resolution.test.ts | 85 ++++++ .../runtime/repo-worktree-row-resolution.ts | 21 +- ...ktree-rm-id-selector-path-spelling.test.ts | 273 ++++++++++++++++++ src/shared/worktree/id.test.ts | 65 ++++- src/shared/worktree/id.ts | 17 ++ 6 files changed, 473 insertions(+), 10 deletions(-) create mode 100644 src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index c33d741bc29..ee1f977ca88 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -534,7 +534,8 @@ import { WORKTREE_ID_SEPARATOR, getRepoIdFromWorktreeId, splitWorktreeId, - splitWorktreeIdForFilesystem + splitWorktreeIdForFilesystem, + worktreeIdComparisonKey } from '../../shared/worktree/id' import { getProjectIdForProviderIdentity, @@ -32294,6 +32295,7 @@ export class OrcaRuntimeService { return worktreeId } + /** Resolves one workspace or throws `selector_not_found` / `selector_ambiguous` — never picks a winner. */ private async resolveWorktreeSelector(selector: string): Promise { const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(selector) // Why only `id:`: every other selector kind is matched across the whole fleet, and their @@ -32315,6 +32317,16 @@ export class OrcaRuntimeService { if (selector.startsWith('id:')) { const worktreeId = explicitWorktreeId ?? selector.slice(3) candidates = worktrees.filter((worktree) => worktree.id === worktreeId) + if (candidates.length === 0) { + // Why (#16243): `id:` is the only shape the renderer can send, and a stored id can spell + // its path differently from the scan — the divergence `path:` has always absorbed. + // The bare unprefixed branch below stays byte-exact on purpose: only `id:` reaches a + // renderer caller, so `id:repo::p/` folds here while bare `repo::p/` still misses. + const comparisonKey = worktreeIdComparisonKey(worktreeId) + candidates = comparisonKey + ? worktrees.filter((worktree) => worktreeIdComparisonKey(worktree.id) === comparisonKey) + : candidates + } if (candidates.length === 0) { const parsed = splitWorktreeIdForFilesystem(worktreeId) const repo = parsed ? this.store?.getRepo(parsed.repoId) : null @@ -41428,12 +41440,8 @@ function runtimePathsEqual(left: string, right: string): boolean { * Windows/WSL/SSH ids still match themselves across hosts. */ function runtimeWorktreeIdsEqual(left: string, right: string): boolean { - const parsedLeft = splitWorktreeId(left) - const parsedRight = splitWorktreeId(right) - return parsedLeft && parsedRight - ? parsedLeft.repoId === parsedRight.repoId && - runtimePathsEqual(parsedLeft.worktreePath, parsedRight.worktreePath) - : left === right + const leftKey = worktreeIdComparisonKey(left) + return leftKey === null ? left === right : leftKey === worktreeIdComparisonKey(right) } function runtimeWorktreeIdentityKey(worktreeId: string): string { diff --git a/src/main/runtime/repo-worktree-row-resolution.test.ts b/src/main/runtime/repo-worktree-row-resolution.test.ts index be50f5678a7..d4bff4a9442 100644 --- a/src/main/runtime/repo-worktree-row-resolution.test.ts +++ b/src/main/runtime/repo-worktree-row-resolution.test.ts @@ -200,3 +200,88 @@ describe('host-qualified scoped worktree resolution', () => { expect(getRepos).not.toHaveBeenCalled() }) }) + +/** + * #16243: the renderer can only address a workspace by `id:::`, and this scoped + * lookup is what a host-qualified removal resolves through. It matched the id byte for byte while a + * `path:` selector has always compared through `normalizeRuntimePathForComparison`, so a stored id + * spelling its path differently from `git worktree list` resolved for the CLI and not for the UI. + */ +describe('scoped worktree id resolution across path spellings (#16243)', () => { + it.each([ + ['a trailing slash', '/same/worktree', 'shared::/same/worktree/'], + ['a doubled separator', '/same/worktree', 'shared::/same//worktree'], + ['an NFD name', '/same/café', `shared::${'/same/café'.normalize('NFD')}`] + ])('resolves the scanned row when the id carries %s', async (_label, scannedPath, worktreeId) => { + const owner = repo('shared', '/local/repo', { executionHostId: 'local' }) + const deps = createDeps([owner]) + deps.scanRepo.mockImplementation(async () => ({ + ok: true, + worktrees: [gitWorktree(scannedPath)] + })) + + await expect(resolveScopedWorktreeIdRow(deps, worktreeId, 'local')).resolves.toMatchObject({ + id: `shared::${scannedPath}`, + path: scannedPath + }) + }) + + it('still refuses the same path under a different repo id', async () => { + const deps = createDeps([ + repo('shared', '/local/repo', { executionHostId: 'local' }), + repo('unrelated', '/unrelated/repo', { executionHostId: 'local' }) + ]) + + await expect( + resolveScopedWorktreeIdRow(deps, 'unrelated::/same/worktree/', 'local') + ).resolves.toBeNull() + }) + + it('refuses rather than guessing when two rows spell one path', async () => { + const owner = repo('shared', '/local/repo', { executionHostId: 'local' }) + const deps = createDeps([owner]) + deps.scanRepo.mockImplementation(async () => ({ + ok: true, + worktrees: [gitWorktree('/same/worktree'), gitWorktree('/same//worktree')] + })) + + await expect( + resolveScopedWorktreeIdRow(deps, 'shared::/same/worktree/', 'local') + ).resolves.toBeNull() + }) + + it('prefers the exactly matching row over an equivalent spelling', async () => { + const owner = repo('shared', '/local/repo', { executionHostId: 'local' }) + const deps = createDeps([owner]) + deps.scanRepo.mockImplementation(async () => ({ + ok: true, + worktrees: [gitWorktree('/same//worktree'), gitWorktree('/same/worktree')] + })) + + await expect( + resolveScopedWorktreeIdRow(deps, 'shared::/same//worktree', 'local') + ).resolves.toMatchObject({ id: 'shared::/same//worktree' }) + }) + + // #15598/#15616: the backslash spelling is what a pre-restart Windows registration recorded. + it('resolves a Windows backslash id against the forward-slash spelling git reports', async () => { + const path = 'D:/Agentic/game2/battle-core' + const owner = repo('shared', 'D:/Agentic/game2', { executionHostId: 'runtime:windows' }) + const deps = createDeps([owner]) + deps.scanRepo.mockImplementation(async () => ({ ok: true, worktrees: [gitWorktree(path)] })) + + await expect( + resolveScopedWorktreeIdRow(deps, 'shared::D:\\Agentic\\game2\\battle-core', 'runtime:windows') + ).resolves.toMatchObject({ id: `shared::${path}`, path }) + }) + + it.each([ + ['no repo boundary', 'not-an-id'], + ['an empty path', 'shared::'] + ])('keeps exact matching for a malformed id with %s', async (_label, worktreeId) => { + const deps = createDeps([repo('shared', '/local/repo', { executionHostId: 'local' })]) + + await expect(resolveScopedWorktreeIdRow(deps, worktreeId, 'local')).resolves.toBeNull() + expect(deps.scanRepo).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/repo-worktree-row-resolution.ts b/src/main/runtime/repo-worktree-row-resolution.ts index b65fd9295f6..a9ef618ced7 100644 --- a/src/main/runtime/repo-worktree-row-resolution.ts +++ b/src/main/runtime/repo-worktree-row-resolution.ts @@ -1,4 +1,8 @@ -import { splitWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree/id' +import { + splitWorktreeId, + splitWorktreeIdForFilesystem, + worktreeIdComparisonKey +} from '../../shared/worktree/id' import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' import { isFolderRepo } from '../../shared/repo-kind' import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage' @@ -195,5 +199,18 @@ export async function resolveScopedWorktreeIdRow( resolveLocalProjectRuntimesForRepos(store, [repo]) ) const projected = projectResolvedWorktreeLineage(rows, store.getAllWorktreeLineage?.() ?? {}) - return projected.find((worktree) => worktree.id === worktreeId) ?? null + const exact = projected.find((worktree) => worktree.id === worktreeId) + if (exact) { + return exact + } + // Why (#16243): the scan can spell this id's path differently — the divergence `path:` absorbs. + // One equivalent row may stand in; two is an ambiguity a scoped lookup must refuse, not guess. + const comparisonKey = worktreeIdComparisonKey(worktreeId) + if (comparisonKey === null) { + return null + } + const equivalent = projected.filter( + (worktree) => worktreeIdComparisonKey(worktree.id) === comparisonKey + ) + return equivalent.length === 1 ? equivalent[0] : null } diff --git a/src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts b/src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts new file mode 100644 index 00000000000..f4e61a76381 --- /dev/null +++ b/src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts @@ -0,0 +1,273 @@ +/** + * Pins path-SPELLING parity between an `id:` selector and `path:`, deliberately not dedupe parity: + * where two same-host rows spell one directory, `path:` collapses them to the first while `id:` + * refuses as ambiguous, because this resolver also serves delete (#16243). + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const electronMocks = vi.hoisted(() => { + const ipcMain = { + on: vi.fn(() => ipcMain), + removeListener: vi.fn(() => ipcMain), + emit: vi.fn(() => true) + } + return { + BrowserWindow: { fromId: vi.fn((): unknown => null) }, + webContents: { fromId: vi.fn((): unknown => null) }, + ipcMain, + app: { getPath: vi.fn(() => '/tmp'), isPackaged: false } + } +}) +vi.mock('electron', () => electronMocks) + +const getSshGitProviderMock = vi.hoisted(() => vi.fn()) +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock, + getSshGitProviderGeneration: vi.fn(() => 0), + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable', + requireSshGitProvider: (connectionId: string) => getSshGitProviderMock(connectionId) +})) + +const listWorktreesStrictMock = vi.hoisted(() => vi.fn()) +vi.mock('../git/worktree', async (importOriginal) => ({ + ...(await importOriginal>()), + listWorktreesStrict: listWorktreesStrictMock +})) + +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { OrcaRuntimeService } from './orca-runtime' + +const REPO_ID = 'repo-local' +const REPO_PATH = '/srv/projects/app' +/** The spelling `git worktree list` reports. */ +const WORKTREE_PATH = '/srv/projects/workspaces/plugin-host' +const CANONICAL_ID = `${REPO_ID}::${WORKTREE_PATH}` + +/** One directory, other spellings a stored id can legitimately carry. */ +const ID_SPELLINGS: [label: string, worktreePath: string, repoPath?: string][] = [ + ['a trailing slash', `${WORKTREE_PATH}/`], + ['a doubled separator', '/srv/projects//workspaces/plugin-host'], + ['an NFD workspace name', '/srv/projects/workspaces/café-plugin'.normalize('NFD')], + // #15598/#15616: a Windows registration records backslashes; git reports forward slashes. + ['a backslash Windows spelling', 'D:\\Agentic\\game2\\battle-core', 'D:/Agentic/game2'] +] + +/** What a scan reports for a stored id: the same directory, canonically spelled. */ +function scannedSpellingOf(storedPath: string): string { + const slashed = /^[A-Za-z]:[\\/]/.test(storedPath) ? storedPath.replace(/\\/g, '/') : storedPath + return slashed.normalize('NFC').replace(/\/+/g, '/').replace(/\/$/, '') +} + +/** One registered repo whose worktree meta is writable, so a delete's `forgetLocal` is observable. */ +function makeStore(repoPath: string = REPO_PATH) { + const metaById: Record> = {} + const store = { + getRepo: (id: string) => store.getRepos().find((repo) => repo.id === id), + getRepos: () => [ + { id: REPO_ID, path: repoPath, displayName: 'app', badgeColor: 'blue', addedAt: 1 } + ], + getAllWorktreeMeta: vi.fn(() => metaById), + getWorktreeMeta: (id: string) => metaById[id], + setWorktreeMeta: (id: string, meta: Record) => { + metaById[id] = { ...metaById[id], ...meta } + return metaById[id] + }, + removeWorktreeMeta: () => {}, + getAllWorktreeLineage: () => ({}), + getAllWorkspaceLineage: () => ({}), + removeWorktreeLineage: vi.fn(), + removeWorkspaceLineage: vi.fn(), + getGitHubCache: () => undefined as never, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }), + getProjects: () => [] + } + return store +} + +/** `git worktree list` output: the main checkout plus one workspace at `worktreePath`. */ +function scanReports(worktreePath: string, repoPath: string = REPO_PATH): void { + listWorktreesStrictMock.mockResolvedValue([ + { path: repoPath, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }, + { path: worktreePath, head: 'def', branch: 'feature', isBare: false, isMainWorktree: false } + ]) +} + +type RemovalInternals = { + resolveWorktreeRemovalTarget: ( + worktreeSelector: string, + requiredHostId?: string + ) => Promise<{ id: string; repoId: string; path: string }> +} + +beforeEach(() => { + getSshGitProviderMock.mockReset() + listWorktreesStrictMock.mockReset() +}) + +describe('worktree id selectors vs. the path spelling git reports (#16243)', () => { + it.each(ID_SPELLINGS)( + 'resolves through the fleet path what `path:` resolves when the id carries %s', + async (_label, storedPath, repoPath) => { + scanReports(scannedSpellingOf(storedPath), repoPath) + const runtime = new OrcaRuntimeService(makeStore(repoPath) as never) + + // The CLI's shape, and the live data point: `path:` already resolves it. + const byPath = await runtime.showManagedWorktree(`path:${storedPath}`) + // The only shape the renderer can send must resolve the SAME workspace. + await expect( + runtime.showManagedWorktree(`id:${REPO_ID}::${storedPath}`) + ).resolves.toMatchObject({ id: byPath.id, path: byPath.path }) + } + ) + + it.each(ID_SPELLINGS)( + 'resolves a host-qualified removal target when the id carries %s', + async (_label, storedPath, repoPath) => { + scanReports(scannedSpellingOf(storedPath), repoPath) + const runtime = new OrcaRuntimeService(makeStore(repoPath) as never) + const internals = runtime as unknown as RemovalInternals + + // The scoped path the UI's delete takes must agree with the fleet path above. + await expect( + internals.resolveWorktreeRemovalTarget( + `id:${REPO_ID}::${storedPath}`, + LOCAL_EXECUTION_HOST_ID + ) + ).resolves.toMatchObject({ repoId: REPO_ID, path: scannedSpellingOf(storedPath) }) + } + ) + + it('still refuses an id whose path names a different workspace', async () => { + scanReports(WORKTREE_PATH) + const runtime = new OrcaRuntimeService(makeStore() as never) + + await expect( + runtime.showManagedWorktree(`id:${REPO_ID}::/srv/projects/workspaces/other-plugin`) + ).rejects.toThrow('selector_not_found') + }) + + // STA-4343: matching across repo ids would delete a workspace the caller never confirmed. + it('still refuses the same path under a different repo id', async () => { + scanReports(WORKTREE_PATH) + const runtime = new OrcaRuntimeService(makeStore() as never) + + await expect(runtime.showManagedWorktree(`id:other-repo::${WORKTREE_PATH}/`)).rejects.toThrow( + 'selector_not_found' + ) + }) + + it('still refuses a removal qualified to a host that does not own the repo id', async () => { + scanReports(WORKTREE_PATH) + const runtime = new OrcaRuntimeService(makeStore() as never) + const internals = runtime as unknown as RemovalInternals + + await expect( + internals.resolveWorktreeRemovalTarget(`id:${CANONICAL_ID}/`, 'runtime:env-b') + ).rejects.toThrow('selector_not_found') + }) + + it('keeps `id:` and `path:` agreeing on a dot segment neither canonicalizes', async () => { + scanReports(WORKTREE_PATH) + const runtime = new OrcaRuntimeService(makeStore() as never) + const dotted = '/srv/projects/./workspaces/plugin-host' + + await expect(runtime.showManagedWorktree(`path:${dotted}`)).rejects.toThrow( + 'selector_not_found' + ) + await expect(runtime.showManagedWorktree(`id:${REPO_ID}::${dotted}`)).rejects.toThrow( + 'selector_not_found' + ) + }) + + // #15598/#15616: on Windows one checkout is recorded under both spellings, and git reports the + // forward-slash one. The backslash id itself rides the ID_SPELLINGS rows above; these pin the + // folding limits around it. + describe('Windows drive-letter spellings', () => { + const WINDOWS_REPO = 'D:/Agentic/game2' + const WINDOWS_WORKTREE = 'D:/Agentic/game2/battle-core' + + it('folds drive-letter case only for Windows paths, never for a POSIX path', async () => { + scanReports(WINDOWS_WORKTREE, WINDOWS_REPO) + const windowsRuntime = new OrcaRuntimeService(makeStore(WINDOWS_REPO) as never) + + // A Windows root is case-insensitive, as `path:` already treats it. + await expect( + windowsRuntime.showManagedWorktree(`id:${REPO_ID}::d:/agentic/game2/battle-core`) + ).resolves.toMatchObject({ path: WINDOWS_WORKTREE }) + + // A POSIX root is not: folding case there would merge distinct directories for a delete. + scanReports(WORKTREE_PATH) + const posixRuntime = new OrcaRuntimeService(makeStore() as never) + + await expect( + posixRuntime.showManagedWorktree(`id:${REPO_ID}::/SRV/projects/workspaces/plugin-host`) + ).rejects.toThrow('selector_not_found') + }) + + it('does not fold a backslash inside a POSIX path, where it is a valid filename character', async () => { + scanReports(WORKTREE_PATH) + const runtime = new OrcaRuntimeService(makeStore() as never) + + await expect( + runtime.showManagedWorktree(`id:${REPO_ID}::/srv/projects\\workspaces\\plugin-host`) + ).rejects.toThrow('selector_not_found') + }) + }) + + // The fail-closed guard on a delete-capable resolver: two rows spelling one directory must not + // let a folded id pick one. `path:` collapses same-host duplicates; `id:` deliberately refuses. + it('refuses a folded id when two same-repo rows spell one directory', async () => { + listWorktreesStrictMock.mockResolvedValue([ + { path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }, + { path: WORKTREE_PATH, head: 'def', branch: 'feature', isBare: false, isMainWorktree: false }, + { + path: '/srv/projects//workspaces/plugin-host', + head: 'ghi', + branch: 'feature-2', + isBare: false, + isMainWorktree: false + } + ]) + const runtime = new OrcaRuntimeService(makeStore() as never) + + // Matches neither row exactly, folds to both. + await expect(runtime.showManagedWorktree(`id:${CANONICAL_ID}/`)).rejects.toThrow( + 'selector_ambiguous' + ) + }) + + // Live-proof limit, pinned so nobody "fixes" the trimming: a folder-workspace id only trims a + // trailing slash at end of string, so one placed before the instance suffix stays exact-only. + it('keeps a folder-workspace id exact when a slash precedes the instance suffix', async () => { + const instanceSuffix = '::workspace:123e4567-e89b-12d3-a456-426614174000' + scanReports(WORKTREE_PATH) + const runtime = new OrcaRuntimeService(makeStore() as never) + + await expect( + runtime.showManagedWorktree(`id:${REPO_ID}::${WORKTREE_PATH}/${instanceSuffix}`) + ).rejects.toThrow('selector_not_found') + }) + + // #15616 guarantees malformed ids keep exact-match behavior; both sites must honour that too. + it.each([ + ['no repo boundary', 'not-an-id'], + ['an empty path', `${REPO_ID}::`] + ])('keeps exact matching for a malformed id with %s', async (_label, malformedId) => { + scanReports(WORKTREE_PATH) + const runtime = new OrcaRuntimeService(makeStore() as never) + const internals = runtime as unknown as RemovalInternals + + await expect(runtime.showManagedWorktree(`id:${malformedId}`)).rejects.toThrow( + 'selector_not_found' + ) + await expect( + internals.resolveWorktreeRemovalTarget(`id:${malformedId}`, LOCAL_EXECUTION_HOST_ID) + ).rejects.toThrow('selector_not_found') + }) +}) diff --git a/src/shared/worktree/id.test.ts b/src/shared/worktree/id.test.ts index e64d27784f5..726e53abe5a 100644 --- a/src/shared/worktree/id.test.ts +++ b/src/shared/worktree/id.test.ts @@ -4,7 +4,8 @@ import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId, splitWorktreeId, - splitWorktreeIdForFilesystem + splitWorktreeIdForFilesystem, + worktreeIdComparisonKey } from './id' describe('WORKTREE_ID_SEPARATOR', () => { @@ -119,3 +120,65 @@ describe('getWorktreePathBasenameFromId', () => { expect(getWorktreePathBasenameFromId('repo-123::')).toBeNull() }) }) + +/** + * #16243: the renderer can only address a workspace by `id:::`, so the key must fold + * exactly the path spellings a `path:` selector already folds — and nothing more. + */ +describe('worktreeIdComparisonKey path-spelling parity for id: selectors (#16243)', () => { + const canonical = 'repo-123::/srv/workspaces/plugin' + const key = (worktreeId: string): string | null => worktreeIdComparisonKey(worktreeId) + + it('folds the path spellings a `path:` selector already accepts', () => { + expect(key('repo-123::/srv/workspaces/plugin/')).toBe(key(canonical)) + expect(key('repo-123::/srv//workspaces/plugin')).toBe(key(canonical)) + expect(key('repo-123::/srv/workspaces/Café'.normalize('NFD'))).toBe( + key('repo-123::/srv/workspaces/Café'.normalize('NFC')) + ) + }) + + it('folds no more loosely than `path:` does', () => { + // A leading `//` is a UNC root, not a doubled separator. + expect(key('repo-123://srv/workspaces/plugin')).not.toBe(key(canonical)) + // Dot segments are not canonicalized, so `id:` and `path:` still agree on refusing them. + expect(key('repo-123::/srv/./workspaces/plugin')).not.toBe(key(canonical)) + }) + + // #15598/#15616: the same Windows checkout is recorded with both separators. + it('folds Windows separator and drive-letter case, as `path:` already does', () => { + const windows = 'repo-123::D:/Agentic/game2' + expect(key('repo-123::D:\\Agentic\\game2')).toBe(key(windows)) + expect(key('repo-123::d:/agentic/game2')).toBe(key(windows)) + // A backslash is a valid POSIX filename character, so a POSIX path never folds it. + expect(key('repo-123::/srv\\workspaces')).not.toBe(key('repo-123::/srv/workspaces')) + // POSIX roots stay case-sensitive. + expect(key('repo-123::/srv/Workspaces')).not.toBe(key('repo-123::/srv/workspaces')) + }) + + it('keeps a UNC or WSL root distinct from a drive-letter location', () => { + // Different roots naming different filesystems must never collapse into one key. + expect(key('repo-123://server/share/game2')).not.toBe(key('repo-123::D:/server/share/game2')) + expect(key('repo-123://wsl.localhost/Ubuntu/home/dev/game2')).not.toBe( + key('repo-123::D:/home/dev/game2') + ) + // The two UNC aliases Windows exposes for one WSL distro are the same location. + expect(key('repo-123://wsl.localhost/Ubuntu/home/dev/game2')).toBe( + key('repo-123://wsl$/Ubuntu/home/dev/game2') + ) + }) + + it('never merges different repos, workspaces, or folder sessions', () => { + // STA-4343: the repo id stays exact, or a removal lands on a repo the caller never confirmed. + expect(key('repo-999::/srv/workspaces/plugin')).not.toBe(key(canonical)) + expect(key('repo-123::/srv/workspaces/other')).not.toBe(key(canonical)) + expect(key('repo-a::/srv/folder::workspace:123e4567-e89b-12d3-a456-426614174000')).not.toBe( + key('repo-a::/srv/folder::workspace:123e4567-e89b-12d3-a456-426614174001') + ) + }) + + it('returns null for ids with no repo boundary so callers keep exact matching', () => { + expect(key('repo-a')).toBeNull() + expect(key('repo-a::')).toBeNull() + expect(key('/srv/workspaces/plugin')).toBeNull() + }) +}) diff --git a/src/shared/worktree/id.ts b/src/shared/worktree/id.ts index d71cbac66eb..478ac6bfa76 100644 --- a/src/shared/worktree/id.ts +++ b/src/shared/worktree/id.ts @@ -1,3 +1,4 @@ +import { normalizeRuntimePathForComparison } from '../cross-platform-path' import { WORKTREE_ID_SEPARATOR } from '../pty-session-id-format' export { WORKTREE_ID_SEPARATOR } from '../pty-session-id-format' @@ -17,6 +18,22 @@ export function getRepoIdFromWorktreeId(worktreeId: string): string { return separatorIdx === -1 ? worktreeId : worktreeId.slice(0, separatorIdx) } +/** + * Canonical comparison form of a worktree id: the repoId is compared EXACT and only the path folds, + * through the same `normalizeRuntimePathForComparison` a `path:` selector has always applied and + * byte-exact id matching denied the renderer (#16243). Null for a malformed id, so callers keep + * exact matching for it. Comparison only — never persist or return this key. + */ +export function worktreeIdComparisonKey(worktreeId: string): string | null { + const parsed = splitWorktreeId(worktreeId) + if (!parsed || !parsed.repoId || !parsed.worktreePath) { + return null + } + return `${parsed.repoId}${WORKTREE_ID_SEPARATOR}${normalizeRuntimePathForComparison( + parsed.worktreePath + )}` +} + export function splitWorktreeId(worktreeId: string): ParsedWorktreeId | null { const separatorIdx = worktreeId.indexOf(WORKTREE_ID_SEPARATOR) if (separatorIdx === -1) { From 9062494f9b232112cbbe137f985a6ff67c8da37b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:37:16 -0700 Subject: [PATCH 049/152] fix(ai-vault): stop a whole opencode.db failure reading as one skipped transcript (#16587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ai-vault): stop a whole opencode.db failure reading as one skipped transcript #15036 reported "1 transcript skipped / database is locked" with both Agent Session History scopes empty. Two separate defects. The panel counts every unkinded scan issue as a skipped transcript, so a failure that lost an entire *source* was reported as one lost *file*. The whole-database failure is now kinded `scope`, and an unknown `kind` from a newer host degrades to `scope` instead of failing validation and coming back unkinded — a mixed-version remote host previously turned a source-level failure into a phantom skipped transcript. The read also inherited sqlite3's 0 ms busy timeout, so a genuinely contended open failed in ~1 ms. It now opens once with a bounded timeout. No retry loop: sqlite's own busy handler already blocks and retries internally for the whole timeout, and WAL readers do not block on a writer at all (measured: 547/547 cross-process reads at timeout=0 while a writer held open transactions). Measured against a real Ubuntu-24.04 distro, Windows cannot take SQLite's file locks over \\wsl.localhost at all: an idle, never-WAL, nothing-attached database still answers SQLITE_BUSY, a 5 s busy timeout does not change it, and the identical bytes open fine once copied to local disk. So a lock-family error on that share never means "a writer holds it" and no timeout can help. The copy says so rather than sending the user after a write-ahead log that is not the problem. Restoring those sessions needs an in-distro read; that is a follow-up, and this PR no longer pretends a timeout will do it. immutable=1 is deliberately not used as a workaround: over the same share it opens and returns 100 of 150 rows, silently dropping everything still in the uncheckpointed -wal — in a history panel, exactly the newest sessions. * skip the provably futile busy wait on \\wsl.localhost paths --- .../session-list-result-validation.test.ts | 33 +++ .../session-list-result-validation.ts | 8 +- .../session-scanner-opencode-sqlite-list.ts | 43 ++-- ...ssion-scanner-opencode-sqlite-open.test.ts | 238 ++++++++++++++++++ .../session-scanner-opencode-sqlite-open.ts | 105 ++++++++ ...n-scanner-opencode-sqlite-worker-client.ts | 3 + .../session-scanner-opencode-sqlite.ts | 176 ++++++------- src/main/codex/codex-session-index-heal.ts | 3 +- src/main/sqlite/sqlite-read-failure.test.ts | 113 +++++++++ src/main/sqlite/sqlite-read-failure.ts | 77 ++++++ src/main/sqlite/sync-database.test.ts | 80 ++++++ .../ai-vault-scan-issue-state.test.ts | 18 ++ .../ai-vault-scan-issue-state.ts | 4 +- 13 files changed, 787 insertions(+), 114 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-opencode-sqlite-open.test.ts create mode 100644 src/main/ai-vault/session-scanner-opencode-sqlite-open.ts create mode 100644 src/main/sqlite/sqlite-read-failure.test.ts create mode 100644 src/main/sqlite/sqlite-read-failure.ts diff --git a/src/main/ai-vault/session-list-result-validation.test.ts b/src/main/ai-vault/session-list-result-validation.test.ts index af1a5e34ec2..484b8c4eb6d 100644 --- a/src/main/ai-vault/session-list-result-validation.test.ts +++ b/src/main/ai-vault/session-list-result-validation.test.ts @@ -112,6 +112,39 @@ describe('parseAiVaultListResult', () => { }) }) + // #15036: a `kind` this build has never heard of used to fail the whole issue, + // and the fallback re-reported it as an unkinded entry — i.e. a newer host's + // whole-source failure came back to an older client as a skipped transcript. + it('keeps an issue whose kind this build does not know, as its own kinded row', () => { + const parsed = parseAiVaultListResult({ + sessions: [validSession()], + issues: [ + { + agent: 'opencode', + kind: 'source-from-a-newer-host', + path: '/home/ada/.local/share/opencode/opencode.db', + message: 'OpenCode history was skipped.' + } + ], + scannedAt: '2026-07-27T00:00:00.000Z' + }) + + expect(parsed.issues).toEqual([ + expect.objectContaining({ kind: 'scope', message: 'OpenCode history was skipped.' }) + ]) + expect(parsed.issues.some((issue) => issue.message.includes('invalid'))).toBe(false) + }) + + it('leaves an unkinded issue unkinded so real skipped transcripts still count', () => { + const parsed = parseAiVaultListResult({ + sessions: [validSession()], + issues: [{ agent: 'codex', path: '/bad.jsonl', message: 'Malformed transcript' }], + scannedAt: '2026-07-27T00:00:00.000Z' + }) + + expect(parsed.issues[0]?.kind).toBeUndefined() + }) + it('rejects a malformed result envelope', () => { expect(() => parseAiVaultListResult({ sessions: [] })).toThrow() }) diff --git a/src/main/ai-vault/session-list-result-validation.ts b/src/main/ai-vault/session-list-result-validation.ts index abcdd1612b3..06502cd4a0b 100644 --- a/src/main/ai-vault/session-list-result-validation.ts +++ b/src/main/ai-vault/session-list-result-validation.ts @@ -80,7 +80,13 @@ const aiVaultSessionSchema = z.object({ const aiVaultScanIssueSchema = z.object({ executionHostId: executionHostIdSchema.optional(), agent: z.string().min(1), - kind: z.enum(['host', 'scope', 'notice']).optional(), + // Why (#15036): a `kind` this build has never heard of used to fail the whole + // issue, which the fallback re-reported as an *unkinded* "invalid entry" — i.e. + // a newer host's source-level failure came back as a skipped transcript. An + // unknown kind degrades to 'scope' (its own muted row); absent stays absent. + kind: z + .union([z.enum(['host', 'scope', 'notice']), z.string().transform(() => 'scope' as const)]) + .optional(), path: z.string(), message: z.string() }) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts index 4b86d04161a..93f9daab98c 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts @@ -3,9 +3,12 @@ import { buildOpenCodeSqliteCandidatePath, splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths' +import { + openCodeDatabaseScanIssue, + readOpenCodeDatabase +} from './session-scanner-opencode-sqlite-open' import type { SessionFileCandidate } from './session-scanner-types' -import { errorMessage } from './session-scanner-values' -import SyncDatabase from '../sqlite/sync-database' +import type SyncDatabase from '../sqlite/sync-database' import { columnExists, tableExists } from '../opencode-usage/schema-helpers' // Why: the SQLite session-list query + reader lives in its own electron-free @@ -18,12 +21,6 @@ type SessionRow = { time_updated: number } -function openReadonlyDatabase(dbPath: string): SyncDatabase { - const db = new SyncDatabase(dbPath, { readonly: true, fileMustExist: true }) - db.pragma('query_only = ON') - return db -} - function canReadOpenCodeSessions(db: SyncDatabase): boolean { return ( tableExists(db, 'session') && @@ -48,6 +45,15 @@ function buildSessionListQuery(db: SyncDatabase, limited: boolean): string { ${limited ? 'LIMIT ?' : ''}` } +function readSessionRows(db: SyncDatabase, limit: number): SessionRow[] { + if (!canReadOpenCodeSessions(db)) { + return [] + } + const limited = Number.isFinite(limit) + const statement = db.prepare(buildSessionListQuery(db, limited)) + return (limited ? statement.all(limit) : statement.all()) as SessionRow[] +} + function rowToCandidate(row: SessionRow, dbPath: string): SessionFileCandidate { const mtimeMs = typeof row.time_updated === 'number' && row.time_updated > 0 @@ -99,26 +105,17 @@ export async function listOpenCodeSqliteSessions(args: { }): Promise { const candidates: SessionFileCandidate[] = [] for (const dbPath of args.dbPaths) { - let db: SyncDatabase | null = null try { - db = openReadonlyDatabase(dbPath) - if (!canReadOpenCodeSessions(db)) { - continue - } - const limited = Number.isFinite(args.limit) - const statement = db.prepare(buildSessionListQuery(db, limited)) - const rows = (limited ? statement.all(args.limit) : statement.all()) as SessionRow[] + const rows = readOpenCodeDatabase({ + dbPath, + read: (db) => readSessionRows(db, args.limit) + }) for (const row of rows) { candidates.push(rowToCandidate(row, dbPath)) } } catch (err) { - args.issues.push({ - agent: 'opencode', - path: dbPath, - message: errorMessage(err) - }) - } finally { - db?.close() + // A whole DB failed, not one transcript: kinded so the panel says so. + args.issues.push(openCodeDatabaseScanIssue(dbPath, err)) } } return dedupeAndSortSqliteCandidates(candidates) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-open.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-open.test.ts new file mode 100644 index 00000000000..7bfd72f9b51 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-open.test.ts @@ -0,0 +1,238 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Worker } from 'node:worker_threads' +import { afterEach, describe, expect, it } from 'vitest' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import Database from '../sqlite/sync-database' +import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-list' +import { + openCodeBusyTimeoutMs, + openCodeDatabaseScanIssue, + readOpenCodeDatabase +} from './session-scanner-opencode-sqlite-open' + +// Reproduces #15036: OpenCode holds opencode.db open while it runs, so a scan +// lands mid-write. The reader had no busy timeout, failed in ~1 ms with the bare +// driver string "database is locked", and one failed DB emptied both scopes. + +let tempDirs: string[] = [] +let lockHolders: Worker[] = [] + +afterEach(async () => { + await Promise.all(lockHolders.splice(0).map((worker) => worker.terminate())) + lockHolders = [] + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }) + } + tempDirs = [] +}) + +const SCHEMA = ` + CREATE TABLE session ( + id TEXT PRIMARY KEY, + parent_id TEXT, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL + ) +` + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-opencode-open-')) + tempDirs.push(dir) + return dir +} + +function seededDatabase(name: string, sessionId: string): string { + const path = join(tempDir(), name) + const db = new Database(path) + db.exec('PRAGMA journal_mode=DELETE') + db.exec(SCHEMA) + db.prepare('INSERT INTO session (id, time_created, time_updated) VALUES (?, ?, ?)').run( + sessionId, + 1_700_000_000_000, + 1_700_000_001_000 + ) + db.close() + return path +} + +// The lock holder must live on another thread: sqlite3_busy_timeout sleeps +// synchronously, so a same-thread timer could never fire to release it. +const LOCK_HOLDER_SOURCE = ` + const { parentPort, workerData } = require('node:worker_threads') + const { DatabaseSync } = require('node:sqlite') + const db = new DatabaseSync(workerData.path) + db.exec('BEGIN EXCLUSIVE') + db.exec("INSERT INTO session (id, time_created, time_updated) VALUES ('locked-write', 1, 1)") + parentPort.postMessage('locked') + setTimeout(() => { + db.exec('ROLLBACK') + db.close() + parentPort.postMessage('released') + }, workerData.holdMs) +` + +async function holdWriteLock(path: string, holdMs: number): Promise { + const worker = new Worker(LOCK_HOLDER_SOURCE, { eval: true, workerData: { path, holdMs } }) + lockHolders.push(worker) + await new Promise((resolve, reject) => { + worker.once('message', () => resolve()) + worker.once('error', reject) + }) +} + +describe('listOpenCodeSqliteSessions against a database OpenCode is writing to', () => { + it('reports the failure as a whole source, not as a skipped transcript', async () => { + const path = seededDatabase('opencode.db', 'session-a') + await holdWriteLock(path, 60_000) + const issues: AiVaultScanIssue[] = [] + + const candidates = await listOpenCodeSqliteSessions({ dbPaths: [path], limit: 10, issues }) + + expect(candidates).toEqual([]) + expect(issues).toHaveLength(1) + // The panel counts only unkinded issues as skipped transcripts. + expect(issues[0]?.kind).toBe('scope') + expect(issues[0]?.message).toContain('OpenCode is writing to opencode.db') + expect(issues[0]?.message).not.toBe('database is locked') + }) + + it('still returns sessions from every other database', async () => { + const contended = seededDatabase('opencode.db', 'session-a') + const healthy = seededDatabase('opencode-alt.db', 'session-b') + await holdWriteLock(contended, 60_000) + const issues: AiVaultScanIssue[] = [] + + const candidates = await listOpenCodeSqliteSessions({ + dbPaths: [contended, healthy], + limit: 10, + issues + }) + + expect(candidates).toHaveLength(1) + expect(candidates[0]?.file.path).toContain('session-b') + expect(issues).toHaveLength(1) + }) + + it('reads the sessions once the write finishes inside the busy timeout', async () => { + const path = seededDatabase('opencode.db', 'session-a') + // Long enough that only a real busy timeout — not a lucky fast open — survives it. + await holdWriteLock(path, 900) + const issues: AiVaultScanIssue[] = [] + + const candidates = await listOpenCodeSqliteSessions({ dbPaths: [path], limit: 10, issues }) + + expect(issues).toEqual([]) + expect(candidates).toHaveLength(1) + expect(candidates[0]?.file.path).toContain('session-a') + }) +}) + +describe('readOpenCodeDatabase', () => { + it('closes the handle on the success path', () => { + const path = seededDatabase('opencode.db', 'session-a') + let captured: Database.Database | null = null + + const rows = readOpenCodeDatabase({ + dbPath: path, + read: (db) => { + captured = db + return db.prepare('SELECT id FROM session').all() + } + }) + + expect(rows).toEqual([{ id: 'session-a' }]) + expect(() => captured!.prepare('SELECT 1')).toThrow(/not open/i) + }) + + it('closes the handle when the read throws', () => { + const path = seededDatabase('opencode.db', 'session-a') + let captured: Database.Database | null = null + + expect(() => + readOpenCodeDatabase({ + dbPath: path, + read: (db) => { + captured = db + throw new Error('read blew up') + } + }) + ).toThrow('read blew up') + expect(() => captured!.prepare('SELECT 1')).toThrow(/not open/i) + }) +}) + +describe('openCodeBusyTimeoutMs', () => { + // Measured on a real Windows host against a real distro: a read over + // \\wsl.localhost fails whatever the timeout, and the wait is not bounded by + // it -- 1500 took ~2400 ms, 5000 took ~7250 ms, 0 failed in ~21 ms. Waiting + // there is dead time on every scan, and enough such databases would spend the + // list worker's whole deadline. + it('does not wait on a share where the wait provably cannot succeed', () => { + expect(openCodeBusyTimeoutMs('\\\\wsl.localhost\\Ubuntu\\home\\ada\\opencode.db')).toBe(0) + expect(openCodeBusyTimeoutMs('\\\\wsl$\\Ubuntu\\home\\ada\\opencode.db')).toBe(0) + }) + + it('still waits on a path where a writer really can be holding the lock', () => { + expect(openCodeBusyTimeoutMs('C:\\Users\\ada\\opencode.db')).toBe(1_500) + expect(openCodeBusyTimeoutMs('/home/ada/.local/share/opencode/opencode.db')).toBe(1_500) + }) +}) + +describe('openCodeDatabaseScanIssue', () => { + const cantOpen = Object.assign(new Error('unable to open database file'), { errcode: 14 }) + + it('names the wal-index over a WSL share rather than repeating the driver string', () => { + const issue = openCodeDatabaseScanIssue( + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.local\\share\\opencode\\opencode.db', + cantOpen + ) + + expect(issue.kind).toBe('scope') + expect(issue.message).toContain('\\\\wsl.localhost') + // Checkpointing cannot fix a share that refuses SQLite's locks, so the copy + // must not send the user after the write-ahead log. + expect(issue.message).not.toContain('write-ahead log') + }) + + it('keeps the advice generic for a non-WSL path', () => { + const issue = openCodeDatabaseScanIssue('/home/ada/.local/share/opencode/opencode.db', cantOpen) + + expect(issue.message).not.toContain('wsl.localhost') + expect(issue.message).toContain('write-ahead log') + }) + + // Measured against a real Ubuntu-24.04 distro: Windows cannot take SQLite's + // locks over \\wsl.localhost at all. An idle, never-WAL database answers + // SQLITE_BUSY the same way, while the identical bytes read fine once copied + // to local disk — so no busy timeout and no journal mode changes the outcome. + it('does not blame a live writer for a lock-family error over a WSL share', () => { + const busy = Object.assign(new Error('database is locked'), { errcode: 5 }) + const issue = openCodeDatabaseScanIssue( + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.local\\share\\opencode\\opencode.db', + busy + ) + + expect(issue.message).not.toContain('is writing to') + expect(issue.message).toContain('inside the distro') + }) + + it('still blames a live writer for the same error on a local path', () => { + const busy = Object.assign(new Error('database is locked'), { errcode: 5 }) + const issue = openCodeDatabaseScanIssue('/home/ada/.local/share/opencode/opencode.db', busy) + + expect(issue.message).toContain('is writing to') + }) + + it('reports anything else with its underlying cause', () => { + const issue = openCodeDatabaseScanIssue( + '/home/ada/.local/share/opencode/opencode.db', + Object.assign(new Error('database disk image is malformed'), { errcode: 11 }) + ) + + expect(issue.kind).toBe('scope') + expect(issue.message).not.toContain('write-ahead log') + expect(issue.message).toContain('database disk image is malformed') + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts new file mode 100644 index 00000000000..84c2cca73d4 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts @@ -0,0 +1,105 @@ +import { basename } from 'node:path' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { isWslUncPath } from '../../shared/wsl-paths' +import SyncDatabase from '../sqlite/sync-database' +import { classifySqliteReadFailure } from '../sqlite/sqlite-read-failure' +import { errorMessage } from './session-scanner-values' + +// Why (#15036): the read used to inherit sqlite3's 0 ms busy timeout, so a +// genuinely contended open failed in ~1 ms. WAL readers almost never block on a +// writer, so this is insurance rather than the fix -- it stays a single bounded +// open, with no retry loop on top of sqlite's own busy handler. +const OPENCODE_SQLITE_BUSY_TIMEOUT_MS = 1_500 + +/** + * Open one OpenCode database for reading. + * + * Read-only, plus `query_only` as a belt-and-suspenders guard so a bug in a + * SELECT list can never mutate the user's opencode.db. + * @param dbPath - Absolute path to an opencode.db file. + * @returns An open handle the caller must close. + */ +/** + * Busy timeout for one database. + * + * Zero over the WSL share because waiting there is provably useless: Windows + * cannot take SQLite's locks over \\wsl.localhost at all, so the open fails + * whatever the timeout. Measured on a real host, the wait is not even bounded + * by the value — timeout 1500 took ~2400 ms and timeout 5000 took ~7250 ms, + * while 0 failed in ~21 ms. Paying that per database, per scan, buys nothing + * and enough such databases would spend the list worker's whole 30 s deadline. + * @param dbPath - Absolute path to an opencode.db file. + * @returns Milliseconds to let SQLite's busy handler wait. + */ +export function openCodeBusyTimeoutMs(dbPath: string): number { + return isWslUncPath(dbPath) ? 0 : OPENCODE_SQLITE_BUSY_TIMEOUT_MS +} + +function openOpenCodeDatabaseReadonly(dbPath: string): SyncDatabase { + const db = new SyncDatabase(dbPath, { + readonly: true, + fileMustExist: true, + timeout: openCodeBusyTimeoutMs(dbPath) + }) + db.pragma('query_only = ON') + return db +} + +/** + * Run one synchronous read against an OpenCode database. + * @param args.dbPath - Absolute path to an opencode.db file. + * @param args.read - Synchronous reader; its result must be fully materialized. + * @returns The reader's value; rethrows whatever SQLite raised. + */ +export function readOpenCodeDatabase(args: { + dbPath: string + read: (db: SyncDatabase) => T +}): T { + const db = openOpenCodeDatabaseReadonly(args.dbPath) + try { + return args.read(db) + } finally { + db.close() + } +} + +/** + * Describe a whole-database read failure as a scan issue. + * + * Kinded so the panel renders this copy instead of counting a failed *source* + * as a skipped *transcript*; `scope` rather than a new member — see + * `aiVaultScanIssueSchema` in session-list-result-validation.ts. + * @param dbPath - Absolute path to the opencode.db that could not be read. + * @param error - The thrown value from the read. + * @returns A kinded scan issue with actionable copy. + */ +export function openCodeDatabaseScanIssue(dbPath: string, error: unknown): AiVaultScanIssue { + const name = basename(dbPath) + // `fileMustExist` already proved the database file is there before SQLite ran, + // so this needs no fs probe of its own — an extra sync stat on a 9p share is + // exactly the hang the WSL transcript gate exists to prevent. + const kind = classifySqliteReadFailure({ error, databaseFileExists: true }) + // Why: measured against a real distro. Windows cannot take SQLite's file locks + // over \\wsl.localhost at all — an idle, never-WAL, nothing-attached database + // answers SQLITE_BUSY just the same, and a 5 s busy timeout does not change it, + // while the identical bytes copied to local disk open fine. So on this share a + // lock-family error never means "a writer holds it", and no timeout can help. + const overWslShare = isWslUncPath(dbPath) + const detail = + kind === 'contended' && !overWslShare + ? `OpenCode is writing to ${name} right now, so its history was skipped. It is read again on the next refresh.` + : kind === 'unreadable' + ? `OpenCode history in ${name} could not be read: ${errorMessage(error)}` + : `OpenCode history in ${name} could not be read. ${unreadableShareAdvice(dbPath)}` + return { agent: 'opencode', kind: 'scope', path: dbPath, message: detail } +} + +function unreadableShareAdvice(dbPath: string): string { + // Named only when the evidence supports it; a generic share gets generic copy. + // Deliberately not "flush the write-ahead log": checkpointing changes nothing + // here, and telling the user to try it would send them after a fix that cannot + // work. The share itself is the blocker. + return isWslUncPath(dbPath) + ? 'Windows cannot open SQLite databases over the \\\\wsl.localhost share, so this history has to be read from inside the distro.' + : 'Its write-ahead log cannot be opened read-only on this filesystem. Exit OpenCode cleanly to flush the log.' +} diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts index ef156b0b3f4..e8fc78907ba 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts @@ -94,8 +94,10 @@ export class OpenCodeSqliteWorkerClient { return value.candidates } catch (err) { if (err instanceof OpenCodeSqliteWorkerUnavailableError) { + // Kinded: a whole source failed, not a transcript. args.issues.push({ agent: 'opencode', + kind: 'scope', path: args.dbPaths[0] ?? 'opencode.db', message: 'OpenCode history was skipped because its background scanner could not start; the app remains responsive.' @@ -106,6 +108,7 @@ export class OpenCodeSqliteWorkerClient { // scan, surfaced as one scan issue rather than an unbounded stall. args.issues.push({ agent: 'opencode', + kind: 'scope', path: args.dbPaths[0] ?? 'opencode.db', message: `OpenCode history scan did not complete: ${errorMessage(err)}` }) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite.ts b/src/main/ai-vault/session-scanner-opencode-sqlite.ts index c0ffdbfb894..322a38f214f 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite.ts @@ -9,8 +9,9 @@ import { normalizeFullFirstUserPromptText, shouldCaptureFullFirstUserPrompt } from './session-scanner-first-user-prompt' +import { readOpenCodeDatabase } from './session-scanner-opencode-sqlite-open' import { normalizeTitleText } from './session-scanner-values' -import SyncDatabase from '../sqlite/sync-database' +import type SyncDatabase from '../sqlite/sync-database' import { columnExists, tableExists } from '../opencode-usage/schema-helpers' // Why: OpenCode 1.17.x migrated session storage from per-session JSON files @@ -53,14 +54,6 @@ type PreviewRow = { summary_body: string | null } -function openReadonlyDatabase(dbPath: string): SyncDatabase { - const db = new SyncDatabase(dbPath, { readonly: true, fileMustExist: true }) - // Why: belt-and-suspenders guard so a bug in the SELECT list can never - // mutate the user's opencode.db. - db.pragma('query_only = ON') - return db -} - function canReadOpenCodeSessions(db: SyncDatabase): boolean { return ( tableExists(db, 'session') && @@ -256,90 +249,97 @@ export async function parseOpenCodeSqliteSession(args: { sessionId: string platform: NodeJS.Platform }): Promise { - const { dbPath, sessionId, platform } = args - let db: SyncDatabase | null = null - try { - db = openReadonlyDatabase(dbPath) - if (!canReadOpenCodeSessions(db)) { - return null - } - const row = db.prepare(buildSessionQuery(db)).get(sessionId) as SessionRow | undefined - if (!row || row.id !== sessionId) { - return null - } + return readOpenCodeDatabase({ + dbPath: args.dbPath, + read: (db) => readSession({ db, ...args }) + }) +} + +// Extracted so the open wrapper owns the handle's lifetime. +function readSession(args: { + db: SyncDatabase + dbPath: string + sessionId: string + platform: NodeJS.Platform +}): AiVaultSession | null { + const { db, dbPath, sessionId, platform } = args + if (!canReadOpenCodeSessions(db)) { + return null + } + const row = db.prepare(buildSessionQuery(db)).get(sessionId) as SessionRow | undefined + if (!row || row.id !== sessionId) { + return null + } - const mtimeMs = - typeof row.time_updated === 'number' && row.time_updated > 0 - ? row.time_updated - : row.time_created - // Why: discovery uses a synthetic db#session path only for parser routing. - // The UI's log open/reveal actions need a real filesystem path. - const accumulator = createAccumulator({ - agent: 'opencode', - file: { - path: dbPath, - mtimeMs, - modifiedAt: new Date(mtimeMs).toISOString() - }, - sessionId - }) - accumulator.title = normalizeTitleText(row.title ?? '') - accumulator.cwd = row.directory - accumulator.model = extractModelId(row.model_json) - accumulator.totalTokens = - (row.tokens_input ?? 0) + (row.tokens_output ?? 0) + (row.tokens_reasoning ?? 0) - accumulator.messageCount = row.message_count ?? 0 - updateTimeline(accumulator, row.time_created) - updateTimeline(accumulator, row.time_updated) + const mtimeMs = + typeof row.time_updated === 'number' && row.time_updated > 0 + ? row.time_updated + : row.time_created + // Why: discovery uses a synthetic db#session path only for parser routing. + // The UI's log open/reveal actions need a real filesystem path. + const accumulator = createAccumulator({ + agent: 'opencode', + file: { + path: dbPath, + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString() + }, + sessionId + }) + accumulator.title = normalizeTitleText(row.title ?? '') + accumulator.cwd = row.directory + accumulator.model = extractModelId(row.model_json) + accumulator.totalTokens = + (row.tokens_input ?? 0) + (row.tokens_output ?? 0) + (row.tokens_reasoning ?? 0) + accumulator.messageCount = row.message_count ?? 0 + updateTimeline(accumulator, row.time_created) + updateTimeline(accumulator, row.time_updated) - const previewSql = buildPreviewQuery(db) - if (previewSql) { - // Why: SQL already dropped anything older than the newest-N window, so the - // accumulator never shifts and cannot detect the truncation itself. Ask for - // one extra row so an exactly-full window is not mistaken for a trimmed one. - const probedRows = db - .prepare(previewSql) - .all(sessionId, OPENCODE_SQLITE_PREVIEW_LIMIT + 1) as PreviewRow[] - if (probedRows.length > OPENCODE_SQLITE_PREVIEW_LIMIT) { - accumulator.previewMessagesTruncated = true + const previewSql = buildPreviewQuery(db) + if (previewSql) { + // Why: SQL already dropped anything older than the newest-N window, so the + // accumulator never shifts and cannot detect the truncation itself. Ask for + // one extra row so an exactly-full window is not mistaken for a trimmed one. + const probedRows = db + .prepare(previewSql) + .all(sessionId, OPENCODE_SQLITE_PREVIEW_LIMIT + 1) as PreviewRow[] + if (probedRows.length > OPENCODE_SQLITE_PREVIEW_LIMIT) { + accumulator.previewMessagesTruncated = true + } + // Newest-first, so the probe row to drop is the oldest one at the tail. + const previewRows = probedRows.slice(0, OPENCODE_SQLITE_PREVIEW_LIMIT) + // Why: query returns newest-first; push in chronological order so the + // accumulator's ring buffer keeps the newest OPENCODE_SQLITE_PREVIEW_LIMIT + // messages. + for (let i = previewRows.length - 1; i >= 0; i--) { + const previewRow = previewRows[i] + if (!previewRow) { + continue } - // Newest-first, so the probe row to drop is the oldest one at the tail. - const previewRows = probedRows.slice(0, OPENCODE_SQLITE_PREVIEW_LIMIT) - // Why: query returns newest-first; push in chronological order so the - // accumulator's ring buffer keeps the newest OPENCODE_SQLITE_PREVIEW_LIMIT - // messages. - for (let i = previewRows.length - 1; i >= 0; i--) { - const previewRow = previewRows[i] - if (!previewRow) { - continue - } - const text = extractPartText(previewRow.part_data) - if (!text) { - continue - } - addPreviewMessage(accumulator, { - role: mapPreviewRole(previewRow.role), - text, - timestamp: previewRow.time_created, - // Preview window is newest-N; first-prompt is loaded separately below. - seedFirstUserPrompt: false - }) - if (previewRow.role === 'user' && !accumulator.title) { - accumulator.title = - normalizeTitleText(previewRow.summary_title ?? '') || - normalizeTitleText(previewRow.summary_body ?? '') - } + const text = extractPartText(previewRow.part_data) + if (!text) { + continue } + addPreviewMessage(accumulator, { + role: mapPreviewRole(previewRow.role), + text, + timestamp: previewRow.time_created, + // Preview window is newest-N; first-prompt is loaded separately below. + seedFirstUserPrompt: false + }) + if (previewRow.role === 'user' && !accumulator.title) { + accumulator.title = + normalizeTitleText(previewRow.summary_title ?? '') || + normalizeTitleText(previewRow.summary_body ?? '') + } } + } - // Why: list preview only joins the newest messages. On-demand copy needs the - // session's earliest real user text part, not a later turn still in the window. - if (shouldCaptureFullFirstUserPrompt()) { - accumulator.firstUserPrompt = readFirstUserPromptFromOpenCodeDb(db, sessionId) - } + // Why: list preview only joins the newest messages. On-demand copy needs the + // session's earliest real user text part, not a later turn still in the window. + if (shouldCaptureFullFirstUserPrompt()) { + accumulator.firstUserPrompt = readFirstUserPromptFromOpenCodeDb(db, sessionId) + } - return finalizeSession(accumulator, platform) - } finally { - db?.close() - } + return finalizeSession(accumulator, platform) } diff --git a/src/main/codex/codex-session-index-heal.ts b/src/main/codex/codex-session-index-heal.ts index 645d96a76b4..12747312646 100644 --- a/src/main/codex/codex-session-index-heal.ts +++ b/src/main/codex/codex-session-index-heal.ts @@ -1,5 +1,6 @@ import { dirname, join } from 'node:path' import { resolveCodexCommand } from '../codex-cli/command' +import { isTransientSqliteContention } from '../sqlite/sqlite-read-failure' import { getSpawnArgsForWindows } from '../win32-utils' import { getCodexSessionBackfillStateDirPath } from './codex-home-paths' import { resolveCodexSessionBackfillPaths } from './codex-session-backfill' @@ -231,7 +232,7 @@ async function healOneThread( recordHealOutcome(paths, thread, 'missing') return } - if (/SQLITE_(?:BUSY|LOCKED)|database (?:is )?(?:busy|locked)/i.test(message)) { + if (isTransientSqliteContention(message)) { // Why: an active Codex process can briefly own sqlite; leave the id off // the ledger and abort this pass so a later startup resumes it. throw error diff --git a/src/main/sqlite/sqlite-read-failure.test.ts b/src/main/sqlite/sqlite-read-failure.test.ts new file mode 100644 index 00000000000..8bd43d54eaa --- /dev/null +++ b/src/main/sqlite/sqlite-read-failure.test.ts @@ -0,0 +1,113 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import SyncDatabase from './sync-database' +import { classifySqliteReadFailure, isTransientSqliteContention } from './sqlite-read-failure' + +// Error codes here are the ones a real node:sqlite open produces: a contended +// database reports errcode 5 ("database is locked"), while a read-only WAL open +// with no usable -shm reports errcode 14 ("unable to open database file"). The +// two need opposite responses, so the classifier must never conflate them. + +let tempDirs: string[] = [] + +afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }) + } + tempDirs = [] +}) + +function contendedDatabase(): { path: string; release: () => void } { + const dir = mkdtempSync(join(tmpdir(), 'orca-sqlite-failure-')) + tempDirs.push(dir) + const path = join(dir, 'contended.db') + const writer = new SyncDatabase(path) + writer.exec('PRAGMA journal_mode=DELETE') + writer.exec('CREATE TABLE session (id TEXT PRIMARY KEY)') + writer.exec('BEGIN EXCLUSIVE') + writer.exec("INSERT INTO session VALUES ('a')") + return { + path, + release: () => { + writer.exec('COMMIT') + writer.close() + } + } +} + +describe('isTransientSqliteContention', () => { + it('recognizes a real SQLITE_BUSY thrown by a read-only open', () => { + const contended = contendedDatabase() + let thrown: unknown + try { + new SyncDatabase(contended.path, { readonly: true, timeout: 0 }) + .prepare('SELECT id FROM session') + .all() + } catch (error) { + thrown = error + } finally { + contended.release() + } + + expect((thrown as { errcode?: number }).errcode).toBe(5) + expect(isTransientSqliteContention(thrown)).toBe(true) + }) + + it('recognizes a relayed message with no errcode, as the Codex heal pass sees it', () => { + expect( + isTransientSqliteContention('codex app-server thread/read failed: database is locked') + ).toBe(true) + expect(isTransientSqliteContention(new Error('SQLITE_LOCKED: table is locked'))).toBe(true) + }) + + it('reads extended result codes through their primary code', () => { + // SQLITE_BUSY_SNAPSHOT (517) and SQLITE_BUSY_RECOVERY (261) both pack 5. + expect(isTransientSqliteContention({ errcode: 517, message: 'busy snapshot' })).toBe(true) + expect(isTransientSqliteContention({ errcode: 261, message: 'recovery' })).toBe(true) + }) + + it('does not treat an unreadable or absent database as contention', () => { + expect(isTransientSqliteContention(new Error('file is not a database'))).toBe(false) + expect( + isTransientSqliteContention({ errcode: 14, message: 'unable to open database file' }) + ).toBe(false) + }) +}) + +describe('classifySqliteReadFailure', () => { + it('classifies contention as retryable regardless of the file evidence', () => { + const error = { errcode: 5, message: 'database is locked' } + + expect(classifySqliteReadFailure({ error, databaseFileExists: true })).toBe('contended') + expect(classifySqliteReadFailure({ error, databaseFileExists: false })).toBe('contended') + }) + + it('classifies SQLITE_CANTOPEN against a present database as an unreachable wal-index', () => { + expect( + classifySqliteReadFailure({ + error: { errcode: 14, message: 'unable to open database file' }, + databaseFileExists: true + }) + ).toBe('wal-index-unavailable') + }) + + it('does not blame the wal-index when the database file itself is gone', () => { + expect( + classifySqliteReadFailure({ + error: { errcode: 14, message: 'unable to open database file' }, + databaseFileExists: false + }) + ).toBe('unreadable') + }) + + it('falls back to unreadable for anything else', () => { + expect( + classifySqliteReadFailure({ + error: { errcode: 11, message: 'database disk image is malformed' }, + databaseFileExists: true + }) + ).toBe('unreadable') + }) +}) diff --git a/src/main/sqlite/sqlite-read-failure.ts b/src/main/sqlite/sqlite-read-failure.ts new file mode 100644 index 00000000000..f96176eee0a --- /dev/null +++ b/src/main/sqlite/sqlite-read-failure.ts @@ -0,0 +1,77 @@ +// Why (#15036): a read-only SQLite open can fail for reasons that demand +// opposite responses, and both arrive as an opaque driver string. Contention is +// transient — retry later, never persist it as a permanent failure. A missing +// wal-index is structural — retrying forever cannot fix it. + +/** Primary result codes; extended codes pack the primary code in the low byte. */ +const SQLITE_BUSY = 5 +const SQLITE_LOCKED = 6 +const SQLITE_CANTOPEN = 14 + +// Shared with the Codex index-heal pass, which only ever sees a relayed message +// string (app-server RPC drops `errcode`), so message matching is not optional. +const CONTENTION_MESSAGE = /SQLITE_(?:BUSY|LOCKED)|database (?:is )?(?:busy|locked)/i + +function primaryErrcode(error: unknown): number | null { + if (!error || typeof error !== 'object' || !('errcode' in error)) { + return null + } + const errcode = (error as { errcode?: unknown }).errcode + return typeof errcode === 'number' && Number.isFinite(errcode) ? errcode & 0xff : null +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +/** + * True when a SQLite failure means "someone else holds the database right now". + * @param error - The thrown value from a `node:sqlite` call or a relayed message. + * @returns Whether the caller should retry later rather than record a failure. + */ +export function isTransientSqliteContention(error: unknown): boolean { + const errcode = primaryErrcode(error) + if (errcode === SQLITE_BUSY || errcode === SQLITE_LOCKED) { + return true + } + return CONTENTION_MESSAGE.test(errorText(error)) +} + +export type SqliteReadFailureKind = + /** Another connection holds the lock; the same read can succeed later. */ + | 'contended' + /** + * The database file itself is present, but SQLite still could not open the + * set of files it needs — in practice the `-wal`/`-shm` pair a read-only WAL + * open requires, which shares that cannot host SQLite shared memory (9p + * `\\wsl.localhost`, SMB, SSHFS) refuse. Retrying cannot fix it. + */ + | 'wal-index-unavailable' + /** Anything else: corrupt file, wrong schema, permissions. */ + | 'unreadable' + +/** + * Classify why a read-only SQLite open or query failed. + * + * Pure: the caller supplies the "does the database file exist" evidence, so + * neither branch needs a filesystem probe of its own. That matters on a WSL 9p + * share, where an extra sync stat is exactly the hang the transcript gate exists + * to prevent. + * @param args.error - The thrown value. + * @param args.databaseFileExists - Whether the main database file was present. + * @returns Which of the three failure modes the error represents. + */ +export function classifySqliteReadFailure(args: { + error: unknown + databaseFileExists: boolean +}): SqliteReadFailureKind { + if (isTransientSqliteContention(args.error)) { + return 'contended' + } + // SQLITE_CANTOPEN against a database that is right there names a companion + // file — the wal-index a read-only WAL open needs — not the database itself. + if (primaryErrcode(args.error) === SQLITE_CANTOPEN && args.databaseFileExists) { + return 'wal-index-unavailable' + } + return 'unreadable' +} diff --git a/src/main/sqlite/sync-database.test.ts b/src/main/sqlite/sync-database.test.ts index 34677095b26..fe3ba7e388d 100644 --- a/src/main/sqlite/sync-database.test.ts +++ b/src/main/sqlite/sync-database.test.ts @@ -1,11 +1,13 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { Worker } from 'node:worker_threads' import { afterEach, describe, expect, it } from 'vitest' import SyncDatabase from './sync-database' const temporaryDirectories: string[] = [] const openDatabases: SyncDatabase.Database[] = [] +const lockHolders: Worker[] = [] async function createDatabase(): Promise { const directory = await mkdtemp(join(tmpdir(), 'orca-sync-database-')) @@ -20,6 +22,7 @@ async function createDatabase(): Promise { } afterEach(async () => { + await Promise.all(lockHolders.splice(0).map((worker) => worker.terminate())) for (const db of openDatabases.splice(0)) { try { db.close() @@ -151,3 +154,80 @@ describe('SyncDatabase statement cache', () => { ) }) }) + +// Why (#15036): the OpenCode readers opened read-only with no busy timeout, so a +// contended DB failed in ~1 ms and emptied the whole panel. These pin that the +// wrapper really forwards `timeout` into sqlite3_busy_timeout. +describe('SyncDatabase read-only opens under contention', () => { + // The lock holder must live on another thread: sqlite3_busy_timeout sleeps + // synchronously, so a same-thread timer could never fire to release it. + const WRITER_SOURCE = ` + const { parentPort, workerData } = require('node:worker_threads') + const { DatabaseSync } = require('node:sqlite') + const db = new DatabaseSync(workerData.path) + db.exec('PRAGMA journal_mode=DELETE') + db.exec("CREATE TABLE items (id TEXT PRIMARY KEY); INSERT INTO items VALUES ('a')") + db.exec('BEGIN EXCLUSIVE') + db.exec("INSERT INTO items VALUES ('b')") + parentPort.postMessage('locked') + setTimeout(() => { + db.exec('COMMIT') + db.close() + parentPort.postMessage('released') + }, workerData.holdMs) + ` + + async function contendedDatabase(holdMs: number): Promise<{ path: string }> { + const directory = await mkdtemp(join(tmpdir(), 'orca-sync-database-busy-')) + temporaryDirectories.push(directory) + const path = join(directory, 'contended.db') + const worker = new Worker(WRITER_SOURCE, { eval: true, workerData: { path, holdMs } }) + lockHolders.push(worker) + await new Promise((resolve, reject) => { + worker.once('message', () => resolve()) + worker.once('error', reject) + }) + return { path } + } + + it('fails immediately with SQLITE_BUSY when no timeout is given', async () => { + const contended = await contendedDatabase(10_000) + const startedAt = Date.now() + + let thrown: unknown + try { + new SyncDatabase(contended.path, { readonly: true }).prepare('SELECT id FROM items').all() + } catch (error) { + thrown = error + } + + expect((thrown as { errcode?: number }).errcode).toBe(5) + expect((thrown as Error).message).toContain('database is locked') + expect(Date.now() - startedAt).toBeLessThan(200) + }) + + it('waits for the configured busy timeout before giving up', async () => { + const contended = await contendedDatabase(10_000) + const startedAt = Date.now() + + expect(() => + new SyncDatabase(contended.path, { readonly: true, timeout: 400 }) + .prepare('SELECT id FROM items') + .all() + ).toThrow(/database is locked/) + + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(350) + }) + + it('succeeds once the writer commits inside the busy timeout', async () => { + const contended = await contendedDatabase(150) + + const reader = new SyncDatabase(contended.path, { readonly: true, timeout: 5_000 }) + openDatabases.push(reader) + + expect(reader.prepare('SELECT id FROM items ORDER BY id').all()).toEqual([ + { id: 'a' }, + { id: 'b' } + ]) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.test.ts index f4e4755eaf7..80ab908cd1a 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.test.ts @@ -78,6 +78,24 @@ describe('aiVaultScanNoticeIssues', () => { expect(skippedAiVaultTranscriptCount(partial)).toBe(1) }) + // #15036: a locked opencode.db holds every OpenCode session, so reporting it + // as "1 transcript skipped" both understates the loss and buries the cause in + // a bare driver string. + it('gives an unreadable OpenCode database its own row instead of a skipped count', () => { + const sourceIssue = { + agent: 'opencode' as const, + kind: 'scope' as const, + path: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.local\\share\\opencode\\opencode.db', + message: + 'OpenCode is writing to opencode.db right now, so its history was skipped. It is read again on the next refresh.' + } + const empty = result([], [sourceIssue]) + + expect(skippedAiVaultTranscriptCount(empty)).toBe(0) + expect(skippedAiVaultTranscriptReasons(empty)).toEqual([]) + expect(aiVaultScanNoticeIssues(empty)).toEqual([sourceIssue]) + }) + it('does not repeat the blocking issue as a notice row', () => { const hostIssue = { executionHostId: 'ssh:dev-box' as const, diff --git a/src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.ts b/src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.ts index d73046f5580..5bf60d3a17a 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.ts @@ -12,7 +12,9 @@ export function blockingAiVaultScanIssue( // Host and scope issues carry their own scanner-authored copy, so they get their // own rows instead of being counted as skipped transcripts — a partial scan // (one SSH host down, rest fine) must not report a connectivity failure as a -// skipped transcript file. +// skipped transcript file, and an unreadable *source* (a whole locked +// opencode.db holding every OpenCode session) must not read as "1 transcript +// skipped". export function aiVaultScanNoticeIssues(result: AiVaultListResult | null): AiVaultScanIssue[] { if (!result) { return [] From 5631aa00dd55787b8bc6e8074aad0a42ee7674d5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:18:51 -0700 Subject: [PATCH 050/152] =?UTF-8?q?feat(orcad):=20items=202=E2=80=937=20?= =?UTF-8?q?=E2=80=94=20degradation,=20natives,=20daemon,=20ops,=20deploy?= =?UTF-8?q?=20(#16398)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ports): stop joining an undefined resourcesPath on a non-Electron host `resolveWorkerEntryPath` branched on `isPackaged` alone and joined `process.resourcesPath`. orcad reports `isPackaged` true — correctly, it is a production build, and ~15 consumers read it that way to gate HTTPS-only skill downloads and the real CLI name — but `process.resourcesPath` is Electron-only and `undefined` under plain Node. So the packaged branch threw `TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string` where a clean "worker unavailable" was the honest outcome. The type said `resourcesPath: string`, which is how it went unnoticed; it is now `string | undefined`, so the compiler carries the fact. A host with no Electron resources tree has no asar to look in, so it falls back to the module directory and lets the caller report a missing worker. Found by the item 1 agent while auditing the same `isPackaged` defect class in the watcher. Verified in both directions: reverting the guard reproduces the TypeError. * feat(orcad): prove node-pty loads before anything requires it Of the two ways node-pty fails, only one is catchable. A missing module throws MODULE_NOT_FOUND. A module built against the wrong libc or Node ABI is refused by the dynamic loader, and in the worst case takes the process down before any handler exists — that is #9902, which crashed the desktop app on Ubuntu 20.04 before a window appeared. There was no libc or ABI precondition anywhere in the tree. So orcad now proves the load in a CHILD process, from main.ts, before anything requires node-pty. Whatever the child does — throw, abort, die on a signal — is data rather than our own death, and the operator gets a sentence naming the host's libc, Node ABI and prebuild slot plus the command to run. Proven-unloadable exits 78 (EX_CONFIG), so a supervisor does not restart an unequippable host forever. A probe that never answered is unverifiable, not blocked: refusing to boot on an inconclusive signal would take down hosts that work. The child dlopens the file node-pty would have chosen, before requiring the package. node-pty's loader walks several directories and rethrows only the LAST error, so a refused binary reads as "Cannot find module ./prebuilds/..." — which sends the operator to install a module that is already there. It also reports through stdout: node echoes the whole -e source above a stack trace, and matching tokens against stderr made the probe's own source text answer for the verdict. Verdicts reach clients as a terminal_unavailable degradation alongside the existing browser_unavailable one, through the same cause-registry shape. degradations[].code is now an open vocabulary; clients already render only `message`. Prebuilds are compiled from PATCHED sources — the patch IS the glibc-floor fix, so an upstream tarball reproduces #9902 — into linux-{x64,arm64}-{glibc,musl} and darwin-{x64,arm64} slots. libc is in the slot name because node-pty's loader falls back to prebuilds/- and cannot tell glibc from musl. orcad installs the matching slot at boot, so a host with no compiler serves terminals. The relay's five pure toolchain-diagnosis functions moved to a transport-free module so the Node bundle can reuse them without dragging ssh2 in behind them; the relay keeps its API by re-export. macOS gets `xcode-select --install` rather than the cross-distro apt/dnf/pacman/apk menu, every line of which is wrong there. * test(orcad): pin the node-pty precondition to ground truth, not a prepared host CI's test shard runs `vitest` directly, so `ensure-native-runtime --runtime=node` never prepares node-pty for the Node ABI — `degraded` is the correct verdict there, and asserting 'ok' encoded an environment the shard does not have. Asserting whatever it returned would be vacuous, so the expectation is now derived from an independent require() of node-pty. Verified it still bites: forcing the precondition to always report 'ok' fails the suite. * feat(orcad): run the terminal daemon, and the ops contract around it orcad declared `canRecoverPersistentLocalPtys: () => false` because it did not run the terminal daemon, so every restart, update and rollback SIGKILLed every running terminal — on the host whose selling point is that work survives the client going away. That is the one property `ssh-execution-boundary.md` recommends the peer model for. Item 4 — the daemon: - Port the launch path off electron: `daemon-init.ts`, `daemon-host-relocation.ts` and `observability/logs-directory.ts` now read the `AppEnvironment` port. Relocation additionally asks whether the app root is an asar archive rather than whether the build is packaged, so a Node host answering `isPackaged() === true` no longer walks into an Electron-only NSIS-escape path (same precedent as `parcel-watcher-entry-path.ts`). - `build-orcad.mjs` emits `daemon-entry.js` beside `orcad.js`, scans the forked children's metafiles for electron/node:sqlite, and load-checks the child under plain Node. - orcad spawns and adopts the daemon; shutdown disconnects and never kills it. `canRecoverPersistentLocalPtys` now reads the live provider and is false under degraded routing, where fresh terminals would die with the process. Item 3 — the ops contract (docs/reference/orcad-operations.md): - Bind policy: `--bind`, default loopback, pinned so neither `orca serve`'s wide default nor the connected-device widen can override it, and so a paired client cannot rebind the listener from outside. - Instance lock on the data root before profile load, scoped to the runtime role so it never refuses a restart that a live daemon makes worthwhile. - Supervision: exit codes a supervisor can act on (78 = do not retry), second-signal escalation, a shutdown deadline, and crash-loop containment on daemon respawn. - Health in the readiness payload: build hash, Node ABI, and a PTY self-test that spans both processes — the daemon spawns a real PTY in its own process and the verdict crosses its socket. Both bundle load-checks now assert on exit codes: these bundles are minified onto one line, so Node's uncaught-exception report echoes every string literal in the bundle and the previous message match passed against a bundle that never loaded. * feat(orcad): deploy, activate and roll back a versioned orcad install Plan items 6 and 7 from docs/design/shipping-orcad.html. Install reuses the relay's transaction verbatim — per-version lock, staged SFTP write, .install-complete sentinel, stale-lock recovery — under a parameterized namespace, so orcad-/ sits beside relay-/ permanently (§06). Parameterizing GC is the trap that creates: each model now collects only its own directories, enforced twice (prefix-scoped remote listing plus a local ownership re-check), and a client picks its model from how the host is registered, never from what it finds on disk. Activation is separate from installation, because a versioned directory selects nothing. A candidate is launched, publishes orca_server_ready, and only becomes active if its cross-process health payload passes: right build hash, listening, daemon live, PTY self-test green. A rejected candidate is stopped and the incumbent restarted, so a careful deploy cannot cause the outage it was being careful about. Update and rollback are shaped by the daemon. An update restarts orcad, the daemon outlives it, and the surviving daemon was forked from the outgoing bundle — so live terminals defer the update rather than proceed, and GC pins the active version, the rollback target and the live daemon's bundle. Orca's persisted state carries no schema version, so rollback restores a pre-activation snapshot rather than trusting backward-readability; the point past which it is unsafe is the first terminal created after activation, which the snapshot cannot describe and the surviving daemon still owns. Running the generated shell for real found two bugs the text assertions missed: tar members re-quoted inside a shell variable captured nothing, and kill -0 reports a zombie as alive. * test(orcad): assert the precondition is self-consistent, not environment-shaped The real-host case cannot predict a status: CI's shard runs vitest directly, so node-pty is never built for the Node ABI and 'degraded' is correct there, while a prepared checkout gives 'ok'. The previous attempt used require('node-pty') as ground truth, which resolves the JS wrapper while the native binding loads lazily — it proved strictly less than the precondition checks, and failed CI for exactly that reason. What is invariant on a host with node-pty installed: never 'blocked', and never a degraded verdict carrying an unestablished reason. The injected-input tests keep the logic coverage. * fix(orcad): drop an eslint-disable the rule no longer needs * test(orcad): separate slot placement from the load verdict Both remaining CI failures were the same shape: tests reaching into node_modules for a pty.node that only exists after `ensure-native-runtime --runtime=node`, which CI's shard never runs because it invokes vitest directly. Slot *placement* is the logic worth checking on every host, so it now uses a synthetic payload and asserts the verdict stays honest about not loading. The three assertions that genuinely need a Node-ABI binding are gated on it existing. Verified: breaking slot installation fails both placement tests; with the real pty.node hidden the file is 17 passed / 3 skipped instead of ENOENT. * test(orcad): gate the load-dependent cases on a real load, not on the file existing CI ships a pty.node built for Electron's ABI, so existsSync was true while require still failed — the gate ran exactly the tests that host can never satisfy. It now probes the binding in a child process, so a bad one cannot take the runner down. The self-consistency assertion also allowed too little: 'blocked' is the honest verdict for a corrupt binding, alongside 'ok' on a prepared host and 'degraded' on an unprepared one. What stays invariant is that anything other than 'ok' names an established cause, so a terminal is never declined for a reason nobody worked out. Verified against all three host states: prepared (19 passed), unprepared, and a corrupt binding (17 passed / 3 skipped, no failures). * test(orcad): gate on the whole premise — binding AND spawn-helper CI has a loadable pty.node but no spawn-helper, and a slot without the helper is legitimately 'degraded'. So the previous gate let a test run whose premise ('a complete slot yields ok') that host cannot satisfy. Verified in both states: with the helper present 19 pass; with it removed the load-dependent cases skip (17 passed / 3 skipped) instead of failing. * fix(orcad): preserve degradation types after rebase --- .gitignore | 1 + config/scripts/build-orcad-prebuilds.mjs | 212 ++++++++++ config/scripts/build-orcad-prebuilds.test.mjs | 121 ++++++ config/scripts/build-orcad.mjs | 161 ++++++-- docs/reference/linux-glibc-compatibility.md | 26 ++ docs/reference/orcad-operations.md | 190 +++++++++ package.json | 1 + .../daemon/daemon-entry-path-layouts.test.ts | 115 ++++++ src/main/daemon/daemon-entry.ts | 5 +- .../daemon/daemon-host-relocation.test.ts | 65 +++- src/main/daemon/daemon-host-relocation.ts | 32 +- .../daemon-init-child-readiness.test.ts | 1 - .../daemon-init-child-startup-failure.test.ts | 1 - .../daemon/daemon-init-dependency-mocks.ts | 15 +- .../daemon-init-endpoint-adoption.test.ts | 1 - src/main/daemon/daemon-init-fresh-import.ts | 13 + ...mon-init-live-session-preservation.test.ts | 1 - src/main/daemon/daemon-init-mock-types.ts | 1 + ...mon-init-packaged-bundle-staleness.test.ts | 1 - .../daemon-init-provider-installation.test.ts | 23 +- .../daemon-init-replacement-reporting.test.ts | 1 - .../daemon-init-restart-sequence.test.ts | 1 - .../daemon-init-wedged-daemon-grace.test.ts | 1 - src/main/daemon/daemon-init.ts | 95 ++++- .../daemon/daemon-respawn-throttle.test.ts | 95 +++++ src/main/daemon/daemon-respawn-throttle.ts | 84 ++++ src/main/daemon/daemon-spawner.ts | 24 ++ src/main/observability/logs-directory.ts | 34 +- src/main/orcad/main-preflight-order.test.ts | 30 ++ src/main/orcad/main.ts | 23 +- src/main/orcad/native-host-abi.test.ts | 110 ++++++ src/main/orcad/native-host-abi.ts | 127 ++++++ src/main/orcad/node-pty-prebuilt-slot.test.ts | 119 ++++++ src/main/orcad/node-pty-prebuilt-slot.ts | 113 ++++++ src/main/orcad/node-pty-precondition.test.ts | 358 +++++++++++++++++ src/main/orcad/node-pty-precondition.ts | 365 ++++++++++++++++++ src/main/orcad/orcad-bind-address.test.ts | 45 +++ src/main/orcad/orcad-bind-address.ts | 66 ++++ .../orcad-bundle-native-load-order.test.ts | 91 +++++ .../orcad/orcad-daemon-supervision.test.ts | 85 ++++ src/main/orcad/orcad-daemon-supervision.ts | 67 ++++ src/main/orcad/orcad-entry.ts | 107 ++++- src/main/orcad/orcad-health.test.ts | 152 ++++++++ src/main/orcad/orcad-health.ts | 155 ++++++++ src/main/orcad/orcad-instance-lock.test.ts | 151 ++++++++ src/main/orcad/orcad-instance-lock.ts | 324 ++++++++++++++++ src/main/orcad/orcad-launch-contract.test.ts | 43 +++ src/main/orcad/orcad-native-preflight.test.ts | 125 ++++++ src/main/orcad/orcad-native-preflight.ts | 72 ++++ .../ports/port-scan-command-client.test.ts | 26 ++ src/main/ports/port-scan-command-client.ts | 10 +- .../runtime/native-terminal-availability.ts | 51 +++ src/main/runtime/orca-runtime.test.ts | 39 ++ src/main/runtime/orca-runtime.ts | 7 + .../runtime-rpc-websocket-bind-host.test.ts | 103 +++++ src/main/runtime/runtime-rpc.ts | 32 +- src/main/server/serve-readiness.test.ts | 64 +++ src/main/server/serve-readiness.ts | 24 +- src/main/ssh/build-toolchain-diagnosis.ts | 166 ++++++++ src/main/ssh/orcad-activation-gate.test.ts | 164 ++++++++ src/main/ssh/orcad-activation-gate.ts | 155 ++++++++ src/main/ssh/orcad-activation-record-store.ts | 48 +++ src/main/ssh/orcad-activation-record.test.ts | 102 +++++ src/main/ssh/orcad-activation-record.ts | 175 +++++++++ src/main/ssh/orcad-local-build-hash.ts | 22 ++ src/main/ssh/orcad-remote-deploy.test.ts | 264 +++++++++++++ src/main/ssh/orcad-remote-deploy.ts | 340 ++++++++++++++++ src/main/ssh/orcad-remote-gc.test.ts | 153 ++++++++ src/main/ssh/orcad-remote-gc.ts | 74 ++++ src/main/ssh/orcad-remote-host-support.ts | 50 +++ src/main/ssh/orcad-remote-launch.test.ts | 121 ++++++ src/main/ssh/orcad-remote-launch.ts | 178 +++++++++ src/main/ssh/orcad-remote-process-control.ts | 73 ++++ src/main/ssh/orcad-remote-rollback.test.ts | 206 ++++++++++ src/main/ssh/orcad-remote-rollback.ts | 244 ++++++++++++ ...-remote-shell-commands.integration.test.ts | 189 +++++++++ src/main/ssh/orcad-state-snapshot.test.ts | 100 +++++ src/main/ssh/orcad-state-snapshot.ts | 173 +++++++++ src/main/ssh/orcad-update-plan.test.ts | 177 +++++++++ src/main/ssh/orcad-update-plan.ts | 234 +++++++++++ .../ssh/remote-install-coexistence.test.ts | 76 ++++ src/main/ssh/remote-install-coexistence.ts | 83 ++++ src/main/ssh/remote-install-gc.ts | 286 ++++++++++++++ src/main/ssh/remote-install-model.test.ts | 70 ++++ src/main/ssh/remote-install-model.ts | 143 +++++++ src/main/ssh/ssh-relay-build-toolchain.ts | 174 +-------- src/main/ssh/ssh-relay-install-namespace.ts | 28 +- src/main/ssh/ssh-relay-versioned-install.ts | 252 ++---------- src/main/ssh/ssh-remote-commands.ts | 51 ++- src/shared/orcad-artifacts.ts | 42 ++ src/shared/runtime-capability-degradation.ts | 64 +++ src/shared/runtime-session-contracts.ts | 33 +- src/shared/runtime-types.ts | 12 +- 93 files changed, 8587 insertions(+), 540 deletions(-) create mode 100644 config/scripts/build-orcad-prebuilds.mjs create mode 100644 config/scripts/build-orcad-prebuilds.test.mjs create mode 100644 docs/reference/orcad-operations.md create mode 100644 src/main/daemon/daemon-entry-path-layouts.test.ts create mode 100644 src/main/daemon/daemon-respawn-throttle.test.ts create mode 100644 src/main/daemon/daemon-respawn-throttle.ts create mode 100644 src/main/orcad/main-preflight-order.test.ts create mode 100644 src/main/orcad/native-host-abi.test.ts create mode 100644 src/main/orcad/native-host-abi.ts create mode 100644 src/main/orcad/node-pty-prebuilt-slot.test.ts create mode 100644 src/main/orcad/node-pty-prebuilt-slot.ts create mode 100644 src/main/orcad/node-pty-precondition.test.ts create mode 100644 src/main/orcad/node-pty-precondition.ts create mode 100644 src/main/orcad/orcad-bind-address.test.ts create mode 100644 src/main/orcad/orcad-bind-address.ts create mode 100644 src/main/orcad/orcad-bundle-native-load-order.test.ts create mode 100644 src/main/orcad/orcad-daemon-supervision.test.ts create mode 100644 src/main/orcad/orcad-daemon-supervision.ts create mode 100644 src/main/orcad/orcad-health.test.ts create mode 100644 src/main/orcad/orcad-health.ts create mode 100644 src/main/orcad/orcad-instance-lock.test.ts create mode 100644 src/main/orcad/orcad-instance-lock.ts create mode 100644 src/main/orcad/orcad-launch-contract.test.ts create mode 100644 src/main/orcad/orcad-native-preflight.test.ts create mode 100644 src/main/orcad/orcad-native-preflight.ts create mode 100644 src/main/runtime/native-terminal-availability.ts create mode 100644 src/main/ssh/build-toolchain-diagnosis.ts create mode 100644 src/main/ssh/orcad-activation-gate.test.ts create mode 100644 src/main/ssh/orcad-activation-gate.ts create mode 100644 src/main/ssh/orcad-activation-record-store.ts create mode 100644 src/main/ssh/orcad-activation-record.test.ts create mode 100644 src/main/ssh/orcad-activation-record.ts create mode 100644 src/main/ssh/orcad-local-build-hash.ts create mode 100644 src/main/ssh/orcad-remote-deploy.test.ts create mode 100644 src/main/ssh/orcad-remote-deploy.ts create mode 100644 src/main/ssh/orcad-remote-gc.test.ts create mode 100644 src/main/ssh/orcad-remote-gc.ts create mode 100644 src/main/ssh/orcad-remote-host-support.ts create mode 100644 src/main/ssh/orcad-remote-launch.test.ts create mode 100644 src/main/ssh/orcad-remote-launch.ts create mode 100644 src/main/ssh/orcad-remote-process-control.ts create mode 100644 src/main/ssh/orcad-remote-rollback.test.ts create mode 100644 src/main/ssh/orcad-remote-rollback.ts create mode 100644 src/main/ssh/orcad-remote-shell-commands.integration.test.ts create mode 100644 src/main/ssh/orcad-state-snapshot.test.ts create mode 100644 src/main/ssh/orcad-state-snapshot.ts create mode 100644 src/main/ssh/orcad-update-plan.test.ts create mode 100644 src/main/ssh/orcad-update-plan.ts create mode 100644 src/main/ssh/remote-install-coexistence.test.ts create mode 100644 src/main/ssh/remote-install-coexistence.ts create mode 100644 src/main/ssh/remote-install-gc.ts create mode 100644 src/main/ssh/remote-install-model.test.ts create mode 100644 src/main/ssh/remote-install-model.ts create mode 100644 src/shared/orcad-artifacts.ts create mode 100644 src/shared/runtime-capability-degradation.ts diff --git a/.gitignore b/.gitignore index efaee0a2e38..a08e1058eeb 100644 --- a/.gitignore +++ b/.gitignore @@ -103,6 +103,7 @@ docs/** !docs/reference/git-compatibility.md !docs/reference/headless-linux-server.md !docs/reference/linux-glibc-compatibility.md +!docs/reference/orcad-operations.md !docs/reference/relay-grace-time-reconfiguration.md !docs/reference/windows-process-enumeration.md !docs/reference/wsl-runner-verification.md diff --git a/config/scripts/build-orcad-prebuilds.mjs b/config/scripts/build-orcad-prebuilds.mjs new file mode 100644 index 00000000000..679625b9f6d --- /dev/null +++ b/config/scripts/build-orcad-prebuilds.mjs @@ -0,0 +1,212 @@ +#!/usr/bin/env node +/** + * Build one node-pty prebuilt for the CURRENT platform/arch/libc and file it in orcad's + * prebuilds matrix, so a deployment target needs no C/C++ toolchain. + * + * node-pty is the only ABI-sensitive native module orcad requires. It is also PATCHED in + * this repo (config/patches/node-pty@1.1.0.patch), and that patch is the glibc-floor fix: + * `.symver` pins on openpty/forkpty/pthread_sigmask plus the `--no-as-needed` ldflags that + * keep libutil/libpthread in DT_NEEDED. An upstream prebuilt has none of it and reproduces + * #9902. So the matrix is compiled from patched sources here, and this script refuses to + * run if the patch is not in the tree it is about to compile. + * + * orcad pins its own Node runtime, so the ABI dimension is fixed and the matrix varies + * only platform/arch/libc: + * linux-x64-glibc, linux-arm64-glibc, linux-x64-musl, linux-arm64-musl, + * darwin-x64, darwin-arm64 + * + * CI runs this once per slot, each inside the container that owns that libc/arch, and + * merges the resulting `out/orcad/prebuilds` trees. `--slot=` forces the label so + * the glibc/musl distinction is recorded from the container rather than detected. + * + * Usage: + * node config/scripts/build-orcad-prebuilds.mjs [--slot=linux-x64-musl] + * node config/scripts/build-orcad-prebuilds.mjs --require-slots # release gate + */ +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import process from 'node:process' +import { spawnSync } from 'node:child_process' + +const require = createRequire(import.meta.url) +const ROOT = join(import.meta.dirname, '..', '..') +const PREBUILDS_DIR = join(ROOT, 'out', 'orcad', 'prebuilds') + +/** Every slot a shipped matrix must fill. The single source of truth for the matrix. */ +export const MATRIX_SLOTS = [ + 'linux-x64-glibc', + 'linux-arm64-glibc', + 'linux-x64-musl', + 'linux-arm64-musl', + 'darwin-x64', + 'darwin-arm64' +] + +/** + * Why the report header and not `ldd`: `glibcVersionRuntime` is present only when the + * process is linked against glibc, and musl images have no `ldd` worth parsing. + */ +export function detectLibc(platform = process.platform, header = readReportHeader()) { + if (platform !== 'linux') { + return 'none' + } + return header && typeof header === 'object' && 'glibcVersionRuntime' in header ? 'glibc' : 'musl' +} + +function readReportHeader() { + try { + return process.report?.getReport?.()?.header + } catch { + return undefined + } +} + +export function slotName(argv = process.argv, platform = process.platform, arch = process.arch) { + const forced = argv.find((arg) => arg.startsWith('--slot=')) + if (forced) { + return forced.slice('--slot='.length) + } + const libc = detectLibc(platform) + return libc === 'none' ? `${platform}-${arch}` : `${platform}-${arch}-${libc}` +} + +/** + * The patch is what holds the Ubuntu 20.04 floor. Compiling without it produces a binary + * that loads fine on the build host and dies on the target — the exact failure the matrix + * exists to prevent, now baked into a shipped artifact instead of a first-connect error. + */ +export function assertNodePtyPatchApplied(nodePtyDir) { + const bindingGyp = readFileSync(join(nodePtyDir, 'binding.gyp'), 'utf8') + const ptySource = readFileSync(join(nodePtyDir, 'src', 'unix', 'pty.cc'), 'utf8') + const missing = [] + if (!bindingGyp.includes('--no-as-needed,-l:libutil.so.1')) { + missing.push("binding.gyp is missing the '--no-as-needed,-l:libutil.so.1' ldflag") + } + if (!ptySource.includes('.symver openpty,openpty@')) { + missing.push('src/unix/pty.cc is missing the .symver glibc pins') + } + if (missing.length > 0) { + throw new Error( + [ + '[orcad-prebuilds] refusing to build: config/patches/node-pty@1.1.0.patch is not applied.', + ...missing.map((line) => ` - ${line}`), + 'A prebuilt compiled without it will not load on Ubuntu 20.04 (see', + 'docs/reference/linux-glibc-compatibility.md and #9902). Run `pnpm install` to apply patches.' + ].join('\n') + ) + } +} + +export function readManifest(prebuildsDir) { + try { + return JSON.parse(readFileSync(join(prebuildsDir, 'manifest.json'), 'utf8')) + } catch { + return null + } +} + +/** + * Why merge rather than overwrite: CI builds one slot per container and merges the trees. + * A manifest that records only the last slot would erase every other container's record, + * and `--require-slots` would then reject a complete matrix. + */ +export function mergeManifest(existing, next) { + const slots = new Set([...(existing?.slots ?? []), next.slot]) + return { + module: 'node-pty', + version: next.version, + nodeAbi: next.nodeAbi, + slots: [...slots].sort() + } +} + +function nodePtyDir() { + return dirname(require.resolve('node-pty/package.json')) +} + +function compileNodePty(dir) { + const built = join(dir, 'build', 'Release', 'pty.node') + if (existsSync(built)) { + console.log(`[orcad-prebuilds] reusing existing build at ${built}`) + return built + } + console.log('[orcad-prebuilds] compiling node-pty from patched source ...') + const result = spawnSync(process.platform === 'win32' ? 'npx.cmd' : 'npx', ['node-gyp', 'rebuild'], { + cwd: dir, + stdio: 'inherit', + env: process.env, + windowsHide: true + }) + if (result.status !== 0) { + throw new Error(`[orcad-prebuilds] node-gyp rebuild failed (status ${result.status})`) + } + if (!existsSync(built)) { + throw new Error(`[orcad-prebuilds] node-gyp succeeded but ${built} is missing`) + } + return built +} + +function requireSlots() { + const manifest = readManifest(PREBUILDS_DIR) + const have = new Set(manifest?.slots ?? []) + const missing = MATRIX_SLOTS.filter((slot) => !have.has(slot)) + if (missing.length > 0) { + console.error( + `[orcad-prebuilds] matrix incomplete — missing ${missing.join(', ')}. ` + + 'Hosts on those slots fall back to a source build and need a C/C++ toolchain.' + ) + process.exitCode = 1 + return + } + console.log(`[orcad-prebuilds] matrix complete — ${MATRIX_SLOTS.length} slots`) +} + +function build() { + const dir = nodePtyDir() + assertNodePtyPatchApplied(dir) + const slot = slotName() + const slotDir = join(PREBUILDS_DIR, slot) + mkdirSync(slotDir, { recursive: true }) + + const builtBinary = compileNodePty(dir) + copyFileSync(builtBinary, join(slotDir, 'pty.node')) + console.log(`[orcad-prebuilds] stored ${slot}/pty.node`) + + // Why spawn-helper ships too: on Unix node-pty posix_spawns build/Release/spawn-helper, + // so a slot without it installs cleanly and then fails ENOENT the first time a user + // opens a terminal. Windows has no spawn-helper. + if (process.platform !== 'win32') { + const helperSource = join(dirname(builtBinary), 'spawn-helper') + if (!existsSync(helperSource)) { + throw new Error(`[orcad-prebuilds] spawn-helper missing at ${helperSource}`) + } + copyFileSync(helperSource, join(slotDir, 'spawn-helper')) + console.log(`[orcad-prebuilds] stored ${slot}/spawn-helper`) + } + + // The static floor gate, applied to the artifact we are about to ship rather than only + // to the packaged desktop app. objdump is Linux-only, which is where the floor lives. + if (process.platform === 'linux') { + const { verifyLinuxGlibcFloor } = require('./verify-linux-glibc-floor.cjs') + verifyLinuxGlibcFloor(slotDir) + } + + const manifest = mergeManifest(readManifest(PREBUILDS_DIR), { + slot, + version: require('node-pty/package.json').version, + nodeAbi: process.versions.modules + }) + writeFileSync(join(PREBUILDS_DIR, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`) + console.log( + `[orcad-prebuilds] manifest: node-pty ${manifest.version}, ABI ${manifest.nodeAbi}, slots ${manifest.slots.join(', ')}` + ) +} + +if (process.argv[1] && process.argv[1].endsWith('build-orcad-prebuilds.mjs')) { + if (process.argv.includes('--require-slots')) { + requireSlots() + } else { + build() + } +} diff --git a/config/scripts/build-orcad-prebuilds.test.mjs b/config/scripts/build-orcad-prebuilds.test.mjs new file mode 100644 index 00000000000..84c0e83c18f --- /dev/null +++ b/config/scripts/build-orcad-prebuilds.test.mjs @@ -0,0 +1,121 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + assertNodePtyPatchApplied, + detectLibc, + MATRIX_SLOTS, + mergeManifest, + readManifest, + slotName +} from './build-orcad-prebuilds.mjs' + +const PATCHED_BINDING_GYP = "'ldflags': ['-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0,--as-needed']" +const PATCHED_PTY_CC = '__asm__(".symver openpty,openpty@" ORCA_GLIBC_COMPAT_VERSION);' + +const dirs = [] +const stage = (bindingGyp, ptyCc) => { + const dir = mkdtempSync(join(tmpdir(), 'orcad-prebuild-src-')) + dirs.push(dir) + mkdirSync(join(dir, 'src', 'unix'), { recursive: true }) + writeFileSync(join(dir, 'binding.gyp'), bindingGyp) + writeFileSync(join(dir, 'src', 'unix', 'pty.cc'), ptyCc) + return dir +} +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('assertNodePtyPatchApplied', () => { + it('accepts a tree with both halves of the glibc-floor fix', () => { + expect(() => + assertNodePtyPatchApplied(stage(PATCHED_BINDING_GYP, PATCHED_PTY_CC)) + ).not.toThrow() + }) + + it('refuses to build when the ldflags half is missing', () => { + // The .symver pins alone let gcc's --as-needed drop libutil/libpthread from + // DT_NEEDED, which loads on the build host and fails on Ubuntu 20.04 — #9902 again, + // this time baked into a shipped prebuilt. + expect(() => assertNodePtyPatchApplied(stage("'ldflags': []", PATCHED_PTY_CC))).toThrow( + /--no-as-needed,-l:libutil\.so\.1/ + ) + }) + + it('refuses to build when the .symver pins are missing', () => { + expect(() => assertNodePtyPatchApplied(stage(PATCHED_BINDING_GYP, '// upstream'))).toThrow( + /\.symver glibc pins/ + ) + }) + + it('names the patch and the doc so the fix is findable', () => { + expect(() => assertNodePtyPatchApplied(stage("'ldflags': []", '// upstream'))).toThrow( + /config\/patches\/node-pty@1\.1\.0\.patch/ + ) + }) +}) + +describe('slot naming', () => { + it('covers every platform orcad ships to', () => { + expect([...MATRIX_SLOTS].sort()).toEqual([ + 'darwin-arm64', + 'darwin-x64', + 'linux-arm64-glibc', + 'linux-arm64-musl', + 'linux-x64-glibc', + 'linux-x64-musl' + ]) + }) + + it('lets CI force the label so the container decides glibc vs musl', () => { + // Detection inside a container that happens to run a differently-linked Node would + // file the build under the wrong slot. The forced label must beat detection outright, + // so assert against one detection could never produce for this platform/arch. + expect(slotName(['node', 'x', '--slot=linux-x64-glibc'], 'linux', 'arm64')).toBe( + 'linux-x64-glibc' + ) + }) + + it('omits the libc dimension off Linux', () => { + expect(slotName([], 'darwin', 'arm64')).toBe('darwin-arm64') + }) + + it('reads glibc from the report header and musl from its absence', () => { + expect(detectLibc('linux', { glibcVersionRuntime: '2.31' })).toBe('glibc') + expect(detectLibc('linux', {})).toBe('musl') + expect(detectLibc('darwin', { glibcVersionRuntime: '2.31' })).toBe('none') + }) +}) + +describe('mergeManifest', () => { + it('accumulates slots across the per-container CI runs that build them', () => { + // Overwriting would erase every other container's record, and the release gate would + // then reject a matrix that is actually complete. + const first = mergeManifest(null, { slot: 'linux-x64-glibc', version: '1.1.0', nodeAbi: '127' }) + const second = mergeManifest(first, { + slot: 'linux-arm64-musl', + version: '1.1.0', + nodeAbi: '127' + }) + + expect(second.slots).toEqual(['linux-arm64-musl', 'linux-x64-glibc']) + expect(second).toMatchObject({ module: 'node-pty', version: '1.1.0', nodeAbi: '127' }) + }) + + it('does not duplicate a slot rebuilt twice', () => { + const once = mergeManifest(null, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' }) + expect(mergeManifest(once, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' }).slots) + .toEqual(['darwin-arm64']) + }) +}) + +describe('readManifest', () => { + it('returns null instead of throwing when no matrix has been built', () => { + const dir = mkdtempSync(join(tmpdir(), 'orcad-prebuild-manifest-')) + dirs.push(dir) + expect(readManifest(dir)).toBeNull() + }) +}) diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index 05948acd529..69c12f0ecc9 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -9,10 +9,25 @@ */ import { fork, spawnSync } from 'node:child_process' import { build } from 'esbuild' -import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' import { arch, platform, tmpdir } from 'node:os' import { join } from 'node:path' import process from 'node:process' +import { + ORCAD_VERSION, + ORCAD_VERSION_FILENAME, + orcadArtifactFilenames +} from '../../src/shared/orcad-artifacts.ts' const ROOT = join(import.meta.dirname, '..', '..') const OUT_DIR = join(ROOT, 'out', 'orcad') @@ -22,6 +37,11 @@ const ENTRY = join(ROOT, 'src/main/orcad/main.ts') // looks for it in the app root. A deployment has no desktop out/main to fall back to. const WATCHER_ENTRY = join(ROOT, 'src/main/ipc/parcel-watcher-process-entry.ts') const WATCHER_OUT_FILE = join(OUT_DIR, 'parcel-watcher-process-entry.js') +// Why beside orcad.js: orcad forks the terminal daemon so PTYs outlive the runtime process, +// and `getDaemonEntryPath()` probes the app root for this exact filename. Without it every +// orcad restart would SIGKILL every running terminal. +const DAEMON_ENTRY = join(ROOT, 'src/main/daemon/daemon-entry.ts') +const DAEMON_OUT_FILE = join(OUT_DIR, 'daemon-entry.js') const AGENT_BROWSER_NAME = `agent-browser-${platform()}-${arch()}${process.platform === 'win32' ? '.exe' : ''}` const OUT_FILE = join(OUT_DIR, 'orcad.js') const AGENT_BROWSER_SOURCE = join(ROOT, 'node_modules', 'agent-browser', 'bin', AGENT_BROWSER_NAME) @@ -64,20 +84,31 @@ if (process.platform !== 'win32') { chmodSync(AGENT_BROWSER_OUTPUT, 0o755) } -await build({ - entryPoints: [WATCHER_ENTRY], - bundle: true, - platform: 'node', - target: 'node18', - format: 'cjs', - outfile: WATCHER_OUT_FILE, - external: EXTERNAL, - plugins: [externalNativeAddons], - minify: true, - sourcemap: false, - define: { 'process.env.NODE_ENV': '"production"' }, - logLevel: 'error' -}) +/** Why one call per child and not one `outdir` build: esbuild mirrors each entry's source + * directory under `outdir`, and both children must land flat beside orcad.js — that is where + * their runtime resolvers look for them. */ +function buildForkedChild(entryPoint, outfile) { + return build({ + entryPoints: [entryPoint], + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + outfile, + external: EXTERNAL, + plugins: [externalNativeAddons], + metafile: true, + minify: true, + sourcemap: false, + define: { 'process.env.NODE_ENV': '"production"' }, + logLevel: 'error' + }) +} + +const childResults = await Promise.all([ + buildForkedChild(WATCHER_ENTRY, WATCHER_OUT_FILE), + buildForkedChild(DAEMON_ENTRY, DAEMON_OUT_FILE) +]) const result = await build({ entryPoints: [ENTRY], @@ -95,29 +126,36 @@ const result = await build({ logLevel: 'error' }) -const output = Object.values(result.metafile.outputs).find((o) => o.entryPoint) +const output = Object.values(result.metafile.outputs).find( + (o) => o.entryPoint === 'src/main/orcad/main.ts' +) // Why check `original` and not just `path`: when electron is bundleable, esbuild // rewrites `path` to the resolved file under node_modules and the naive check passes // while the package is very much in the bundle. -const electronImporters = new Set() -for (const [file, info] of Object.entries(result.metafile.inputs)) { - for (const imported of info.imports ?? []) { - const specifier = imported.original ?? imported.path - if (specifier === 'electron' || specifier.startsWith('electron/')) { - electronImporters.add(file) - } - } -} -const sqliteImporters = new Set() -for (const [file, info] of Object.entries(result.metafile.inputs)) { - for (const imported of info.imports ?? []) { - const specifier = imported.original ?? imported.path - if (specifier === 'node:sqlite') { - sqliteImporters.add(file) +// Why both metafiles: the forked children ship in the same deployment and run under the +// same plain Node. A daemon-entry that reached electron would fail at fork time, on the +// path whose whole point is that terminals survive. +function collectImporters(metafiles, matches) { + const importers = new Set() + for (const metafile of metafiles) { + for (const [file, info] of Object.entries(metafile.inputs)) { + for (const imported of info.imports ?? []) { + if (matches(imported.original ?? imported.path)) { + importers.add(file) + } + } } } + return importers } +const metafiles = [result.metafile, ...childResults.map((child) => child.metafile)] +const electronImporters = collectImporters( + metafiles, + (specifier) => specifier === 'electron' || specifier.startsWith('electron/') +) +const sqliteImporters = collectImporters(metafiles, (specifier) => specifier === 'node:sqlite') + const graphErrors = [] if (electronImporters.size > 0) { graphErrors.push( @@ -147,23 +185,53 @@ if (graphErrors.length > 0) { // dynamic require, a missing native, a top-level throw. The plain-node-entry-guard // smoke-loads its entries for exactly this reason, and orcad cannot join that guard // because it is an esbuild artifact rather than a rollup input. + // Why an exit code and not a message match: these bundles are minified onto one line, so + // Node's uncaught-exception report echoes that whole line — which contains every string + // literal in the bundle. A crash therefore "matches" any expected message, and a textual + // assertion passes against a bundle that never loaded. const smoke = spawnSync(process.execPath, [OUT_FILE, '--orcad-smoke-load-check'], { encoding: 'utf8', timeout: 60_000 }) const smokeOutput = `${smoke.stdout ?? ''}${smoke.stderr ?? ''}` - if ( - smoke.error || - smoke.signal || - !/Unknown argument: --orcad-smoke-load-check/.test(smokeOutput) - ) { + if (smoke.error || smoke.signal || smoke.status !== 0) { console.error( `[build-orcad] the bundle did not load under plain Node.\n` + - `Expected argv rejection, got signal=${smoke.signal ?? 'none'} ` + + `Expected a clean load-check exit, got status=${smoke.status ?? 'none'} ` + + `signal=${smoke.signal ?? 'none'} ` + `error=${smoke.error?.message ?? 'none'}\n${smokeOutput.slice(0, 2000)}` ) process.exitCode = 1 } + // Why require + parseArgs and not a real daemon: requiring the bundle evaluates every + // top-level import, and calling its exported argv parser proves the entry's own code is + // there rather than a graph that merely resolved. Booting one would need a socket, a + // token and a PTY — `smoke:orcad-terminal` does that end to end, through orcad. + // The verdict is carried by the exit code for the same minification reason as above. + const daemonSmoke = spawnSync( + process.execPath, + [ + '-e', + `const mod = require(${JSON.stringify(DAEMON_OUT_FILE)})\n` + + `if (typeof mod.parseArgs !== 'function') { process.exit(3) }\n` + + `try { mod.parseArgs([]); process.exit(4) } catch { process.exit(0) }` + ], + { + encoding: 'utf8', + timeout: 60_000, + env: { ...process.env, ORCA_DAEMON_ENTRY_LOAD_CHECK: '1' } + } + ) + const daemonSmokeOutput = `${daemonSmoke.stdout ?? ''}${daemonSmoke.stderr ?? ''}` + if (daemonSmoke.error || daemonSmoke.signal || daemonSmoke.status !== 0) { + console.error( + `[build-orcad] the daemon child did not load under plain Node.\n` + + `Expected a clean load check, got status=${daemonSmoke.status ?? 'none'} ` + + `signal=${daemonSmoke.signal ?? 'none'} ` + + `error=${daemonSmoke.error?.message ?? 'none'}\n${daemonSmokeOutput.slice(0, 2000)}` + ) + process.exitCode = 1 + } const watcherFailure = await smokeLoadWatcherChild() if (watcherFailure) { console.error( @@ -173,9 +241,26 @@ if (graphErrors.length > 0) { } } +// Why a content hash and not ORCAD_VERSION alone: the remote install directory is keyed on +// this string, so two different builds carrying one version would share a directory — and an +// already-`.install-complete` dir is never re-uploaded. The deploy would silently run stale +// bytes while reporting the new version. if (process.exitCode !== 1) { + const hash = createHash('sha256') + for (const filename of orcadArtifactFilenames()) { + const artifactPath = join(OUT_DIR, filename) + if (!existsSync(artifactPath)) { + throw new Error( + `orcad declares ${filename} in ORCAD_ARTIFACTS but never emitted it. Add the build ` + + 'step, or drop it from src/shared/orcad-artifacts.ts.' + ) + } + hash.update(readFileSync(artifactPath)) + } + const fullVersion = `${ORCAD_VERSION}+${hash.digest('hex').slice(0, 12)}` + writeFileSync(join(OUT_DIR, ORCAD_VERSION_FILENAME), fullVersion) console.log( - `[build-orcad] ok — ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports.` + `[build-orcad] ok — ${fullVersion}, ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports.` ) } diff --git a/docs/reference/linux-glibc-compatibility.md b/docs/reference/linux-glibc-compatibility.md index 60d855c7372..bf98dbdcb80 100644 --- a/docs/reference/linux-glibc-compatibility.md +++ b/docs/reference/linux-glibc-compatibility.md @@ -82,6 +82,32 @@ libstdc++ floor — its glibc needs are still checked. Speech-to-text therefore needs a host with libstdc++ from GCC 11+ (Ubuntu 21.10 / 22.04 LTS or newer); the app itself still launches on stock 20.04. +**3. Check before loading, on hosts that ship without a compiler (`orcad`).** +The two gates above protect the packaged desktop app, where the binary is built and +verified by the same pipeline. `orcad` is deployed to hosts Orca never built on, so it +adds a runtime precondition +([`src/main/orcad/node-pty-precondition.ts`](../../src/main/orcad/node-pty-precondition.ts)), +run from `main.ts` before anything requires `node-pty`. It loads the addon in a **child +process**, so a binary the loader refuses — or one that aborts outright — is data rather +than this process's death, and the operator gets a sentence naming the host's libc, its +Node ABI, its prebuild slot and the command to run. A proven-unloadable binary exits 78 +(`EX_CONFIG`) instead of reaching the `require`; a probe that never answered is reported +as unverifiable and boots anyway, because a silent probe is not evidence. Whatever it +finds is published in `status.get`'s `degradations[]` under `terminal_unavailable`. + +**4. Ship the binary, built from patched sources.** +[`config/scripts/build-orcad-prebuilds.mjs`](../../config/scripts/build-orcad-prebuilds.mjs) +(`pnpm run build:orcad-prebuilds`, after `build:orcad`) compiles node-pty for the current +host and files it under `out/orcad/prebuilds//`, where a slot is +`linux-{x64,arm64}-{glibc,musl}` or `darwin-{x64,arm64}`. libc is part of the slot name +because node-pty's own loader falls back to `prebuilds/-` and cannot tell +glibc from musl — a glibc binary parked there is loaded on Alpine and dies at `dlopen`. +The script refuses to compile a tree where `config/patches/node-pty@1.1.0.patch` is not +applied: without the patch the prebuilt is a #9902 crash shipped as an artifact rather +than a first-connect error. CI runs it once per slot inside the matching container +(`--slot=` forces the label), merges the trees, and `--require-slots` fails a release with +a hole in the matrix. + ## Adding or upgrading a native dependency - Prefer packages that ship prebuilt binaries compiled against an old toolchain diff --git a/docs/reference/orcad-operations.md b/docs/reference/orcad-operations.md new file mode 100644 index 00000000000..3ef3fdea37a --- /dev/null +++ b/docs/reference/orcad-operations.md @@ -0,0 +1,190 @@ +# Running orcad + +`orcad` is the Orca runtime served from plain Node. This is the contract between it and +whatever supervises it: what it binds, what it owns on disk, who restarts what, and what its +readiness payload actually proves. + +Design background: `docs/design/shipping-orcad.html` §00c and §04. + +## Two long-lived processes, not one + +A deployment is **orcad** plus **the terminal daemon**. + +| | orcad | terminal daemon | +| --- | --- | --- | +| Started by | the supervisor | orcad, detached | +| Owns | RPC, git, worktrees, persistence | every local PTY | +| Lifetime | one supervised run | **outlives orcad** | +| Endpoint | `ws://:` | `/daemon/daemon-v.sock` | + +The daemon outliving orcad is the property the whole peer model is recommended for +(`docs/reference/ssh-execution-boundary.md`): daemon-backed PTYs stay `live` across a runtime +restart, so a restart, an update or a rollback does not destroy running work. Everything +below exists to keep that true. + +**Consequence for supervision:** orcad's shutdown path calls `disconnectDaemon()`, never +`shutdownDaemon()`. A supervisor that reaps orcad's whole process group — systemd's +`KillMode=control-group` — kills the daemon too and turns every restart back into data loss. +Use `KillMode=mixed` (the default) or `process`, and never `--send-sigkill` on the group. + +## Bind policy + +`--bind `, **default `127.0.0.1`**. + +Only literal IPs are accepted; hostnames are refused because DNS would decide which +interface got bound. `localhost` maps to `127.0.0.1`. `0.0.0.0` / `::` are the explicit +opt-ins to network reach, and the startup log says so on every launch. + +The bind is **pinned**, not defaulted. Two things widen the desktop's listener on their own — +`orca serve`'s wide default, and a startup where some device has connected before — and an +unattended host's exposure must be exactly what the operator asked for on every launch. A +mobile pairing offer, which normally rebinds to all interfaces, is refused while the bind is +pinned to loopback and reports `network_exposure_failed` rather than advertising an endpoint +nothing can reach. + +Under the shipping design a client reaches a remote orcad over an SSH local port-forward, so +loopback is the correct default and the pairing credential travels over SSH. + +## Data root and the instance lock + +The data root is `$ORCA_USER_DATA`, else `$XDG_DATA_HOME/Orca`, else `~/.orca`. + +Before the profile index or the store is touched, orcad takes `/orcad.lock`. +It refuses to start when: + +| Code | Meaning | +| --- | --- | +| `orcad_data_root_wrong_owner` | the root is owned by another uid (POSIX) | +| `orcad_data_root_shared` | the root is group/world accessible and could not be tightened | +| `orcad_instance_lock_held` | another live orcad owns this root | +| `orcad_instance_lock_foreign_identity` | the lock belongs to a different identity | +| `orcad_data_root_unusable` | the root cannot be created, stat'd or written | + +A root that is merely too permissive and that we own is tightened to `0700` rather than +refused — orcad stores credentials there unsealed (no OS keyring on this host), so the goal +is a private root, and refusing when we could just fix it helps nobody. We refuse when the +permissions are not ours to fix. Windows is exempt from the owner and mode checks: ACLs are +not expressible as a POSIX mode, and `statSync().mode` there reports a synthesized one. + +A dead holder's record is reclaimed (PID plus process start time, so a recycled PID does not +read as alive). A record belonging to a different identity is never reclaimed. + +**The lock scopes one role — who is the runtime.** It deliberately says nothing about the +daemon, which lives under `/daemon` and fences its own endpoint with its own PID +record. A lock that asked "is any process using this root" would refuse exactly the restarts +a live daemon makes worthwhile. + +## Supervision + +### Who supervises orcad + +An external supervisor (systemd, launchd, a process manager). orcad conforms to it: + +- **Readiness.** One JSON line on stdout (`--json`), `type: "orca_server_ready"`, published + after the listener is bound and the daemon verdict is in. There is no separate readiness + socket; the line is the signal. Set the supervisor's start timeout generously — the daemon + launch has its own retries and can take tens of seconds on a cold host. +- **Shutdown.** `SIGTERM` or `SIGINT` starts a graceful stop. A **second** signal exits + immediately with code 1 rather than being swallowed — a supervisor's second signal means + its first deadline elapsed, and waiting silently is what turns a stop into a `SIGKILL`, + the one teardown that skips the daemon handoff. orcad also imposes its own 15s deadline + and exits 1, so the failure stays attributable instead of arriving as an unlogged kill. +- **Exit codes.** + + | Code | Meaning | Supervisor should | + | --- | --- | --- | + | 0 | clean shutdown | restart per policy | + | 1 | startup or shutdown failure | restart with backoff | + | 78 | configuration fault (bind address, data root, instance lock) | **not** restart | + + 78 is `EX_CONFIG`. Put it in systemd's `RestartPreventExitStatus`: restarting on a data + root owned by someone else is a restart-spin, not a recovery. +- **Logs.** orcad writes human-readable diagnostics to **stderr** and its readiness contract + to **stdout**; the supervisor owns capture and rotation. The daemon, being detached, writes + its own NDJSON lifecycle log to `/logs/daemon.log` (suppressed by + `ORCA_DIAGNOSTICS_DISABLED=1`). Rotation of that file is not implemented — see + [What is not covered](#what-is-not-covered). + +### orcad supervising the daemon + +- **Launch.** Forked detached from `daemon-entry.js` beside `orcad.js`, with its own PID + record, token and socket under `/daemon`. +- **Adoption before spawn.** A daemon already answering the endpoint is adopted, not + replaced, unless it is unhealthy, foreign, or built from a superseded bundle *and* owns no + live sessions. Replacing a healthy daemon kills its PTYs, so code freshness always defers + to live work. +- **Restart.** The adapter respawns the daemon on death, transparently to callers. +- **Crash-loop containment.** At most **5 launches per 60s rolling window** per orcad run; + past that, launches are refused with `daemon_crash_loop` and terminals fail with that + message instead of the process forking forever. The window slides, so a repaired host + recovers without restarting orcad. An operator-initiated daemon restart clears it — that + is the deliberate "try again". +- **No macOS login-session watch.** That watch retires the daemon when the spawning GUI login + session dies. An orcad daemon must survive its SSH session ending. +- **Shutdown.** orcad never stops the daemon. A daemon that was never adopted retires itself + after its adoption window; an adopted one stays resident (see Decommissioning). + +### Decommissioning + +The daemon outliving orcad is deliberate, so stopping orcad does **not** leave the host with +zero Orca processes. A daemon that has been adopted stays resident after its runtime +disconnects — that is what makes the next start a reattach rather than a cold restore. To +retire a host completely, stop orcad and then stop the daemon named by +`health.terminalDaemon.pid`, or delete the data root and let the endpoint go stale. + +## Health + +The readiness payload carries a `health` object: + +``` +buildHash sha256 (16 hex) of the running orcad bundle — build identity that a version + string cannot give, so a rollback that did not replace the file is visible +buildVersion ORCA_VERSION +nodeVersion / nodeAbi process.versions.node / .modules — the ABI native addons must match +platform / arch / pid +terminalDaemon: + state live | degraded | absent + ownsFreshSessions whether NEW terminals are daemon-owned, i.e. survive an orcad restart + pid the live daemon's pid, from its own PID record + buildVersion the build the LIVE daemon was forked from (may legitimately predate + this orcad after an update — reporting orcad's version for both would + hide exactly that) + entryPath / protocolVersion + selfTest { ok, coverage, verdict, durationMs } +``` + +### What the self-test proves + +`selfTest` runs `checkDaemonHealth` against the daemon's socket. It is green only when the +daemon **opened its socket, completed the protocol handshake, and ran `ptySpawnHealth` — a +real short-lived PTY spawned inside the daemon's own process**. It therefore spans both +processes: orcad drives it, the daemon performs it, the verdict crosses the socket. + +- `coverage: 'pty-spawn'` — the full round trip above. +- `coverage: 'handshake'` — **win32 only**, where `checkPtySpawnHealth` returns without + spawning anything. A green verdict there covers the handshake and nothing more. It is + reported separately rather than folded into `ok` so nobody reads it as a PTY round trip. + +`state` is `live` only when the self-test passed **and** `ownsFreshSessions` is true. A +daemon that answers but has fallen back to local spawning for new terminals is `degraded`, +because those terminals die with orcad. A daemon that answered and then failed its spawn +probe is also `degraded`, not `absent`: it still holds live sessions, and calling those +exited would be the verdict `ssh-execution-boundary.md` forbids guessing. + +## What is not covered + +Named here so nothing reads as implemented that is not: + +- **A continuous health endpoint.** `health` is published once, in the readiness payload. A + supervisor's periodic liveness/readiness probe needs an HTTP or RPC surface over the same + `collectOrcadHealth()`; that surface does not exist yet. +- **libc slot.** §04 asks for it in the health payload. It belongs to the native strategy + (plan item 5), which owns libc detection; there is no honest value to publish until then. +- **`degradations[]`.** Plan item 2's contract, not this one. +- **Credential administration** (list / revoke / rotate devices, expiring pending offers, + structured security logging) — §04, not delivered here. +- **Pinned-port fail-closed.** A pinned `--port` still falls back to an OS-assigned port on + conflict. +- **Reconciling `webClientUrl` with reachability** under the loopback default. +- **State-schema rollback rules.** +- **Daemon log rotation.** `/logs/daemon.log` grows unbounded. diff --git a/package.json b/package.json index b87bb21a49a..7b46b369406 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs", "check:runtime-electron-ratchet": "node config/scripts/check-runtime-electron-ratchet.mjs", "build:orcad": "node config/scripts/build-orcad.mjs", + "build:orcad-prebuilds": "node config/scripts/build-orcad-prebuilds.mjs", "smoke:orcad-terminal": "node config/scripts/ensure-native-runtime.mjs --runtime=node && pnpm run build:cli && pnpm run build:orcad && node config/scripts/runtime-serve-terminal-smoke.mjs --target orcad", "smoke:serve-terminal": "node config/scripts/runtime-serve-terminal-smoke.mjs", "check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs", diff --git a/src/main/daemon/daemon-entry-path-layouts.test.ts b/src/main/daemon/daemon-entry-path-layouts.test.ts new file mode 100644 index 00000000000..b960cbf9d8f --- /dev/null +++ b/src/main/daemon/daemon-entry-path-layouts.test.ts @@ -0,0 +1,115 @@ +/** + * Which daemon-entry.js the launcher forks, per deployment layout. + * + * The layout that matters here is orcad's: a packaged host with NO asar, whose bundle root + * holds `orcad.js` and `daemon-entry.js` side by side (config/scripts/build-orcad.mjs emits + * exactly that). Resolving against `out/main` there would fork a path that does not exist, + * and the failure would surface as "terminals do not persist" rather than as a missing file. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { FAKE_APP_PATH, FAKE_DAEMON_ENTRY_PATH } from './daemon-init-test-harness' + +const { + getAppPathMock, + isPackagedMock, + probeSocketExistsMock, + forkMock, + checkDaemonHealthMock, + spawnerInstances, + importFresh, + installDefaultNetConnectStub, + moduleFactories +} = await vi.hoisted(async () => + (await import('./daemon-init-test-harness')).createDaemonInitMocks() +) + +vi.mock('fs', () => moduleFactories.fs()) +vi.mock('child_process', async (importOriginal) => + moduleFactories.childProcess(await importOriginal>()) +) +vi.mock('net', () => moduleFactories.net()) +vi.mock('./daemon-health', () => moduleFactories.daemonHealth()) +vi.mock('./daemon-pid-identity', () => moduleFactories.daemonPidIdentity()) +vi.mock('./daemon-tcc-attribution', () => moduleFactories.daemonTccAttribution()) +vi.mock('./daemon-bundle-staleness', () => moduleFactories.daemonBundleStaleness()) +vi.mock('./daemon-stale-kill', () => moduleFactories.daemonStaleKill()) +vi.mock('./daemon-process-start-time', () => moduleFactories.daemonProcessStartTime()) +vi.mock('./daemon-pid-file-parse', () => moduleFactories.daemonPidFileParse()) +vi.mock('./client', () => moduleFactories.client()) +vi.mock('./daemon-lifecycle-event', () => moduleFactories.daemonLifecycleEvent()) +vi.mock('./daemon-spawner', () => moduleFactories.daemonSpawner()) +vi.mock('./daemon-pty-adapter', () => moduleFactories.daemonPtyAdapter()) +vi.mock('../ipc/pty', () => moduleFactories.ipcPty()) + +const ASAR_APP_PATH = join('/packaged', 'resources', 'app.asar') +const ASAR_UNPACKED_ENTRY = join( + '/packaged', + 'resources', + 'app.asar.unpacked', + 'out', + 'main', + 'daemon-entry.js' +) +const ORCAD_ROOT = join('/opt', 'orcad') +const ORCAD_ADJACENT_ENTRY = join(ORCAD_ROOT, 'daemon-entry.js') + +/** Drive one launch under the given layout and return the entry path that was forked. */ +async function forkedDaemonEntryPath(layout: { + appPath: string + isPackaged: boolean + existingEntry?: string +}): Promise { + // Why after importFresh: it resets these mocks to their defaults on every fresh import. + const mod = await importFresh() + getAppPathMock.mockReturnValue(layout.appPath) + isPackagedMock.mockReturnValue(layout.isPackaged) + probeSocketExistsMock.mockImplementation((p?: string) => p === layout.existingEntry) + checkDaemonHealthMock.mockResolvedValue('unreachable') + await mod.initDaemonPtyProvider() + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise + forkMock.mockImplementationOnce(() => { + throw new Error('stop after entry resolution') + }) + await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow( + 'stop after entry resolution' + ) + return forkMock.mock.calls.at(-1)?.[0] as string +} + +describe('daemon entry path per deployment layout', () => { + beforeEach(() => { + installDefaultNetConnectStub() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('resolves the dev checkout entry under out/main', async () => { + expect(await forkedDaemonEntryPath({ appPath: FAKE_APP_PATH, isPackaged: false })).toBe( + FAKE_DAEMON_ENTRY_PATH + ) + }) + + it('redirects a packaged Electron asar root to app.asar.unpacked', async () => { + expect(await forkedDaemonEntryPath({ appPath: ASAR_APP_PATH, isPackaged: true })).toBe( + ASAR_UNPACKED_ENTRY + ) + }) + + it('forks the entry beside orcad.js on a packaged host with no asar', async () => { + // orcad answers isPackaged() true; the question the resolver must ask is whether the app + // root is an asar archive, not whether the build is packaged. + expect( + await forkedDaemonEntryPath({ + appPath: ORCAD_ROOT, + isPackaged: true, + existingEntry: ORCAD_ADJACENT_ENTRY + }) + ).toBe(ORCAD_ADJACENT_ENTRY) + }) +}) diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index addfa50b16a..72537f64cbf 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -327,8 +327,9 @@ async function main(): Promise { warmWindowsConptyOnce() } -// Only auto-run when executed directly (not imported for testing) -const isDirectExecution = !process.env.VITEST +// Only auto-run when executed directly (not imported for testing, or for the build guard's +// load check — see config/scripts/build-orcad.mjs). +const isDirectExecution = !process.env.VITEST && !process.env.ORCA_DAEMON_ENTRY_LOAD_CHECK if (isDirectExecution) { main().catch((err) => { console.error('[daemon] Fatal:', err) diff --git a/src/main/daemon/daemon-host-relocation.test.ts b/src/main/daemon/daemon-host-relocation.test.ts index 1d3ab98db90..78e4787c0f6 100644 --- a/src/main/daemon/daemon-host-relocation.test.ts +++ b/src/main/daemon/daemon-host-relocation.test.ts @@ -9,20 +9,30 @@ import { } from 'node:fs' import os from 'node:os' import { dirname, join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' -// Mutable Electron app stub, hoisted so the vi.mock factory closes over it. -const { electronApp } = vi.hoisted(() => ({ - electronApp: { - isPackaged: true, - userDataPath: '', - version: '9.9.9', - getPath: (): string => electronApp.userDataPath, - getVersion: (): string => electronApp.version - } -})) +import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment' -vi.mock('electron', () => ({ app: electronApp })) +// Mutable host stub. Relocation now reads the AppEnvironment port rather than electron's +// `app`, so orcad's daemon launch path can resolve without Electron in the graph. +const hostApp = { + isPackaged: true, + userDataPath: '', + appPath: '', + version: '9.9.9' +} + +function installHostApp(): void { + setAppEnvironment({ + getPath: () => hostApp.userDataPath, + getAppPath: () => hostApp.appPath, + getVersion: () => hostApp.version, + isPackaged: () => hostApp.isPackaged, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + } as AppEnvironment) +} import { buildDaemonHostManifest, @@ -88,9 +98,11 @@ beforeEach(() => { mkdirSync(localAppDataDir, { recursive: true }) process.env.LOCALAPPDATA = localAppDataDir buildInstallFixture(installDir) - electronApp.isPackaged = true - electronApp.userDataPath = userDataDir - electronApp.version = '9.9.9' + hostApp.isPackaged = true + hostApp.userDataPath = userDataDir + hostApp.appPath = join(installDir, 'resources', 'app.asar') + hostApp.version = '9.9.9' + installHostApp() setProcessProp('platform', 'win32') setProcessProp('execPath', join(installDir, 'Orca.exe')) setProcessProp('resourcesPath', join(installDir, 'resources')) @@ -214,6 +226,18 @@ describe('materializeRelocatedDaemonHost', () => { expect(materializeRelocatedDaemonHost()).toBeNull() expect(existsSync(join(localAppDataDir, 'Orca', 'daemon-host'))).toBe(false) }) + + it('does nothing for a packaged host with no asar root (orcad on win32)', () => { + // orcad answers isPackaged() true — it is a shipped build — but it is plain Node: no + // asar, no resourcesPath, and no NSIS updater to escape. Relocation staging a copy of + // an Electron tree that is not there is the isPackaged-honesty defect, and it would + // silently produce a null host on a path whose failures are meant to be visible. + hostApp.appPath = join(installDir, 'resources', 'app') + installHostApp() + expect(materializeRelocatedDaemonHost()).toBeNull() + expect(getRelocatedDaemonHost()).toBeNull() + expect(existsSync(join(localAppDataDir, 'Orca', 'daemon-host'))).toBe(false) + }) }) describe('getRelocatedDaemonHost', () => { @@ -249,6 +273,17 @@ describe('pruneOldDaemonHosts', () => { expect(existsSync(join(root, '2.0.0'))).toBe(true) expect(existsSync(join(root, '1.0.0'))).toBe(false) }) + + it('reclaims nothing for a packaged host with no asar root (orcad on win32)', () => { + const root = join(localAppDataDir, 'Orca', 'daemon-host') + mkdirSync(join(root, '1.0.0'), { recursive: true }) + hostApp.appPath = join(installDir, 'resources', 'app') + installHostApp() + pruneOldDaemonHosts(new Set()) + // A Node host owns no daemon-host tree, so deleting under it would be reaching into a + // directory layout it never created. + expect(existsSync(join(root, '1.0.0'))).toBe(true) + }) }) describe('collectPinnedDaemonVersions', () => { diff --git a/src/main/daemon/daemon-host-relocation.ts b/src/main/daemon/daemon-host-relocation.ts index 4242b292de1..79e8bcf0ad7 100644 --- a/src/main/daemon/daemon-host-relocation.ts +++ b/src/main/daemon/daemon-host-relocation.ts @@ -10,7 +10,7 @@ import { writeFileSync } from 'node:fs' import { dirname, join, win32 as winPath } from 'node:path' -import { app } from 'electron' +import { getAppEnvironment } from '../../shared/app-environment' import { parseDaemonPidFile } from './daemon-pid-file-parse' import { startTimeMatches } from './daemon-process-start-time' @@ -85,9 +85,27 @@ function resolveEntrySourcePath(resourcesPath: string): string { return join(unpackedRoot, 'out', 'main', 'daemon-entry.js') } +/** + * Whether this process is a packaged ELECTRON app on win32 — the only shape relocation + * addresses, because what it escapes is the NSIS updater's kill zone. + * + * Why asar and not isPackaged alone: orcad answers isPackaged() true (it is a shipped build, + * not a dev checkout) while having no asar, no resourcesPath and no NSIS installer. Asking + * whether the app root is an asar archive is the same honesty fix the watcher path uses, and + * it keeps a Node host from staging a copy of an Electron tree it does not have. + */ +function isPackagedElectronWin32(): boolean { + const environment = getAppEnvironment() + return ( + process.platform === 'win32' && + environment.isPackaged() && + environment.getAppPath().includes('app.asar') + ) +} + // Relocation inputs from the live packaged process, or null when it doesn't apply (non-win32, dev, or missing resourcesPath). function collectDaemonHostSources(): DaemonHostSources | null { - if (process.platform !== 'win32' || !app.isPackaged) { + if (!isPackagedElectronWin32()) { return null } const resourcesPath = process.resourcesPath @@ -206,7 +224,7 @@ function hostRootDir(): string { const base = typeof localAppData === 'string' && localAppData.length > 0 ? join(localAppData, LOCAL_HOST_ROOT_NAME) - : app.getPath('userData') + : getAppEnvironment().getPath('userData') return join(base, HOST_SUBDIR) } @@ -219,7 +237,7 @@ export function getRelocatedDaemonHost(): RelocatedDaemonHost | null { if (!sources) { return null } - const version = app.getVersion() + const version = getAppEnvironment().getVersion() const dest = join(hostRootDir(), version) const marker = readMarker(dest) if (!marker || marker.version !== version) { @@ -246,7 +264,7 @@ export function materializeRelocatedDaemonHost(): RelocatedDaemonHost | null { if (!sources) { return null } - const version = app.getVersion() + const version = getAppEnvironment().getVersion() const root = hostRootDir() const dest = join(root, version) const staging = join(root, `${version}.staging-${randomBytes(6).toString('hex')}`) @@ -319,10 +337,10 @@ export function collectPinnedDaemonVersions(runtimeDir: string): Set { * Best-effort — never throws; a locked/staging dir is retried on a future launch. */ export function pruneOldDaemonHosts(pinnedVersions: ReadonlySet): void { - if (process.platform !== 'win32' || !app.isPackaged) { + if (!isPackagedElectronWin32()) { return } - const version = app.getVersion() + const version = getAppEnvironment().getVersion() const root = hostRootDir() let entries try { diff --git a/src/main/daemon/daemon-init-child-readiness.test.ts b/src/main/daemon/daemon-init-child-readiness.test.ts index 132727fff5f..60d3c9c596d 100644 --- a/src/main/daemon/daemon-init-child-readiness.test.ts +++ b/src/main/daemon/daemon-init-child-readiness.test.ts @@ -16,7 +16,6 @@ const { (await import('./daemon-init-test-harness')).createDaemonInitMocks() ) -vi.mock('electron', () => moduleFactories.electron()) vi.mock('fs', () => moduleFactories.fs()) vi.mock('child_process', async (importOriginal) => moduleFactories.childProcess(await importOriginal>()) diff --git a/src/main/daemon/daemon-init-child-startup-failure.test.ts b/src/main/daemon/daemon-init-child-startup-failure.test.ts index 182307d075b..27d23a400d2 100644 --- a/src/main/daemon/daemon-init-child-startup-failure.test.ts +++ b/src/main/daemon/daemon-init-child-startup-failure.test.ts @@ -14,7 +14,6 @@ const { (await import('./daemon-init-test-harness')).createDaemonInitMocks() ) -vi.mock('electron', () => moduleFactories.electron()) vi.mock('fs', () => moduleFactories.fs()) vi.mock('child_process', async (importOriginal) => moduleFactories.childProcess(await importOriginal>()) diff --git a/src/main/daemon/daemon-init-dependency-mocks.ts b/src/main/daemon/daemon-init-dependency-mocks.ts index 40e7ffa1ae6..d920a13866e 100644 --- a/src/main/daemon/daemon-init-dependency-mocks.ts +++ b/src/main/daemon/daemon-init-dependency-mocks.ts @@ -17,10 +17,7 @@ export type { MockAdapter, MockSpawner } from './daemon-init-mock-types' /** The module objects each test file's own hoisted `vi.mock` factories return. */ export function createDaemonInitModuleFactories(state: DaemonInitMockState) { const { - getPathMock, - getAppPathMock, forkMock, - isPackagedMock, probeSocketExistsMock, writeFileSyncMock, readFileSyncMock, @@ -62,6 +59,7 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) { readonly launcher: unknown readonly ensureRunning: Mock readonly resetHandle: Mock + readonly resetRespawnWindow: Mock readonly shutdown: Mock readonly getHandle: Mock private socketCounter: number @@ -99,6 +97,7 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) { } }) this.resetHandle = vi.fn() + this.resetRespawnWindow = vi.fn() this.shutdown = vi.fn(async () => {}) this.getHandle = vi.fn(() => this.handle) spawnerInstances.push(this as unknown as MockSpawner) @@ -159,16 +158,6 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) { } return { - electron: () => ({ - app: { - get isPackaged() { - return isPackagedMock() - }, - getPath: getPathMock, - getAppPath: getAppPathMock, - getVersion: () => '1.2.3' - } - }), fs: () => ({ mkdirSync: vi.fn<(...args: unknown[]) => void>(), existsSync: (p: string) => probeSocketExistsMock(p) || p.includes('.pid'), diff --git a/src/main/daemon/daemon-init-endpoint-adoption.test.ts b/src/main/daemon/daemon-init-endpoint-adoption.test.ts index 2566ba0996e..5503188d430 100644 --- a/src/main/daemon/daemon-init-endpoint-adoption.test.ts +++ b/src/main/daemon/daemon-init-endpoint-adoption.test.ts @@ -25,7 +25,6 @@ const { (await import('./daemon-init-test-harness')).createDaemonInitMocks() ) -vi.mock('electron', () => moduleFactories.electron()) vi.mock('fs', () => moduleFactories.fs()) vi.mock('child_process', async (importOriginal) => moduleFactories.childProcess(await importOriginal>()) diff --git a/src/main/daemon/daemon-init-fresh-import.ts b/src/main/daemon/daemon-init-fresh-import.ts index 5515e5d213e..1d4f35d655c 100644 --- a/src/main/daemon/daemon-init-fresh-import.ts +++ b/src/main/daemon/daemon-init-fresh-import.ts @@ -1,9 +1,11 @@ import { vi } from 'vitest' +import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment' import type { DaemonInitMockState } from './daemon-init-test-harness' /** Resets every mock plus the module registry, then re-imports daemon-init so its module-level spawner/adapter/restartInFlight start fresh. */ export async function importFreshDaemonInit(state: DaemonInitMockState) { const { + getPathMock, getAppPathMock, isPackagedMock, probeSocketExistsMock, @@ -108,6 +110,17 @@ export async function importFreshDaemonInit(state: DaemonInitMockState) { launchedStartedAtMs.current = 1_000_000 getProcessStartedAtMsMock.mockReset() getProcessStartedAtMsMock.mockReturnValue(1_000_000) + // Why the real port rather than a module mock: daemon-init reads AppEnvironment, whose + // installed instance is anchored to a realm symbol precisely so it survives resetModules. + setAppEnvironment({ + getPath: getPathMock, + getAppPath: getAppPathMock, + getVersion: () => '1.2.3', + isPackaged: isPackagedMock, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + } as unknown as AppEnvironment) // Why: import after resetModules so module-level spawner/adapter/restartInFlight start fresh — needed to test first-init and the coalescer. return import('./daemon-init') } diff --git a/src/main/daemon/daemon-init-live-session-preservation.test.ts b/src/main/daemon/daemon-init-live-session-preservation.test.ts index 06c926386b9..2a3eece9937 100644 --- a/src/main/daemon/daemon-init-live-session-preservation.test.ts +++ b/src/main/daemon/daemon-init-live-session-preservation.test.ts @@ -23,7 +23,6 @@ const { (await import('./daemon-init-test-harness')).createDaemonInitMocks() ) -vi.mock('electron', () => moduleFactories.electron()) vi.mock('fs', () => moduleFactories.fs()) vi.mock('child_process', async (importOriginal) => moduleFactories.childProcess(await importOriginal>()) diff --git a/src/main/daemon/daemon-init-mock-types.ts b/src/main/daemon/daemon-init-mock-types.ts index c71c19d7e4f..8c8b805740f 100644 --- a/src/main/daemon/daemon-init-mock-types.ts +++ b/src/main/daemon/daemon-init-mock-types.ts @@ -4,6 +4,7 @@ import type { Mock } from 'vitest' export type MockSpawner = { ensureRunning: Mock resetHandle: Mock + resetRespawnWindow: Mock shutdown: Mock getHandle: Mock launcher: unknown diff --git a/src/main/daemon/daemon-init-packaged-bundle-staleness.test.ts b/src/main/daemon/daemon-init-packaged-bundle-staleness.test.ts index f3ee88e7b70..6683e627e6d 100644 --- a/src/main/daemon/daemon-init-packaged-bundle-staleness.test.ts +++ b/src/main/daemon/daemon-init-packaged-bundle-staleness.test.ts @@ -24,7 +24,6 @@ const { (await import('./daemon-init-test-harness')).createDaemonInitMocks() ) -vi.mock('electron', () => moduleFactories.electron()) vi.mock('fs', () => moduleFactories.fs()) vi.mock('child_process', async (importOriginal) => moduleFactories.childProcess(await importOriginal>()) diff --git a/src/main/daemon/daemon-init-provider-installation.test.ts b/src/main/daemon/daemon-init-provider-installation.test.ts index e8038894d69..ceb7e9dfa69 100644 --- a/src/main/daemon/daemon-init-provider-installation.test.ts +++ b/src/main/daemon/daemon-init-provider-installation.test.ts @@ -28,7 +28,6 @@ const { (await import('./daemon-init-test-harness')).createDaemonInitMocks() ) -vi.mock('electron', () => moduleFactories.electron()) vi.mock('fs', () => moduleFactories.fs()) vi.mock('child_process', async (importOriginal) => moduleFactories.childProcess(await importOriginal>()) @@ -255,6 +254,28 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(adapterInstances[0].listProcesses).toHaveBeenCalled() }) + it('answers daemonOwnsFreshPersistentPtys honestly for each installed provider', async () => { + // What orcad publishes as `canRecoverPersistentLocalPtys` and as the readiness health + // verdict. Answering true under degraded routing would advertise recovery for terminals + // that die with the runtime process. + const mod = await importFresh() + expect(mod.daemonOwnsFreshPersistentPtys()).toBe(false) + + await mod.initDaemonPtyProvider() + expect(mod.daemonOwnsFreshPersistentPtys()).toBe(true) + + const degraded = await importFresh() + ensureRunningOverrides.push(async () => ({ + socketPath: '/fake/degraded-socket', + tokenPath: '/fake/degraded-token', + mode: 'degraded-new-pty-fallback' + })) + await degraded.initDaemonPtyProvider() + const { DegradedDaemonPtyProvider } = await import('./degraded-daemon-pty-provider') + expect(degraded.getDaemonProvider()).toBeInstanceOf(DegradedDaemonPtyProvider) + expect(degraded.daemonOwnsFreshPersistentPtys()).toBe(false) + }) + it('rechecks the preserved daemon endpoint before recovering fresh-spawn routing', async () => { const mod = await importFresh() ensureRunningOverrides.push(async () => ({ diff --git a/src/main/daemon/daemon-init-replacement-reporting.test.ts b/src/main/daemon/daemon-init-replacement-reporting.test.ts index 5cc3f33a02a..92bed823fb3 100644 --- a/src/main/daemon/daemon-init-replacement-reporting.test.ts +++ b/src/main/daemon/daemon-init-replacement-reporting.test.ts @@ -26,7 +26,6 @@ const { (await import('./daemon-init-test-harness')).createDaemonInitMocks() ) -vi.mock('electron', () => moduleFactories.electron()) vi.mock('fs', () => moduleFactories.fs()) vi.mock('child_process', async (importOriginal) => moduleFactories.childProcess(await importOriginal>()) diff --git a/src/main/daemon/daemon-init-restart-sequence.test.ts b/src/main/daemon/daemon-init-restart-sequence.test.ts index 5f1468342ac..71417db986c 100644 --- a/src/main/daemon/daemon-init-restart-sequence.test.ts +++ b/src/main/daemon/daemon-init-restart-sequence.test.ts @@ -24,7 +24,6 @@ const { (await import('./daemon-init-test-harness')).createDaemonInitMocks() ) -vi.mock('electron', () => moduleFactories.electron()) vi.mock('fs', () => moduleFactories.fs()) vi.mock('child_process', async (importOriginal) => moduleFactories.childProcess(await importOriginal>()) diff --git a/src/main/daemon/daemon-init-wedged-daemon-grace.test.ts b/src/main/daemon/daemon-init-wedged-daemon-grace.test.ts index 7ad1b96abb7..18debd1342a 100644 --- a/src/main/daemon/daemon-init-wedged-daemon-grace.test.ts +++ b/src/main/daemon/daemon-init-wedged-daemon-grace.test.ts @@ -18,7 +18,6 @@ const { (await import('./daemon-init-test-harness')).createDaemonInitMocks() ) -vi.mock('electron', () => moduleFactories.electron()) vi.mock('fs', () => moduleFactories.fs()) vi.mock('child_process', async (importOriginal) => moduleFactories.childProcess(await importOriginal>()) diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index d6c4240b19b..77edbed6027 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -2,7 +2,7 @@ restart, teardown); the "swap the provider atomically" invariant keeps restart + singletons co-located. */ import { join } from 'node:path' import { randomUUID } from 'node:crypto' -import { app } from 'electron' +import { getAppEnvironment } from '../../shared/app-environment' import { mkdirSync, existsSync, readFileSync, unlinkSync } from 'node:fs' import { fork, type ChildProcess } from 'node:child_process' import { connect } from 'node:net' @@ -36,7 +36,7 @@ import { import { getDaemonLaunchIdentity } from './daemon-pid-identity' import { isDaemonStaleForCurrentBundle } from './daemon-bundle-staleness' import { killStaleDaemon } from './daemon-stale-kill' -import { parseDaemonPidFile } from './daemon-pid-file-parse' +import { parseDaemonPidFile, type ParsedDaemonPid } from './daemon-pid-file-parse' import { collectPinnedDaemonVersions, materializeRelocatedDaemonHost, @@ -102,21 +102,25 @@ let adapter: DaemonProvider | null = null let restartInFlight: Promise | null = null function getRuntimeDir(): string { - const dir = join(app.getPath('userData'), 'daemon') + const dir = join(getAppEnvironment().getPath('userData'), 'daemon') mkdirSync(dir, { recursive: true }) return dir } function getHistoryDir(): string { - const dir = join(app.getPath('userData'), 'terminal-history') + const dir = join(getAppEnvironment().getPath('userData'), 'terminal-history') mkdirSync(dir, { recursive: true }) return dir } function getDaemonEntryPath(): string { - const appPath = app.getAppPath() - // Why: packaged app.getAppPath() points at app.asar, so redirect to app.asar.unpacked where daemon-entry.js is fork-executable. - const basePath = app.isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath + const appPath = getAppEnvironment().getAppPath() + // Why: packaged getAppPath() points at app.asar, so redirect to app.asar.unpacked where daemon-entry.js is fork-executable. + // Why asar and not isPackaged: orcad is a packaged non-Electron host whose bundle root holds + // orcad.js and daemon-entry.js side by side with no asar to redirect (see parcel-watcher-entry-path.ts). + const basePath = appPath.includes('app.asar') + ? appPath.replace('app.asar', 'app.asar.unpacked') + : appPath const directEntryPath = join(basePath, 'daemon-entry.js') if (existsSync(directEntryPath)) { return directEntryPath @@ -124,6 +128,12 @@ function getDaemonEntryPath(): string { return join(basePath, 'out', 'main', 'daemon-entry.js') } +// macOS TCC attribution pins the daemon to a packaged app bundle; there is none on a Node host. +function resolvePackagedDarwinAppVersion(): string | null { + const environment = getAppEnvironment() + return process.platform === 'darwin' && environment.isPackaged() ? environment.getVersion() : null +} + // Why: pass a log-file arg so field failures are diagnosable, but honor the ORCA_DIAGNOSTICS_DISABLED privacy switch. function daemonLogArgs(): string[] { const disabled = (process.env.ORCA_DIAGNOSTICS_DISABLED ?? '').trim().toLowerCase() @@ -519,12 +529,12 @@ function createOutOfProcessLauncher( entryPath ) const stalePackagedBundle = - app.isPackaged && + getAppEnvironment().isPackaged() && (await isDaemonStaleForCurrentBundle( runtimeDir, socketPath, tokenPath, - app.getVersion() + getAppEnvironment().getVersion() )) if (identity === 'mismatch' || stalePackagedBundle) { // Why: replacing a healthy daemon kills its child PTYs; defer code freshness until no live sessions would be lost. @@ -662,7 +672,7 @@ function createOutOfProcessLauncher( trackDaemonReplaced(pendingReplacement.reason, pendingReplacement.liveSessionCount) } - const userDataPath = app.getPath('userData') + const userDataPath = getAppEnvironment().getPath('userData') // Why: on win32 packaged, stage a daemon-host copy in userData so its image escapes the NSIS updater's kill zone; lazy so it's off first-paint. Fail-open: null → in-dir host. const relocatedHost = materializeRelocatedDaemonHost() // Fork the relocated entry when available; otherwise the install-dir entry. @@ -681,7 +691,7 @@ function createOutOfProcessLauncher( '--entry-path', entryPath, '--app-version', - app.getVersion(), + getAppEnvironment().getVersion(), '--spawner-exec-path', process.execPath, ...(macosLoginSessionWatch ? ['--login-session-watch'] : []), @@ -699,7 +709,7 @@ function createOutOfProcessLauncher( env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', - // Why: the detached plain-Node daemon can't call app.getPath(), but shell rcfiles must live outside swept tmp. + // Why: the detached plain-Node daemon has no AppEnvironment, but shell rcfiles must live outside swept tmp. ORCA_USER_DATA_PATH: userDataPath } } @@ -962,7 +972,7 @@ export async function initDaemonPtyProvider( pidPath: getDaemonPidPath(runtimeDir), profileScope: runtimeDir, runtimeDir, - packagedAppVersion: process.platform === 'darwin' && app.isPackaged ? app.getVersion() : null, + packagedAppVersion: resolvePackagedDarwinAppVersion(), historyPath: getHistoryDir(), // Why: on daemon death, ensureConnected() detects the dead socket and calls this to fork a replacement before retrying. respawn: async (reason: DaemonRespawnReason) => { @@ -1066,6 +1076,60 @@ async function reconcileSeededClaudeLivePtys(provider: DaemonProvider): Promise< } // Why: a narrow getter (not a raw export) keeps the "swap on restart" invariant in one place (replaceDaemonProvider). +/** + * Whether the installed provider is a daemon that will own FRESH terminals too. + * + * Why not `getDaemonProvider() !== null`: DegradedDaemonPtyProvider routes the daemon's + * EXISTING sessions to the daemon but spawns new ones on the in-process local provider, so + * those die with this process. A host that answered "I can recover persistent local PTYs" + * from that state would be advertising recovery for terminals that cannot be recovered. + */ +export function daemonOwnsFreshPersistentPtys(): boolean { + return adapter !== null && !(adapter instanceof DegradedDaemonPtyProvider) +} + +/** Endpoint coordinates of the daemon this process installed, for out-of-band health probes. */ +export type DaemonEndpointFacts = { + runtimeDir: string + socketPath: string + tokenPath: string + pidPath: string + protocolVersion: number +} + +export function getDaemonEndpointFacts(): DaemonEndpointFacts | null { + if (!adapter) { + return null + } + const runtimeDir = getRuntimeDir() + return { + runtimeDir, + socketPath: getDaemonSocketPath(runtimeDir), + tokenPath: getDaemonTokenPath(runtimeDir), + pidPath: getDaemonPidPath(runtimeDir), + protocolVersion: PROTOCOL_VERSION + } +} + +/** + * What the live daemon's own PID record says about the build it was forked from. + * + * Why the record and not this process's version: the daemon deliberately outlives the + * runtime, so after an update the two can legitimately disagree — and a health surface that + * reported orcad's version for both would hide exactly that. + */ +export function readDaemonPidRecord(): ParsedDaemonPid | null { + const facts = getDaemonEndpointFacts() + if (!facts) { + return null + } + try { + return parseDaemonPidFile(readFileSync(facts.pidPath, 'utf8')) + } catch { + return null + } +} + export function getDaemonProvider(): DaemonProvider | null { return adapter } @@ -1154,6 +1218,9 @@ async function runRestartDaemon(): Promise { } const runtimeDir = getRuntimeDir() + // An operator asking for a restart is the deliberate "try again" that clears crash-loop + // containment; without this a wedged host could never be recovered from the UI. + currentSpawner.resetRespawnWindow() const currentOnly = getCurrentDaemonAdapter(currentAdapter) const legacyAdapters = getLegacyDaemonAdapters(currentAdapter) @@ -1198,7 +1265,7 @@ async function runRestartDaemon(): Promise { pidPath: getDaemonPidPath(runtimeDir), profileScope: runtimeDir, runtimeDir, - packagedAppVersion: process.platform === 'darwin' && app.isPackaged ? app.getVersion() : null, + packagedAppVersion: resolvePackagedDarwinAppVersion(), historyPath: getHistoryDir(), respawn: async (reason: DaemonRespawnReason) => { // Why: attribute rather than emit — the launcher below is the one that completes the diff --git a/src/main/daemon/daemon-respawn-throttle.test.ts b/src/main/daemon/daemon-respawn-throttle.test.ts new file mode 100644 index 00000000000..70806f999e8 --- /dev/null +++ b/src/main/daemon/daemon-respawn-throttle.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from 'vitest' +import { DaemonSpawner, type DaemonProcessHandle } from './daemon-spawner' +import { DaemonCrashLoopError, DaemonRespawnThrottle } from './daemon-respawn-throttle' + +describe('DaemonRespawnThrottle', () => { + it('admits up to the cap, then refuses with the time left in the window', () => { + let now = 0 + const throttle = new DaemonRespawnThrottle({ + maxAttempts: 3, + windowMs: 1_000, + now: () => now + }) + expect(throttle.admit().allowed).toBe(true) + now = 200 + expect(throttle.admit().allowed).toBe(true) + now = 400 + expect(throttle.admit().allowed).toBe(true) + now = 500 + const refused = throttle.admit() + expect(refused).toEqual({ + allowed: false, + reason: 'crash_loop', + attemptsInWindow: 3, + retryAfterMs: 500 + }) + }) + + it('admits again once the window slides past the oldest attempt', () => { + let now = 0 + const throttle = new DaemonRespawnThrottle({ maxAttempts: 2, windowMs: 1_000, now: () => now }) + throttle.admit() + throttle.admit() + expect(throttle.admit().allowed).toBe(false) + now = 1_500 + expect(throttle.admit().allowed).toBe(true) + }) + + it('clears the window on an explicit reset (the operator-restart escape hatch)', () => { + const throttle = new DaemonRespawnThrottle({ maxAttempts: 1, windowMs: 60_000 }) + expect(throttle.admit().allowed).toBe(true) + expect(throttle.admit().allowed).toBe(false) + throttle.reset() + expect(throttle.admit().allowed).toBe(true) + }) +}) + +describe('DaemonSpawner crash-loop containment', () => { + const handle: DaemonProcessHandle = { shutdown: async () => {} } + + it('stops forking once the daemon has died repeatedly inside the window', async () => { + const launcher = vi.fn(async () => handle) + const spawner = new DaemonSpawner({ + runtimeDir: '/tmp/orcad-throttle-test', + launcher, + respawnThrottle: new DaemonRespawnThrottle({ maxAttempts: 3, windowMs: 60_000 }) + }) + for (let i = 0; i < 3; i += 1) { + await spawner.ensureRunning() + // What a dead daemon looks like to the adapter's respawn path. + spawner.resetHandle() + } + expect(launcher).toHaveBeenCalledTimes(3) + await expect(spawner.ensureRunning()).rejects.toBeInstanceOf(DaemonCrashLoopError) + // The refusal must actually prevent the fork, not just annotate it. + expect(launcher).toHaveBeenCalledTimes(3) + }) + + it('does not count a cached handle as a new attempt', async () => { + const launcher = vi.fn(async () => handle) + const spawner = new DaemonSpawner({ + runtimeDir: '/tmp/orcad-throttle-test', + launcher, + respawnThrottle: new DaemonRespawnThrottle({ maxAttempts: 2, windowMs: 60_000 }) + }) + await spawner.ensureRunning() + await spawner.ensureRunning() + await spawner.ensureRunning() + expect(launcher).toHaveBeenCalledTimes(1) + }) + + it('lets an operator restart clear containment', async () => { + const launcher = vi.fn(async () => handle) + const spawner = new DaemonSpawner({ + runtimeDir: '/tmp/orcad-throttle-test', + launcher, + respawnThrottle: new DaemonRespawnThrottle({ maxAttempts: 1, windowMs: 60_000 }) + }) + await spawner.ensureRunning() + spawner.resetHandle() + await expect(spawner.ensureRunning()).rejects.toBeInstanceOf(DaemonCrashLoopError) + spawner.resetRespawnWindow() + await expect(spawner.ensureRunning()).resolves.toBeDefined() + expect(launcher).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/daemon/daemon-respawn-throttle.ts b/src/main/daemon/daemon-respawn-throttle.ts new file mode 100644 index 00000000000..9988a6b9fac --- /dev/null +++ b/src/main/daemon/daemon-respawn-throttle.ts @@ -0,0 +1,84 @@ +/** + * Crash-loop containment for the terminal daemon. + * + * The respawn path is driven by `ensureConnected()`: every reconnect attempt against a dead + * socket forks a replacement. A daemon that dies during startup — a broken node-pty, an + * unwritable runtime dir, a wrong libc — therefore forks forever, as fast as the caller + * retries. On the desktop that burns CPU; under orcad, where a supervisor is watching a + * process that never reports failure, it is the "restart-spin while the deploy reports + * success" shape the ops contract has to rule out. + * + * Sliding window, not a permanent trip: the failure is usually environmental, and an + * environment can be repaired without restarting the runtime. Once the window drains, the + * next attempt is admitted and a repaired host recovers on its own. + */ +export type DaemonRespawnAdmission = + | { allowed: true } + | { allowed: false; reason: 'crash_loop'; attemptsInWindow: number; retryAfterMs: number } + +export type DaemonRespawnThrottleOptions = { + /** Attempts allowed inside `windowMs` before the next one is refused. */ + maxAttempts?: number + windowMs?: number + now?: () => number +} + +// Generous on purpose: a user manually restarting the daemon a few times, or a laptop +// waking to a stale socket, must never trip this. Five failures inside a minute is a +// daemon that cannot start, not a daemon having a bad moment. +export const DEFAULT_DAEMON_RESPAWN_MAX_ATTEMPTS = 5 +export const DEFAULT_DAEMON_RESPAWN_WINDOW_MS = 60_000 + +export class DaemonRespawnThrottle { + private readonly maxAttempts: number + private readonly windowMs: number + private readonly now: () => number + private attempts: number[] = [] + + constructor(options: DaemonRespawnThrottleOptions = {}) { + this.maxAttempts = options.maxAttempts ?? DEFAULT_DAEMON_RESPAWN_MAX_ATTEMPTS + this.windowMs = options.windowMs ?? DEFAULT_DAEMON_RESPAWN_WINDOW_MS + this.now = options.now ?? Date.now + } + + /** Record and admit one respawn attempt, or refuse it as a crash loop. */ + admit(): DaemonRespawnAdmission { + const now = this.now() + this.attempts = this.attempts.filter((at) => now - at < this.windowMs) + if (this.attempts.length >= this.maxAttempts) { + const oldest = this.attempts[0] as number + return { + allowed: false, + reason: 'crash_loop', + attemptsInWindow: this.attempts.length, + retryAfterMs: Math.max(0, this.windowMs - (now - oldest)) + } + } + this.attempts.push(now) + return { allowed: true } + } + + /** + * Forget the recorded attempts. + * + * Why not automatic on a successful fork: a crash loop IS a sequence of successful forks + * followed by immediate deaths, so "the fork returned" is not evidence of recovery. Only + * a caller that knows the daemon stayed up — or a deliberate operator restart — may clear + * the window. + */ + reset(): void { + this.attempts = [] + } +} + +export class DaemonCrashLoopError extends Error { + readonly code = 'daemon_crash_loop' + constructor(admission: Extract) { + super( + `The terminal daemon has failed ${admission.attemptsInWindow} times in a row; refusing to ` + + `respawn it for another ${Math.ceil(admission.retryAfterMs / 1000)}s. Terminals will not ` + + 'start until the underlying failure is fixed (check the daemon log).' + ) + this.name = 'DaemonCrashLoopError' + } +} diff --git a/src/main/daemon/daemon-spawner.ts b/src/main/daemon/daemon-spawner.ts index 6a6d377cc2f..a0376ef0fc0 100644 --- a/src/main/daemon/daemon-spawner.ts +++ b/src/main/daemon/daemon-spawner.ts @@ -10,6 +10,7 @@ import { } from 'node:fs' import { join } from 'node:path' import { PROTOCOL_VERSION } from './types' +import { DaemonCrashLoopError, DaemonRespawnThrottle } from './daemon-respawn-throttle' export type DaemonConnectionInfo = { socketPath: string @@ -44,6 +45,8 @@ export type DaemonLauncher = ( export type DaemonSpawnerOptions = { runtimeDir: string launcher: DaemonLauncher + /** Test seam; production uses the default window. */ + respawnThrottle?: DaemonRespawnThrottle } export class DaemonSpawner { @@ -53,10 +56,12 @@ export class DaemonSpawner { private socketPath: string private tokenPath: string private pidPath: string + private respawnThrottle: DaemonRespawnThrottle constructor(opts: DaemonSpawnerOptions) { this.runtimeDir = opts.runtimeDir this.launcher = opts.launcher + this.respawnThrottle = opts.respawnThrottle ?? new DaemonRespawnThrottle() this.socketPath = getDaemonSocketPath(this.runtimeDir) this.tokenPath = getDaemonTokenPath(this.runtimeDir) this.pidPath = getDaemonPidPath(this.runtimeDir) @@ -67,6 +72,14 @@ export class DaemonSpawner { return { socketPath: this.socketPath, tokenPath: this.tokenPath } } + // Why here and not in the respawn callback: every launch — first, post-death, and + // post-restart — funnels through this method, so this is the only place a crash loop + // cannot route around. + const admission = this.respawnThrottle.admit() + if (!admission.allowed) { + throw new DaemonCrashLoopError(admission) + } + // Why: a detached daemon may clean up after its parent exits. A unique // launch identity keeps it from deleting a replacement daemon's PID file. this.handle = await this.launcher(this.socketPath, this.tokenPath, this.pidPath, randomUUID()) @@ -85,6 +98,17 @@ export class DaemonSpawner { this.handle = null } + /** + * Forget the crash-loop window. + * + * Why an explicit call and not "a fork succeeded": a crash loop is a run of successful + * forks whose daemons then die, so the fork returning proves nothing. An operator asking + * for a restart does mean "try again", and that is the only thing that clears it. + */ + resetRespawnWindow(): void { + this.respawnThrottle.reset() + } + async shutdown(): Promise { if (!this.handle) { return diff --git a/src/main/observability/logs-directory.ts b/src/main/observability/logs-directory.ts index 94a1b7baae8..20ad104af74 100644 --- a/src/main/observability/logs-directory.ts +++ b/src/main/observability/logs-directory.ts @@ -1,27 +1,29 @@ // Single source of truth for the app's logs directory and the files inside it. // macOS convention is `~/Library/Application Support/Orca/logs/`; Windows and -// Linux resolve the same intent via Electron's `userData` dir. Falls back to a -// homedir-derived path when Electron's `app` is unavailable (unit tests). +// Linux resolve the same intent via the host's `userData` dir. Falls back to a +// homedir-derived path when no AppEnvironment is installed (unit tests). -import { app } from 'electron' +import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' import { homedir, platform } from 'node:os' import { join } from 'node:path' +// Why the port and not electron's `app`: the daemon launch path reads this for --log-file, +// and that path has to resolve under plain Node (orcad) as well as the desktop. +// Why 'userData' + 'logs' rather than getPath('logs'): electron's 'logs' is ~/Library/Logs +// on macOS, which is NOT where this app has ever written. Changing it would strand +// existing log bundles. function getUserDataDir(): string { - try { - return app.getPath('userData') - } catch { - // Tests — Electron's `app` may not be initialized. Use an OS-conventional - // fallback so callers can resolve the path without the Electron runtime. - const home = homedir() - if (platform() === 'darwin') { - return join(home, 'Library', 'Application Support', 'Orca') - } - if (platform() === 'win32') { - return join(process.env.APPDATA ?? home, 'Orca') - } - return join(home, '.config', 'Orca') + if (hasAppEnvironment()) { + return getAppEnvironment().getPath('userData') } + const home = homedir() + if (platform() === 'darwin') { + return join(home, 'Library', 'Application Support', 'Orca') + } + if (platform() === 'win32') { + return join(process.env.APPDATA ?? home, 'Orca') + } + return join(home, '.config', 'Orca') } export function getLogsDirectory(): string { diff --git a/src/main/orcad/main-preflight-order.test.ts b/src/main/orcad/main-preflight-order.test.ts new file mode 100644 index 00000000000..bd130bfb33f --- /dev/null +++ b/src/main/orcad/main-preflight-order.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest' + +/** + * The precondition is only worth anything if it runs first. A loader failure is not + * catchable, so a preflight that lands after `main()` has already reached + * `await import('../ipc/pty')` prevents nothing. + */ +const order: string[] = [] + +vi.mock('./orcad-native-preflight', () => ({ + runOrcadNativePreflight: () => { + order.push('preflight') + return true + } +})) + +vi.mock('./orcad-entry', () => ({ + main: async () => { + order.push('main') + } +})) + +describe('orcad entry', () => { + it('runs the native preflight before starting the runtime', async () => { + await import('./main') + await vi.waitFor(() => expect(order).toContain('main')) + + expect(order).toEqual(['preflight', 'main']) + }) +}) diff --git a/src/main/orcad/main.ts b/src/main/orcad/main.ts index e76ed873cb0..73a973c7b24 100644 --- a/src/main/orcad/main.ts +++ b/src/main/orcad/main.ts @@ -1,8 +1,27 @@ /** Executable entry for `orcad`. See `./orcad-entry.ts`. */ import process from 'node:process' -import { main } from './orcad-entry' +import { main, resolveOrcadExitCode } from './orcad-entry' +import { runOrcadNativePreflight } from './orcad-native-preflight' + +// Why exit before the preflight: reaching this line means the whole module graph resolved +// under plain Node, which is all the build guard needs to prove. Probing natives or +// starting a server to prove it would bind a port and take a data-root lock on a build +// machine. +if (process.argv.includes('--orcad-smoke-load-check')) { + process.exit(0) +} + +// Why here and not inside startOrcad: this must run before anything requires node-pty, +// and `orcad-entry` reaches it through `await import('../ipc/pty')`. Static imports are +// evaluated before this statement, so the guarantee is that no module in the graph +// requires node-pty at import time — which the bundle's lazy `require("node-pty")` in +// local-pty-provider satisfies. See ./node-pty-precondition.ts for why a child process. +runOrcadNativePreflight() main().catch((error: unknown) => { console.error('orcad: failed to start:', error) - process.exit(1) + // Why a resolved code and not a bare 1: a data-root or bind-address refusal is a + // configuration fault that restarting cannot fix, and a supervisor needs to tell the two + // apart to avoid restart-spinning on it. + process.exit(resolveOrcadExitCode(error)) }) diff --git a/src/main/orcad/native-host-abi.test.ts b/src/main/orcad/native-host-abi.test.ts new file mode 100644 index 00000000000..44f469f0faa --- /dev/null +++ b/src/main/orcad/native-host-abi.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import { + compareDottedVersions, + detectLibcFromReportHeader, + detectNativeHostAbi, + GLIBC_FLOOR, + isBelowGlibcFloor, + nativeSlotName, + parseNodeAbiMismatch, + parseUnmetGlibcVersion +} from './native-host-abi' + +describe('detectLibcFromReportHeader', () => { + it('reads glibc and its version from a glibc host report', () => { + expect(detectLibcFromReportHeader('linux', { glibcVersionRuntime: '2.31' })).toEqual({ + libc: 'glibc', + glibcVersion: '2.31' + }) + }) + + it('calls a Linux report with no glibcVersionRuntime musl', () => { + // Alpine's Node omits the key entirely; that absence is the only signal available + // without shelling out to ldd, which musl images do not usefully provide. + expect(detectLibcFromReportHeader('linux', { arch: 'x64' })).toEqual({ + libc: 'musl', + glibcVersion: null + }) + }) + + it('does not invent a libc dimension for macOS or Windows', () => { + // A darwin-arm64-glibc slot would never match anything CI builds. + expect(detectLibcFromReportHeader('darwin', { glibcVersionRuntime: '2.31' }).libc).toBe('none') + expect(detectLibcFromReportHeader('win32', undefined).libc).toBe('none') + }) + + it('treats an unreadable report as glibc with an unknown version, not as musl', () => { + // Guessing musl would send a glibc host looking for a slot that does not exist. + expect(detectLibcFromReportHeader('linux', undefined)).toEqual({ + libc: 'glibc', + glibcVersion: null + }) + }) +}) + +describe('nativeSlotName', () => { + it('carries libc on Linux and omits it elsewhere', () => { + expect(nativeSlotName({ platform: 'linux', arch: 'x64', libc: 'glibc' })).toBe( + 'linux-x64-glibc' + ) + expect(nativeSlotName({ platform: 'linux', arch: 'arm64', libc: 'musl' })).toBe( + 'linux-arm64-musl' + ) + expect(nativeSlotName({ platform: 'darwin', arch: 'arm64', libc: 'none' })).toBe('darwin-arm64') + }) + + it('never lets a glibc slot answer for a musl host', () => { + // node-pty's own loader checks prebuilds/- with no libc, which is the + // exact confusion this name exists to prevent. + expect(nativeSlotName({ platform: 'linux', arch: 'x64', libc: 'glibc' })).not.toBe( + nativeSlotName({ platform: 'linux', arch: 'x64', libc: 'musl' }) + ) + }) +}) + +describe('glibc floor', () => { + it('compares dotted versions numerically, not lexically', () => { + // '2.9' > '2.31' under string compare; that ordering would pass a broken host. + expect(compareDottedVersions('2.9', '2.31')).toBe(-1) + expect(compareDottedVersions('2.31', '2.31.0')).toBe(0) + expect(compareDottedVersions('2.34', GLIBC_FLOOR)).toBe(1) + }) + + it('answers null when the version is unknown rather than claiming the floor is met', () => { + expect(isBelowGlibcFloor(null)).toBeNull() + expect(isBelowGlibcFloor('2.28')).toBe(true) + expect(isBelowGlibcFloor('2.31')).toBe(false) + }) +}) + +describe('loader error parsing', () => { + it('extracts the unmet symbol version from the #9902 message', () => { + expect( + parseUnmetGlibcVersion( + "/lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by /app/node_modules/node-pty/build/Release/pty.node)" + ) + ).toBe('2.34') + }) + + it('ignores unrelated loader noise', () => { + expect(parseUnmetGlibcVersion('Error: Cannot find module ./pty.node')).toBeNull() + }) + + it('extracts both ABI numbers from a NODE_MODULE_VERSION mismatch', () => { + expect( + parseNodeAbiMismatch( + 'was compiled against a different Node.js version using NODE_MODULE_VERSION 115. This version of Node.js requires NODE_MODULE_VERSION 127.' + ) + ).toEqual({ built: '115', host: '127' }) + }) +}) + +describe('detectNativeHostAbi', () => { + it('describes the host this test is running on', () => { + const abi = detectNativeHostAbi() + expect(abi.platform).toBe(process.platform) + expect(abi.arch).toBe(process.arch) + expect(abi.nodeAbi).toBe(process.versions.modules) + expect(abi.libc).toBe(process.platform === 'linux' ? abi.libc : 'none') + }) +}) diff --git a/src/main/orcad/native-host-abi.ts b/src/main/orcad/native-host-abi.ts new file mode 100644 index 00000000000..4afb8ac7bfa --- /dev/null +++ b/src/main/orcad/native-host-abi.ts @@ -0,0 +1,127 @@ +/** + * What this host would load a native addon against: platform, arch, libc flavour and + * Node's ABI number, plus the prebuild slot name those four pick. + * + * Why libc is a first-class dimension: node-pty's own loader + * (`node_modules/node-pty/lib/utils.js`) falls back to `prebuilds/-`, + * which does NOT distinguish glibc from musl. A glibc binary dropped in that directory + * is loaded on Alpine and dies inside the dynamic loader. Slot names carry the libc so + * a mismatch is a miss rather than a crash. + * + * Everything here is pure apart from `detectNativeHostAbi`, so the classification can be + * tested for hosts this machine is not. + */ +import process from 'node:process' + +export type LibcFlavor = 'glibc' | 'musl' | 'none' + +export type NativeHostAbi = { + platform: NodeJS.Platform + arch: string + libc: LibcFlavor + /** Runtime glibc version ('2.31'), or null on musl, non-Linux, and unreadable reports. */ + glibcVersion: string | null + /** `NODE_MODULE_VERSION` — the addon ABI this runtime accepts. */ + nodeAbi: string +} + +/** Stock Ubuntu 20.04. See docs/reference/linux-glibc-compatibility.md. */ +export const GLIBC_FLOOR = '2.31' + +type ReportHeader = { glibcVersionRuntime?: unknown } + +/** + * Why absence means musl: `glibcVersionRuntime` is written by Node's report only when the + * process is linked against glibc. Alpine's Node omits it. This avoids shelling out to + * `ldd`, which is not present on every image and prints to stderr on musl. + * + * Non-Linux hosts get 'none': macOS and Windows have one system libc, so the dimension + * carries no information and must not widen the slot matrix. + */ +export function detectLibcFromReportHeader( + platform: NodeJS.Platform, + header: unknown +): { libc: LibcFlavor; glibcVersion: string | null } { + if (platform !== 'linux') { + return { libc: 'none', glibcVersion: null } + } + if (!header || typeof header !== 'object') { + // Why glibc and not 'unknown': an unreadable report is not evidence of musl, and + // glibc is the overwhelmingly common Linux case. The null version keeps the floor + // check from claiming a number it does not have. + return { libc: 'glibc', glibcVersion: null } + } + const runtime = (header as ReportHeader).glibcVersionRuntime + if (typeof runtime === 'string' && runtime.length > 0) { + return { libc: 'glibc', glibcVersion: runtime } + } + if ('glibcVersionRuntime' in (header as object)) { + return { libc: 'glibc', glibcVersion: null } + } + return { libc: 'musl', glibcVersion: null } +} + +export function detectNativeHostAbi(): NativeHostAbi { + let header: unknown + try { + header = (process.report?.getReport?.() as { header?: unknown } | undefined)?.header + } catch { + header = undefined + } + const { libc, glibcVersion } = detectLibcFromReportHeader(process.platform, header) + return { + platform: process.platform, + arch: process.arch, + libc, + glibcVersion, + nodeAbi: process.versions.modules + } +} + +/** `linux-x64-glibc`, `linux-arm64-musl`, `darwin-arm64`, `win32-x64`. */ +export function nativeSlotName(abi: Pick): string { + return abi.libc === 'none' + ? `${abi.platform}-${abi.arch}` + : `${abi.platform}-${abi.arch}-${abi.libc}` +} + +/** Numeric dotted compare: -1 / 0 / 1. Missing components read as 0, so '2.31' === '2.31.0'. */ +export function compareDottedVersions(left: string, right: string): number { + const a = left.split('.').map((part) => Number.parseInt(part, 10) || 0) + const b = right.split('.').map((part) => Number.parseInt(part, 10) || 0) + for (let i = 0; i < Math.max(a.length, b.length); i += 1) { + const diff = (a[i] ?? 0) - (b[i] ?? 0) + if (diff !== 0) { + return diff > 0 ? 1 : -1 + } + } + return 0 +} + +/** Null when the version is unknown — an unread report must not read as "below the floor". */ +export function isBelowGlibcFloor(glibcVersion: string | null): boolean | null { + if (!glibcVersion) { + return null + } + return compareDottedVersions(glibcVersion, GLIBC_FLOOR) < 0 +} + +/** + * The symbol version node's loader complained about, e.g. + * `libc.so.6: version 'GLIBC_2.34' not found (required by .../pty.node)` -> '2.34'. + * This is the fingerprint of #9902: a binary built on a newer glibc than the target. + */ +export function parseUnmetGlibcVersion(loaderError: string): string | null { + const match = loaderError.match(/version `?GLIBC_([0-9][0-9.]*)'? not found/) + return match ? match[1] : null +} + +/** `NODE_MODULE_VERSION 115 ... requires NODE_MODULE_VERSION 127` -> { built: '115', host: '127' }. */ +export function parseNodeAbiMismatch( + loaderError: string +): { built: string; host: string } | null { + const match = loaderError.match( + /NODE_MODULE_VERSION\s+(\d+)\D+NODE_MODULE_VERSION\s+(\d+)/ + ) + return match ? { built: match[1], host: match[2] } : null +} diff --git a/src/main/orcad/node-pty-prebuilt-slot.test.ts b/src/main/orcad/node-pty-prebuilt-slot.test.ts new file mode 100644 index 00000000000..00880eee1e5 --- /dev/null +++ b/src/main/orcad/node-pty-prebuilt-slot.test.ts @@ -0,0 +1,119 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + installPrebuiltSlot, + readPrebuiltSlotManifest, + resolveOrcadPrebuildsDir +} from './node-pty-prebuilt-slot' +import type { NativeHostAbi } from './native-host-abi' + +const LINUX_GLIBC: NativeHostAbi = { + platform: 'linux', + arch: 'x64', + libc: 'glibc', + glibcVersion: '2.31', + nodeAbi: '127' +} + +const dirs: string[] = [] +const temp = (): string => { + const dir = mkdtempSync(join(tmpdir(), 'orcad-slot-')) + dirs.push(dir) + return dir +} +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + delete process.env.ORCA_ORCAD_PREBUILDS_DIR +}) + +const stageSlot = (prebuilds: string, slot: string): void => { + mkdirSync(join(prebuilds, slot), { recursive: true }) + writeFileSync(join(prebuilds, slot, 'pty.node'), 'binary') + writeFileSync(join(prebuilds, slot, 'spawn-helper'), 'helper') +} + +describe('resolveOrcadPrebuildsDir', () => { + it('looks beside the running bundle', () => { + expect(resolveOrcadPrebuildsDir('/opt/orcad/orcad.js')).toBe(join('/opt/orcad', 'prebuilds')) + }) + + it('honours an explicit override', () => { + process.env.ORCA_ORCAD_PREBUILDS_DIR = '/custom/prebuilds' + expect(resolveOrcadPrebuildsDir('/opt/orcad/orcad.js')).toBe('/custom/prebuilds') + }) +}) + +describe('installPrebuiltSlot', () => { + it('installs the slot binary and spawn-helper into build/Release', () => { + const prebuilds = temp() + const nodePtyDir = temp() + stageSlot(prebuilds, 'linux-x64-glibc') + + const outcome = installPrebuiltSlot({ abi: LINUX_GLIBC, nodePtyDir, prebuildsDir: prebuilds }) + + expect(outcome).toEqual({ installed: true, slot: 'linux-x64-glibc', spawnHelper: true }) + expect(existsSync(join(nodePtyDir, 'build', 'Release', 'pty.node'))).toBe(true) + // Without the executable bit every spawn fails EACCES at the moment a user opens a terminal. + const helper = statSync(join(nodePtyDir, 'build', 'Release', 'spawn-helper')) + expect(helper.mode & 0o111).not.toBe(0) + }) + + it('will not load a glibc slot on a musl host', () => { + // node-pty's own loader cannot tell these apart; the slot name is the only thing that can. + const prebuilds = temp() + const nodePtyDir = temp() + stageSlot(prebuilds, 'linux-x64-glibc') + + const outcome = installPrebuiltSlot({ + abi: { ...LINUX_GLIBC, libc: 'musl' }, + nodePtyDir, + prebuildsDir: prebuilds + }) + + expect(outcome).toEqual({ installed: false, slot: 'linux-x64-musl', why: 'no-slot' }) + expect(existsSync(join(nodePtyDir, 'build', 'Release', 'pty.node'))).toBe(false) + }) + + it('refuses an ABI-mismatched matrix instead of installing a binary that cannot load', () => { + // Installing it would turn "no prebuilt for this host" into a loader failure that + // reads as a corrupt install. + const prebuilds = temp() + const nodePtyDir = temp() + stageSlot(prebuilds, 'linux-x64-glibc') + writeFileSync( + join(prebuilds, 'manifest.json'), + JSON.stringify({ module: 'node-pty', version: '1.1.0', nodeAbi: '115', slots: [] }) + ) + + const outcome = installPrebuiltSlot({ abi: LINUX_GLIBC, nodePtyDir, prebuildsDir: prebuilds }) + + expect(outcome).toMatchObject({ installed: false, why: 'abi-mismatch' }) + expect(existsSync(join(nodePtyDir, 'build', 'Release', 'pty.node'))).toBe(false) + }) + + it('reports a missing prebuilds directory distinctly from a missing slot', () => { + // They mean different things: no matrix shipped at all, versus a matrix with a hole. + expect( + installPrebuiltSlot({ + abi: LINUX_GLIBC, + nodePtyDir: temp(), + prebuildsDir: join(temp(), 'absent') + }) + ).toEqual({ installed: false, slot: 'linux-x64-glibc', why: 'no-prebuilds-dir' }) + }) +}) + +describe('readPrebuiltSlotManifest', () => { + it('returns null for absent or malformed manifests rather than a half-built object', () => { + const prebuilds = temp() + expect(readPrebuiltSlotManifest(prebuilds)).toBeNull() + writeFileSync(join(prebuilds, 'manifest.json'), '{ not json') + expect(readPrebuiltSlotManifest(prebuilds)).toBeNull() + writeFileSync(join(prebuilds, 'manifest.json'), JSON.stringify({ version: '1.1.0' })) + expect(readPrebuiltSlotManifest(prebuilds)).toBeNull() + }) +}) diff --git a/src/main/orcad/node-pty-prebuilt-slot.ts b/src/main/orcad/node-pty-prebuilt-slot.ts new file mode 100644 index 00000000000..992cbe705ee --- /dev/null +++ b/src/main/orcad/node-pty-prebuilt-slot.ts @@ -0,0 +1,113 @@ +/** + * Install the shipped node-pty prebuilt that matches this host, so a deployment needs + * no C/C++ toolchain. + * + * Why copy into `build/Release` rather than leave it in `prebuilds/`: node-pty's own + * loader falls back to `prebuilds/-` with no libc in the name, so a + * glibc binary parked there is loaded on Alpine and dies in the dynamic loader. Putting + * the chosen slot's binary in `build/Release` is what makes the libc dimension real — + * node-pty only ever sees the one we picked. + * + * The prebuilds themselves are built from the PATCHED source (config/patches/node-pty@1.1.0.patch) + * by config/scripts/build-orcad-prebuilds.mjs. An upstream tarball would not do: the patch + * carries the `.symver` pins and the `--no-as-needed` libutil/libpthread flags that hold the + * Ubuntu 20.04 / glibc 2.31 floor (docs/reference/linux-glibc-compatibility.md). + */ +import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import process from 'node:process' +import { nativeSlotName, type NativeHostAbi } from './native-host-abi' + +export type PrebuiltSlotManifest = { + module: string + version: string + nodeAbi: string + slots: string[] +} + +export type PrebuiltSlotOutcome = + | { installed: true; slot: string; spawnHelper: boolean } + | { installed: false; slot: string; why: 'no-slot' | 'no-prebuilds-dir' | 'abi-mismatch'; detail?: string } + +/** + * Where a deployment's prebuilds live: beside the bundle that is running. `argv[1]` is + * `orcad.js` itself, so this stays correct wherever the install directory ends up. + */ +export function resolveOrcadPrebuildsDir(entryScript = process.argv[1]): string | null { + const override = process.env.ORCA_ORCAD_PREBUILDS_DIR + if (override) { + return override + } + return entryScript ? join(dirname(entryScript), 'prebuilds') : null +} + +export function readPrebuiltSlotManifest(prebuildsDir: string): PrebuiltSlotManifest | null { + try { + const parsed = JSON.parse(readFileSync(join(prebuildsDir, 'manifest.json'), 'utf8')) as unknown + if (!parsed || typeof parsed !== 'object') { + return null + } + const manifest = parsed as Partial + if (typeof manifest.nodeAbi !== 'string' || typeof manifest.version !== 'string') { + return null + } + return { + module: typeof manifest.module === 'string' ? manifest.module : 'node-pty', + version: manifest.version, + nodeAbi: manifest.nodeAbi, + slots: Array.isArray(manifest.slots) ? manifest.slots.filter((s) => typeof s === 'string') : [] + } + } catch { + return null + } +} + +/** + * Copy `//pty.node` (and `spawn-helper`) into node-pty's `build/Release`. + * + * Refuses on an ABI mismatch instead of copying: a binary built for another + * `NODE_MODULE_VERSION` cannot load, and installing it would replace a "no prebuilt" + * diagnosis with a loader failure that reads as a corrupt install. + */ +export function installPrebuiltSlot(options: { + abi: NativeHostAbi + nodePtyDir: string + prebuildsDir?: string | null +}): PrebuiltSlotOutcome { + const slot = nativeSlotName(options.abi) + const prebuildsDir = options.prebuildsDir ?? resolveOrcadPrebuildsDir() + if (!prebuildsDir || !existsSync(prebuildsDir)) { + return { installed: false, slot, why: 'no-prebuilds-dir' } + } + const manifest = readPrebuiltSlotManifest(prebuildsDir) + if (manifest && manifest.nodeAbi !== options.abi.nodeAbi) { + return { + installed: false, + slot, + why: 'abi-mismatch', + detail: `shipped prebuilds target Node ABI ${manifest.nodeAbi}, this host runs ABI ${options.abi.nodeAbi}` + } + } + const source = join(prebuildsDir, slot, 'pty.node') + if (!existsSync(source)) { + return { installed: false, slot, why: 'no-slot' } + } + const releaseDir = join(options.nodePtyDir, 'build', 'Release') + mkdirSync(releaseDir, { recursive: true }) + copyFileSync(source, join(releaseDir, 'pty.node')) + + // Why this matters as much as pty.node: on Unix node-pty posix_spawns + // build/Release/spawn-helper. Without it every spawn fails with ENOENT at the moment + // a user opens a terminal, long after the "install succeeded" line. + let spawnHelper = false + if (options.abi.platform !== 'win32') { + const helperSource = join(prebuildsDir, slot, 'spawn-helper') + if (existsSync(helperSource)) { + const helperDest = join(releaseDir, 'spawn-helper') + copyFileSync(helperSource, helperDest) + chmodSync(helperDest, 0o755) + spawnHelper = true + } + } + return { installed: true, slot, spawnHelper } +} diff --git a/src/main/orcad/node-pty-precondition.test.ts b/src/main/orcad/node-pty-precondition.test.ts new file mode 100644 index 00000000000..fadb144d1ff --- /dev/null +++ b/src/main/orcad/node-pty-precondition.test.ts @@ -0,0 +1,358 @@ +import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + buildNodePtyLoadProbeScript, + checkNodePtyPrecondition, + classifyNodePtyProbeResult, + formatNodePtyPreconditionReport, + probeLocalBuildToolchainHints +} from './node-pty-precondition' +import { detectNativeHostAbi } from './native-host-abi' + +const require = createRequire(import.meta.url) +const REAL_NODE_PTY = dirname(require.resolve('node-pty/package.json')) +const REAL_PTY_NODE = join(REAL_NODE_PTY, 'build', 'Release', 'pty.node') + +/** + * Whether this host's compiled node-pty actually loads under plain Node. + * + * Why not existsSync: CI ships a pty.node built for Electron's ABI, so the file is + * present and `require` still fails. Gating on existence ran the load-dependent tests + * on a host that could never satisfy them. Probed in a child so a bad binding cannot + * take the test runner down with it. + */ +const REAL_SPAWN_HELPER = join(REAL_NODE_PTY, 'build', 'Release', 'spawn-helper') + +const realNodePtyLoads = ((): boolean => { + if (!existsSync(REAL_PTY_NODE)) { + return false + } + // Why spawn-helper too: a slot without it is legitimately 'degraded', so a test that + // expects 'ok' has an unsatisfiable premise on a host that lacks it. CI has the + // binding but not the helper, which is what made the previous gate insufficient. + if (process.platform !== 'win32' && !existsSync(REAL_SPAWN_HELPER)) { + return false + } + const probe = spawnSync(process.execPath, ['-e', `require(${JSON.stringify(REAL_PTY_NODE)})`], { + encoding: 'utf8', + timeout: 30_000 + }) + return !probe.error && probe.status === 0 +})() + +const probe = (overrides: Partial[0]> = {}) => + classifyNodePtyProbeResult({ + code: 1, + signal: null, + stdout: '', + stderr: '', + timedOut: false, + ...overrides + }) + +const reported = (message: string) => + classifyNodePtyProbeResult({ + code: 4, + signal: null, + stdout: `ORCA_NODE_PTY_LOAD_ERROR ${JSON.stringify(message)}\n`, + stderr: '', + timedOut: false + }) + +describe('classifyNodePtyProbeResult', () => { + it('accepts only a clean exit that printed the token', () => { + expect(probe({ code: 0, stdout: 'ORCA_NODE_PTY_LOAD_OK /x/build/Release' })).toBeNull() + // A zero exit with no token means the probe never reached the load. + expect(probe({ code: 0, stdout: '' })?.reason).toBe('load_failed') + }) + + it('names the glibc floor for the #9902 loader message', () => { + expect( + reported( + "/lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by /app/node_modules/node-pty/build/Release/pty.node)" + ) + ).toEqual({ + status: 'blocked', + reason: 'libc_floor', + detail: 'the binary requires GLIBC_2.34' + }) + }) + + it('names a libstdc++ floor break too', () => { + expect(reported("version `GLIBCXX_3.4.29' not found")?.reason).toBe('libc_floor') + }) + + it('separates a Node ABI mismatch from a libc mismatch', () => { + // These need different fixes — rebuild against this Node vs. build on an older libc — + // so collapsing them sends the operator to the wrong one. + expect( + reported( + 'was compiled against a different Node.js version using NODE_MODULE_VERSION 115. This version of Node.js requires NODE_MODULE_VERSION 127.' + ) + ).toEqual({ + status: 'blocked', + reason: 'abi_mismatch', + detail: 'built for Node ABI 115, this host runs ABI 127' + }) + }) + + it('reports a signalled probe as a crash, even with no output at all', () => { + // The uncatchable case: a binary that aborts inside the loader never reaches the + // child's catch and often prints nothing. + expect(probe({ code: null, signal: 'SIGSEGV' })).toEqual({ + status: 'blocked', + reason: 'load_crashed', + detail: 'the load probe was killed by SIGSEGV' + }) + }) + + it('distinguishes no binary anywhere from a binary the loader refused', () => { + // "install node-pty" and "rebuild node-pty for this libc" are different instructions. + expect(probe({ code: 3, stdout: 'ORCA_NODE_PTY_NO_BINARY\n' })).toEqual({ + status: 'blocked', + reason: 'dependency_missing', + detail: 'node-pty is installed but has no compiled binary for this platform' + }) + expect(reported('dlopen(...): slice is not valid mach-o file')?.reason).toBe('load_failed') + }) + + it('ignores its own token strings echoed back inside the child stderr', () => { + // node prints the whole `-e` source above the stack trace, and that source contains + // every token below. Matching on stderr made a refused binary read as "not installed". + const echoedSource = + '[eval]:1\nif(!f){console.log("ORCA_NODE_PTY_NO_BINARY");process.exit(3)}\n' + + ' ^\n\nError: dlopen(/app/pty.node): slice is not valid mach-o file\n' + + expect( + classifyNodePtyProbeResult({ + code: 4, + signal: null, + stdout: `ORCA_NODE_PTY_LOAD_ERROR ${JSON.stringify('dlopen(/app/pty.node): slice is not valid mach-o file')}`, + stderr: echoedSource, + timedOut: false + }) + ).toMatchObject({ reason: 'load_failed' }) + }) + + it('reads past node\u2019s echoed source line when it can only use stderr', () => { + const failure = probe({ + stderr: + '[eval]:1\nprocess.dlopen({exports:{}},f);\n ^\n\nError: something specific went wrong\n' + }) + expect(failure?.detail).toBe('Error: something specific went wrong') + }) + + it('calls a timeout unverifiable rather than blocked', () => { + // A probe that never answered is not evidence that node-pty is broken, and refusing + // to boot on it would take down hosts that work. + expect(probe({ timedOut: true })).toEqual({ + status: 'unverifiable', + reason: 'unknown', + detail: 'the node-pty load probe did not finish in time, so nothing was established' + }) + }) +}) + +describe('buildNodePtyLoadProbeScript', () => { + it('loads through absolute paths so the child cannot resolve a different copy', () => { + const script = buildNodePtyLoadProbeScript('/opt/app/node_modules/node-pty') + expect(script).toContain('"/opt/app/node_modules/node-pty/lib/index.js"') + expect(script).toContain('"/opt/app/node_modules/node-pty/lib/utils.js"') + expect(script).toContain('loadNativeModule') + // Windows defers conpty.node to first spawn, so requiring the package proves nothing there. + expect(script).toContain('conpty') + // The raw dlopen must precede requiring the package, or node-pty's own loader + // re-wraps the loader error into a misleading "Cannot find module". + expect(script.indexOf('process.dlopen')).toBeLessThan(script.indexOf('lib/index.js')) + }) +}) + +describe('checkNodePtyPrecondition', () => { + const temporaryDirs: string[] = [] + const stageNodePty = (): string => { + const root = mkdtempSync(join(tmpdir(), 'orcad-node-pty-')) + temporaryDirs.push(root) + const dir = join(root, 'node-pty') + mkdirSync(join(dir, 'build', 'Release'), { recursive: true }) + cpSync(join(REAL_NODE_PTY, 'lib'), join(dir, 'lib'), { recursive: true }) + cpSync(join(REAL_NODE_PTY, 'package.json'), join(dir, 'package.json')) + return dir + } + + afterEach(() => { + for (const dir of temporaryDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('survives a native binary that the dynamic loader refuses', () => { + // The whole reason the probe is a child process: this file is loaded with dlopen, and + // an in-process require of it can take the host down before any handler runs. Reaching + // the assertion below at all is the evidence. + const dir = stageNodePty() + writeFileSync(join(dir, 'build', 'Release', 'pty.node'), Buffer.from('not a native addon')) + + const verdict = checkNodePtyPrecondition({ nodePtyDir: dir, prebuildsDir: null }) + + expect(verdict.status).toBe('blocked') + expect(verdict.reason).toBeDefined() + expect(verdict.reason).not.toBe('spawn_helper_missing') + }) + + it('blocks when node-pty is not resolvable at all', () => { + const verdict = checkNodePtyPrecondition({ nodePtyDir: null, prebuildsDir: null }) + expect(verdict).toMatchObject({ status: 'blocked', reason: 'dependency_missing' }) + }) + + it('returns a self-consistent verdict against the real host', () => { + // Why not a predicted status: this depends on how the host was prepared. CI's test + // shard runs `vitest` directly, so `ensure-native-runtime --runtime=node` never + // builds node-pty for the Node ABI and `degraded` is correct there; a prepared + // checkout gives 'ok'. Predicting either encodes an environment. + // + // Why not require('node-pty') as ground truth: that resolves the JS wrapper while + // the native binding loads lazily, so it proves strictly less than this checks — + // that was the first version of this test and it failed on CI for that reason. + // + // What is invariant: on a host where node-pty is installed at all, the verdict is + // never 'blocked' and never carries an unestablished reason. + const verdict = checkNodePtyPrecondition({ prebuildsDir: null }) + + // Why not a fixed status: a prepared host gives 'ok', CI's unprepared shard gives + // 'degraded', and a corrupt binding gives 'blocked' — all three are honest. What is + // invariant is that anything other than 'ok' names an established cause, so the + // host can never decline a terminal for a reason it did not work out. + expect(['ok', 'degraded', 'blocked', 'unverifiable']).toContain(verdict.status) + if (verdict.status !== 'ok') { + expect(verdict.reason).toBeDefined() + expect(verdict.reason).not.toBe('unknown') + } + expect(verdict.slot).toBe( + process.platform === 'linux' + ? `linux-${process.arch}-${verdict.abi.libc}` + : `${process.platform}-${process.arch}` + ) + }) + + // Why gated on the real binding: this asserts a LOAD outcome, so it needs a pty.node + // built for the Node ABI. CI's shard never runs ensure-native-runtime, so the copy + // ENOENT'd there. + it.runIf(process.platform !== 'win32' && realNodePtyLoads)( + 'degrades rather than blocks when only spawn-helper is missing', + () => { + // node-pty posix_spawns spawn-helper, so this host loads fine and then fails ENOENT + // the first time someone opens a terminal. Everything else it serves still works. + const dir = stageNodePty() + cpSync( + join(REAL_NODE_PTY, 'build', 'Release', 'pty.node'), + join(dir, 'build', 'Release', 'pty.node') + ) + + const verdict = checkNodePtyPrecondition({ nodePtyDir: dir, prebuildsDir: null }) + + expect(verdict).toMatchObject({ status: 'degraded', reason: 'spawn_helper_missing' }) + } + ) + + // Why split: the "ok" half needs a REAL loadable pty.node, which only exists after + // `ensure-native-runtime --runtime=node`. CI's shard runs vitest directly, so copying + // from node_modules ENOENT'd there. Slot *placement* is the logic worth checking on + // every host; the load verdict needs a prepared one. + it('places the matching slot even when the payload is not loadable', () => { + const dir = stageNodePty() + const abi = detectNativeHostAbi() + const slot = + abi.libc === 'none' + ? `${abi.platform}-${abi.arch}` + : `${abi.platform}-${abi.arch}-${abi.libc}` + const prebuildsDir = mkdtempSync(join(tmpdir(), 'orcad-prebuilds-')) + temporaryDirs.push(prebuildsDir) + mkdirSync(join(prebuildsDir, slot), { recursive: true }) + writeFileSync(join(prebuildsDir, slot, 'pty.node'), 'not a real binding') + if (process.platform !== 'win32') { + writeFileSync(join(prebuildsDir, slot, 'spawn-helper'), '#!/bin/sh\nexit 0\n') + } + + const verdict = checkNodePtyPrecondition({ nodePtyDir: dir, prebuildsDir }) + + // Installed from the right slot, and honest that the payload does not load. + expect(verdict.prebuilt).toMatchObject({ installed: true, slot }) + expect(verdict.status).not.toBe('ok') + expect(verdict.reason).toBeDefined() + }) + + it.runIf(realNodePtyLoads)( + 'reports ok once a loadable slot is installed (needs a Node-ABI build)', + () => { + const dir = stageNodePty() + const abi = detectNativeHostAbi() + const slot = + abi.libc === 'none' + ? `${abi.platform}-${abi.arch}` + : `${abi.platform}-${abi.arch}-${abi.libc}` + const prebuildsDir = mkdtempSync(join(tmpdir(), 'orcad-prebuilds-')) + temporaryDirs.push(prebuildsDir) + mkdirSync(join(prebuildsDir, slot), { recursive: true }) + cpSync(REAL_PTY_NODE, join(prebuildsDir, slot, 'pty.node')) + const helper = REAL_SPAWN_HELPER + if (process.platform !== 'win32' && existsSync(helper)) { + cpSync(helper, join(prebuildsDir, slot, 'spawn-helper')) + } + + const verdict = checkNodePtyPrecondition({ nodePtyDir: dir, prebuildsDir }) + + expect(verdict.prebuilt).toMatchObject({ installed: true, slot }) + expect(verdict.status).toBe('ok') + } + ) +}) + +describe('formatNodePtyPreconditionReport', () => { + it('names the host, the slot and the action', () => { + const report = formatNodePtyPreconditionReport( + { + status: 'blocked', + slot: 'linux-x64-musl', + abi: { + platform: 'linux', + arch: 'x64', + libc: 'musl', + glibcVersion: null, + nodeAbi: '127' + }, + reason: 'dependency_missing', + prebuilt: { installed: false, slot: 'linux-x64-musl', why: 'no-slot' } + }, + 'Terminals are unavailable on this host.', + [' sudo apk add build-base python3'] + ) + + expect(report).toContain('platform linux/x64') + expect(report).toContain('libc musl') + expect(report).toContain('prebuild slot linux-x64-musl') + expect(report).toContain('No shipped prebuilt matches slot linux-x64-musl.') + expect(report).toContain('sudo apk add build-base python3') + }) +}) + +describe('probeLocalBuildToolchainHints', () => { + it('gives macOS the Xcode command line tools, not a Linux package manager', () => { + // The relay's hint list answers with a cross-distro apt/dnf/pacman/apk menu when it + // finds no package manager. On macOS every line of that menu is wrong. + const hints = probeLocalBuildToolchainHints('darwin') + expect(hints).toEqual([' xcode-select --install']) + expect(hints.join('\n')).not.toMatch(/apt-get|dnf|pacman|apk/) + }) + + it('says nothing on Windows, where node-pty ships prebuilds', () => { + expect(probeLocalBuildToolchainHints('win32')).toEqual([]) + }) + + it.runIf(process.platform === 'linux')('reuses the relay diagnosis on Linux', () => { + expect(probeLocalBuildToolchainHints('linux').length).toBeGreaterThan(0) + }) +}) diff --git a/src/main/orcad/node-pty-precondition.ts b/src/main/orcad/node-pty-precondition.ts new file mode 100644 index 00000000000..2857449c736 --- /dev/null +++ b/src/main/orcad/node-pty-precondition.ts @@ -0,0 +1,365 @@ +/** + * Prove `node-pty` can be loaded on this host BEFORE anything in the process requires it. + * + * Why this exists: of the two ways node-pty fails, only one is catchable. A missing + * module throws `MODULE_NOT_FOUND` and a caller can degrade. A module that is present but + * built against the wrong libc or Node ABI is refused by the dynamic loader, and in the + * worst case takes the process down before any handler exists — that is #9902, which + * crashed the desktop app on Ubuntu 20.04 before a window appeared + * (docs/reference/linux-glibc-compatibility.md). + * + * So the load happens in a CHILD process. Whatever the child does — throw, abort, die on + * a signal — is data to us rather than our own death, and the operator gets a sentence + * naming what to change instead of a loader stack trace. + * + * The cost is one short-lived `node -e` at startup. That is the price of turning an + * uncatchable failure into a catchable one, and it is paid once per boot. + */ +import { existsSync, accessSync, constants } from 'node:fs' +import { dirname, join } from 'node:path' +import process from 'node:process' +import { runProcessSync, type ProcessResult } from '../../shared/child-process/run-process' +import type { RuntimeTerminalUnavailableReason } from '../../shared/runtime-types' +import { + buildToolchainProbeCommand, + parseBuildToolchainProbe, + toolchainInstallHintLines +} from '../ssh/build-toolchain-diagnosis' +import { + detectNativeHostAbi, + nativeSlotName, + parseNodeAbiMismatch, + parseUnmetGlibcVersion, + type NativeHostAbi +} from './native-host-abi' +import { installPrebuiltSlot, type PrebuiltSlotOutcome } from './node-pty-prebuilt-slot' + +// Why every verdict travels on STDOUT: node echoes the whole `-e` source into stderr +// before the stack trace, so any substring test against stderr also matches this file's +// own token strings. stdout carries only what the child chose to print. +const PROBE_OK_TOKEN = 'ORCA_NODE_PTY_LOAD_OK' +const NO_BINARY_TOKEN = 'ORCA_NODE_PTY_NO_BINARY' +const LOAD_ERROR_TOKEN = 'ORCA_NODE_PTY_LOAD_ERROR' +const PROBE_TIMEOUT_MS = 20_000 + +/** + * `ok` — proved loadable. `degraded` — loads, but something only spawn-time needs is + * broken, so the host should still serve everything else. `blocked` — proved unloadable, + * so nothing in this process may require it. `unverifiable` — the probe itself did not + * answer, which is not evidence either way. + * + * Why `unverifiable` is separate from `blocked`: a probe that times out or cannot spawn + * says nothing about node-pty, and refusing to boot on it would brick working hosts for + * a reason that was never established. Same verdict discipline as + * docs/reference/ssh-execution-boundary.md — loss of contact is not proof of death. + */ +export type NodePtyPreconditionStatus = 'ok' | 'degraded' | 'blocked' | 'unverifiable' + +export type NodePtyPreconditionVerdict = { + status: NodePtyPreconditionStatus + slot: string + abi: NativeHostAbi + reason?: RuntimeTerminalUnavailableReason + detail?: string + /** What the slot install did, when one was attempted. */ + prebuilt?: PrebuiltSlotOutcome +} + +export type NodePtyProbeFailure = { + status: 'blocked' | 'unverifiable' + reason: RuntimeTerminalUnavailableReason + detail: string +} + +/** + * Read the child's exit into a cause. Pure, so every failure shape is testable from a + * host that cannot reproduce it — the whole point, since the shapes that matter belong + * to Alpine and Ubuntu 20.04. + */ +export function classifyNodePtyProbeResult( + result: Pick +): NodePtyProbeFailure | null { + const stdout = result.stdout + if (result.code === 0 && stdout.includes(PROBE_OK_TOKEN)) { + return null + } + if (result.timedOut) { + return { + status: 'unverifiable', + reason: 'unknown', + detail: 'the node-pty load probe did not finish in time, so nothing was established' + } + } + // Why signal before anything the child said: a binary that aborts or segfaults inside + // the loader never reaches the catch, and often prints nothing at all. That silence is + // exactly the uncatchable case this probe is a separate process for. + if (result.signal) { + return { + status: 'blocked', + reason: 'load_crashed', + detail: `the load probe was killed by ${result.signal}` + } + } + if (stdout.includes(NO_BINARY_TOKEN)) { + return { + status: 'blocked', + reason: 'dependency_missing', + detail: 'node-pty is installed but has no compiled binary for this platform' + } + } + const reported = readReportedLoadError(stdout) + if (reported !== null) { + return classifyLoaderMessage(reported) + } + return { + status: 'blocked', + reason: 'load_failed', + detail: firstLine(result.stderr) || `the load probe exited with code ${result.code}` + } +} + +/** The message the child caught, or null when it never got that far. */ +function readReportedLoadError(stdout: string): string | null { + const line = stdout.split('\n').find((candidate) => candidate.startsWith(LOAD_ERROR_TOKEN)) + if (!line) { + return null + } + try { + return JSON.parse(line.slice(LOAD_ERROR_TOKEN.length).trim()) as string + } catch { + return line.slice(LOAD_ERROR_TOKEN.length).trim() + } +} + +/** Read a dynamic-loader message. Pure, so shapes this host cannot reproduce are testable. */ +export function classifyLoaderMessage(message: string): NodePtyProbeFailure { + const abiMismatch = parseNodeAbiMismatch(message) + if (abiMismatch) { + return { + status: 'blocked', + reason: 'abi_mismatch', + detail: `built for Node ABI ${abiMismatch.built}, this host runs ABI ${abiMismatch.host}` + } + } + const unmetGlibc = parseUnmetGlibcVersion(message) + if (unmetGlibc) { + return { + status: 'blocked', + reason: 'libc_floor', + detail: `the binary requires GLIBC_${unmetGlibc}` + } + } + if (/(GLIBCXX_|CXXABI_)[0-9.]+'? not found/.test(message)) { + return { status: 'blocked', reason: 'libc_floor', detail: firstLine(message) } + } + if (/MODULE_NOT_FOUND|Cannot find module/.test(message)) { + return { status: 'blocked', reason: 'dependency_missing', detail: firstLine(message) } + } + return { status: 'blocked', reason: 'load_failed', detail: firstLine(message) } +} + +/** + * Why not simply the first non-empty line: when the child dies without catching, node + * prints the offending source line and a caret before the error, so line one is the + * script rather than the diagnosis. Prefer the first line that reads as an error. + */ +function firstLine(text: string): string { + const lines = text.split('\n').filter((candidate) => candidate.trim().length > 0) + const errorLine = lines.find((candidate) => /^[A-Za-z]*(Error|Exception):/.test(candidate.trim())) + return (errorLine ?? lines[0] ?? text).trim().slice(0, 400) +} + +/** + * The script the child runs. + * + * Why it dlopens the file itself rather than trusting node-pty's loader: that loader + * tries several directories and rethrows only the LAST error, so a `pty.node` the + * dynamic loader refused is reported as `Cannot find module './prebuilds/...'`. Acting on + * that sends the operator to install a module that is already there. The dlopen has to + * come BEFORE `require(index.js)` for the same reason: node-pty's unixTerminal calls the + * loader at module scope, so requiring the package first re-wraps the error we came for. + * + * Why it catches and prints instead of throwing: a thrown error reaches us as a stack + * trace with the script source echoed above it, and the message we need is then one line + * inside a blob that also contains these very tokens. What the child cannot catch — a + * loader that aborts the process — still reaches us as a signal, which is the case this + * whole indirection exists for. + */ +export function buildNodePtyLoadProbeScript(nodePtyDir: string): string { + const entry = JSON.stringify(join(nodePtyDir, 'lib', 'index.js')) + const utils = JSON.stringify(join(nodePtyDir, 'lib', 'utils.js')) + const root = JSON.stringify(nodePtyDir) + // Same directory order node-pty's own loader walks, so the file opened here is the file + // it would load. Windows defers conpty.node to the first spawn, which is why the name is + // chosen the way node-pty chooses it rather than always being 'pty'. + return [ + `const fs=require('fs'),p=require('path');`, + `const n=process.platform==='win32'&&Number(require('os').release().split('.')[2])>=18309?'conpty':'pty';`, + `let f=null;`, + `for(const d of ['build/Release','build/Debug','prebuilds/'+process.platform+'-'+process.arch]){`, + `for(const r of [${root},p.join(${root},'lib')]){`, + `const c=p.join(r,d,n+'.node');if(fs.existsSync(c)){f=c;break}}if(f)break}`, + `if(!f){console.log(${JSON.stringify(NO_BINARY_TOKEN)});process.exit(3)}`, + `try{`, + `process.dlopen({exports:{}},f);`, + `require(${entry});`, + `require(${utils}).loadNativeModule(n);`, + `console.log(${JSON.stringify(PROBE_OK_TOKEN)}+' '+p.dirname(f));`, + `}catch(e){`, + `console.log(${JSON.stringify(LOAD_ERROR_TOKEN)}+' '+JSON.stringify(String((e&&e.message)||e)));`, + `process.exit(4)}` + ].join('') +} + +function resolveNodePtyDir(): string | null { + try { + // Why require.resolve and not import: resolution only — the load itself happens in + // the child process, which is the whole point of the precondition. + return dirname(require.resolve('node-pty/package.json')) + } catch { + return null + } +} + +/** Local equivalent of the relay's remote toolchain probe, reusing its pure half. */ +export function probeLocalBuildToolchainHints(platform: NodeJS.Platform): string[] { + if (platform === 'win32') { + return [] + } + // Why macOS is not routed through the relay's hints: that function answers with a + // cross-distro apt/dnf/pacman/apk menu when it finds no package manager, and none of + // those lines is the macOS answer. Printing them here would be confidently wrong. + if (platform === 'darwin') { + return [' xcode-select --install'] + } + try { + const result = runProcessSync({ + program: '/bin/sh', + args: ['-c', buildToolchainProbeCommand()], + timeoutMs: 10_000 + }) + return toolchainInstallHintLines(parseBuildToolchainProbe(result.stdout)) + } catch { + return [] + } +} + +export function checkNodePtyPrecondition( + options: { nodePtyDir?: string | null; abi?: NativeHostAbi; prebuildsDir?: string | null } = {} +): NodePtyPreconditionVerdict { + const abi = options.abi ?? detectNativeHostAbi() + const slot = nativeSlotName(abi) + // Why `in` and not `??`: an explicit `null` means "this host cannot resolve node-pty", + // which is a case tests must be able to state. `??` would silently re-detect instead. + const nodePtyDir = 'nodePtyDir' in options ? options.nodePtyDir : resolveNodePtyDir() + if (!nodePtyDir) { + return { + status: 'blocked', + slot, + abi, + reason: 'dependency_missing', + detail: 'node-pty is not resolvable from this install' + } + } + + // Why install before probing: on a toolchain-free deployment the compiled binary does + // not exist yet, and the shipped slot is the only thing that can make the probe pass. + let prebuilt: PrebuiltSlotOutcome | undefined + if (!existsSync(join(nodePtyDir, 'build', 'Release', 'pty.node'))) { + prebuilt = installPrebuiltSlot({ + abi, + nodePtyDir, + ...(options.prebuildsDir === undefined ? {} : { prebuildsDir: options.prebuildsDir }) + }) + } + + let result: ProcessResult + try { + result = runProcessSync({ + program: process.execPath, + args: ['-e', buildNodePtyLoadProbeScript(nodePtyDir)], + timeoutMs: PROBE_TIMEOUT_MS + }) + } catch (error) { + return { + status: 'unverifiable', + slot, + abi, + reason: 'unknown', + detail: `the node-pty load probe could not be started: ${(error as Error).message}`, + ...(prebuilt ? { prebuilt } : {}) + } + } + const failure = classifyNodePtyProbeResult(result) + if (failure) { + return { + status: failure.status, + slot, + abi, + reason: failure.reason, + detail: failure.detail, + ...(prebuilt ? { prebuilt } : {}) + } + } + + // Loaded. The remaining way terminals fail is spawn-time: node-pty posix_spawns + // build/Release/spawn-helper, and a missing one turns every terminal.create into ENOENT + // on a host that otherwise looks healthy. That is a degradation, not a boot blocker. + const loadedDir = result.stdout.split(PROBE_OK_TOKEN)[1]?.trim().split('\n')[0]?.trim() + if (abi.platform !== 'win32') { + const helper = join(loadedDir || join(nodePtyDir, 'build', 'Release'), 'spawn-helper') + if (!isExecutableFile(helper)) { + return { + status: 'degraded', + slot, + abi, + reason: 'spawn_helper_missing', + detail: `expected an executable at ${helper}`, + ...(prebuilt ? { prebuilt } : {}) + } + } + } + return { status: 'ok', slot, abi, ...(prebuilt ? { prebuilt } : {}) } +} + +function isExecutableFile(path: string): boolean { + try { + accessSync(path, constants.X_OK) + return true + } catch { + return false + } +} + +/** The operator-facing report. Names the host, the cause, and the next action. */ +export function formatNodePtyPreconditionReport( + verdict: NodePtyPreconditionVerdict, + message: string, + toolchainHints: string[] = [] +): string { + const { abi } = verdict + const host = [ + `platform ${abi.platform}/${abi.arch}`, + abi.libc === 'none' + ? null + : `libc ${abi.libc}${abi.glibcVersion ? ` ${abi.glibcVersion}` : ''}`, + `Node ABI ${abi.nodeAbi}`, + `prebuild slot ${verdict.slot}` + ] + .filter((part): part is string => part !== null) + .join(', ') + const lines = [message, '', `Host: ${host}`] + if (verdict.prebuilt && !verdict.prebuilt.installed) { + lines.push( + verdict.prebuilt.why === 'no-slot' + ? `No shipped prebuilt matches slot ${verdict.slot}.` + : verdict.prebuilt.why === 'no-prebuilds-dir' + ? 'This install ships no prebuilds directory.' + : `Shipped prebuilds are unusable here: ${verdict.prebuilt.detail ?? 'ABI mismatch'}.` + ) + } + if (toolchainHints.length > 0) { + lines.push('', 'To build node-pty on this host, install a C/C++ toolchain:', ...toolchainHints) + } + return lines.join('\n') +} diff --git a/src/main/orcad/orcad-bind-address.test.ts b/src/main/orcad/orcad-bind-address.test.ts new file mode 100644 index 00000000000..2ddbcc56343 --- /dev/null +++ b/src/main/orcad/orcad-bind-address.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { + bindHostIsNetworkExposed, + describeOrcadBindExposure, + ORCAD_LOOPBACK_BIND_HOST, + OrcadBindAddressError, + resolveOrcadBindHost +} from './orcad-bind-address' + +describe('resolveOrcadBindHost', () => { + it('defaults to loopback when the operator asked for nothing', () => { + expect(resolveOrcadBindHost()).toBe(ORCAD_LOOPBACK_BIND_HOST) + expect(ORCAD_LOOPBACK_BIND_HOST).toBe('127.0.0.1') + }) + + it('accepts literal IPv4 and IPv6 addresses, including explicit wide binds', () => { + expect(resolveOrcadBindHost('0.0.0.0')).toBe('0.0.0.0') + expect(resolveOrcadBindHost('10.1.2.3')).toBe('10.1.2.3') + expect(resolveOrcadBindHost('::1')).toBe('::1') + expect(resolveOrcadBindHost('localhost')).toBe('127.0.0.1') + expect(resolveOrcadBindHost(' 127.0.0.1 ')).toBe('127.0.0.1') + }) + + it('refuses hostnames, because DNS would decide which interface got bound', () => { + expect(() => resolveOrcadBindHost('internal.example')).toThrow(OrcadBindAddressError) + expect(() => resolveOrcadBindHost('')).toThrow(OrcadBindAddressError) + expect(() => resolveOrcadBindHost('0.0.0.0:80')).toThrow(OrcadBindAddressError) + }) +}) + +describe('bindHostIsNetworkExposed', () => { + it('separates local-only addresses from network-reachable ones', () => { + expect(bindHostIsNetworkExposed('127.0.0.1')).toBe(false) + expect(bindHostIsNetworkExposed('127.5.5.5')).toBe(false) + expect(bindHostIsNetworkExposed('::1')).toBe(false) + expect(bindHostIsNetworkExposed('0.0.0.0')).toBe(true) + expect(bindHostIsNetworkExposed('::')).toBe(true) + expect(bindHostIsNetworkExposed('10.1.2.3')).toBe(true) + }) + + it('says out loud when a deployment is reachable from the network', () => { + expect(describeOrcadBindExposure('0.0.0.0')).toContain('reachable from the network') + expect(describeOrcadBindExposure('127.0.0.1')).toContain('local only') + }) +}) diff --git a/src/main/orcad/orcad-bind-address.ts b/src/main/orcad/orcad-bind-address.ts new file mode 100644 index 00000000000..d5fc4548dd1 --- /dev/null +++ b/src/main/orcad/orcad-bind-address.ts @@ -0,0 +1,66 @@ +/** + * The address `orcad` binds its RPC listener to. + * + * Why loopback by default: the desktop stays on loopback until the user pairs, and the + * shipping design reaches a remote orcad over an SSH local port-forward — so the wide + * bind orcad had was both a departure from the desktop's posture and unnecessary for the + * deploy model. Exposure is now something an operator asks for by name. + */ +import { isIP } from 'node:net' + +export const ORCAD_LOOPBACK_BIND_HOST = '127.0.0.1' +const ALL_INTERFACES_V4 = '0.0.0.0' +const ALL_INTERFACES_V6 = '::' + +export class OrcadBindAddressError extends Error { + readonly code = 'orcad_invalid_bind_address' +} + +/** + * Resolve `--bind`. Literal IPs only. + * + * Why not hostnames: `listen()` resolves a name through DNS, so the interface actually + * bound is decided by resolver configuration this process cannot see. An operator who + * writes `--bind internal.example` would have no way to know whether the service came up + * on a private interface or a public one. + */ +export function resolveOrcadBindHost(raw?: string): string { + if (raw === undefined) { + return ORCAD_LOOPBACK_BIND_HOST + } + const value = raw.trim() + if (value === '') { + throw new OrcadBindAddressError('--bind expects an address') + } + if (value === 'localhost') { + return ORCAD_LOOPBACK_BIND_HOST + } + if (isIP(value) === 0) { + throw new OrcadBindAddressError( + `--bind expects a literal IP address (got '${value}'). Hostnames are refused because ` + + 'DNS decides which interface would be bound. Use 127.0.0.1 for loopback, or ' + + '0.0.0.0 to expose every interface.' + ) + } + return value +} + +/** True when this address reaches beyond the local machine. */ +export function bindHostIsNetworkExposed(host: string): boolean { + if (host === ALL_INTERFACES_V4 || host === ALL_INTERFACES_V6) { + return true + } + if (isIP(host) === 4) { + return !host.startsWith('127.') + } + return host !== '::1' +} + +/** One line for the startup log, so an exposed deployment is never a silent default. */ +export function describeOrcadBindExposure(host: string): string { + return bindHostIsNetworkExposed(host) + ? `orcad is bound to ${host} and is reachable from the network. Anything that can reach ` + + 'this port can attempt pairing.' + : `orcad is bound to ${host} (local only). Reach it from another machine with an SSH ` + + 'local port-forward, or re-launch with --bind to expose it.' +} diff --git a/src/main/orcad/orcad-bundle-native-load-order.test.ts b/src/main/orcad/orcad-bundle-native-load-order.test.ts new file mode 100644 index 00000000000..1c26501b6a6 --- /dev/null +++ b/src/main/orcad/orcad-bundle-native-load-order.test.ts @@ -0,0 +1,91 @@ +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { runProcessSync } from '../../shared/child-process/run-process' + +/** + * The preflight only prevents a loader crash if nothing in the bundle's import graph has + * already required node-pty by the time it runs. esbuild wraps `local-pty-provider` in a + * lazy initializer because `orcad-entry` reaches it through `await import('../ipc/pty')`, + * and that laziness is load-bearing rather than incidental — a single top-level static + * import anywhere in the graph would hoist `require("node-pty")` above every statement in + * `main.ts`, including the preflight. + * + * Why this builds the bundle instead of skipping without one: no CI job builds orcad and + * runs vitest. `smoke:orcad-terminal` builds it in the static-analysis job, which never + * runs vitest; the `orcad_browser` job runs vitest but deliberately does not build orcad. + * A `runIf(existsSync(...))` guard therefore skips in every shard, forever — the same way + * an unset ORCA_BROWSER_EXECUTABLE kept the browser provider uncovered. + * + * Why not fail-when-CI instead: the wiring that would satisfy it lives in `.github/`, so + * that turns a silent gap into a red build someone else has to fix. Building costs well + * under a second (esbuild), works in every shard and on every machine, and needs no job + * to cooperate. What it must never do is skip. + */ +const REPO_ROOT = join(__dirname, '..', '..', '..') +const BUNDLE = join(REPO_ROOT, 'out', 'orcad', 'orcad.js') +const BUILD_SCRIPT = join(REPO_ROOT, 'config', 'scripts', 'build-orcad.mjs') + +/** + * Why it throws rather than skipping when the build fails: a bundle that cannot be built + * is a louder problem than the one this test checks, and swallowing it here is exactly + * how the assertion would go missing. + */ +function ensureOrcadBundle(): void { + if (existsSync(BUNDLE)) { + return + } + const build = runProcessSync({ + program: process.execPath, + args: [BUILD_SCRIPT], + cwd: REPO_ROOT, + timeoutMs: 300_000 + }) + if (!existsSync(BUNDLE)) { + const output = `${build.stdout}${build.stderr}`.slice(0, 4000) + throw new Error( + `could not build ${BUNDLE} (exit ${build.code}); the load-order assertion cannot run:\n${output}` + ) + } +} + +describe('orcad bundle native load order', () => { + const dirs: string[] = [] + afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('does not require node-pty in-process before the entry rejects its argv', () => { + ensureOrcadBundle() + const dir = mkdtempSync(join(tmpdir(), 'orcad-load-order-')) + dirs.push(dir) + const harness = join(dir, 'harness.cjs') + writeFileSync( + harness, + [ + "const Module = require('module')", + 'const original = Module._load', + 'Module._load = function (request, ...rest) {', + " if (request === 'node-pty') { console.log('IN_PROCESS_NODE_PTY_REQUIRE') }", + ' return original.call(this, request, ...rest)', + '}', + "process.argv.push('--orcad-load-order-check')", + `require(${JSON.stringify(BUNDLE)})` + ].join('\n') + ) + + const result = runProcessSync({ + program: process.execPath, + args: [harness], + timeoutMs: 120_000 + }) + const output = `${result.stdout}${result.stderr}` + + // Proof the graph fully loaded and reached argv parsing rather than dying early. + expect(output).toContain('Unknown argument: --orcad-load-order-check') + expect(output).not.toContain('IN_PROCESS_NODE_PTY_REQUIRE') + }, 360_000) +}) diff --git a/src/main/orcad/orcad-daemon-supervision.test.ts b/src/main/orcad/orcad-daemon-supervision.test.ts new file mode 100644 index 00000000000..19616c9d5ef --- /dev/null +++ b/src/main/orcad/orcad-daemon-supervision.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + initDaemonPtyProviderMock, + disconnectDaemonMock, + shutdownDaemonMock, + daemonOwnsFreshPersistentPtysMock, + readDaemonPidRecordMock +} = vi.hoisted(() => ({ + initDaemonPtyProviderMock: vi.fn<(signal?: unknown, options?: unknown) => Promise>(), + disconnectDaemonMock: vi.fn<() => Promise>(), + shutdownDaemonMock: vi.fn<() => Promise>(), + daemonOwnsFreshPersistentPtysMock: vi.fn<() => boolean>(), + readDaemonPidRecordMock: vi.fn<() => { pid: number } | null>() +})) + +vi.mock('../daemon/daemon-init', () => ({ + initDaemonPtyProvider: initDaemonPtyProviderMock, + disconnectDaemon: disconnectDaemonMock, + // Exported here purely so the test can prove it is never reached — killing the daemon on + // orcad shutdown is what would make an orcad restart destructive again. + shutdownDaemon: shutdownDaemonMock, + daemonOwnsFreshPersistentPtys: daemonOwnsFreshPersistentPtysMock, + readDaemonPidRecord: readDaemonPidRecordMock +})) + +const { startOrcadDaemon, stopOrcadDaemon } = await import('./orcad-daemon-supervision') + +beforeEach(() => { + initDaemonPtyProviderMock.mockResolvedValue() + disconnectDaemonMock.mockResolvedValue() + shutdownDaemonMock.mockResolvedValue() + daemonOwnsFreshPersistentPtysMock.mockReturnValue(true) + readDaemonPidRecordMock.mockReturnValue({ pid: 4242 }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() +}) + +describe('startOrcadDaemon', () => { + it('reports live with the daemon pid once the provider is installed', async () => { + await expect(startOrcadDaemon()).resolves.toEqual({ state: 'live', pid: 4242 }) + }) + + it('does not arm the macOS login-session death watch', async () => { + await startOrcadDaemon() + // That watch retires the daemon when the spawning GUI login session dies. An orcad + // daemon must survive its SSH session ending — arming it would kill every terminal the + // moment the operator logged out, which is the opposite of the property being bought. + expect(initDaemonPtyProviderMock).toHaveBeenCalledWith(undefined, { + macosLoginSessionWatch: false + }) + }) + + it('reports degraded when fresh terminals would fall back to the local provider', async () => { + daemonOwnsFreshPersistentPtysMock.mockReturnValue(false) + const result = await startOrcadDaemon() + expect(result.state).toBe('degraded') + }) + + it('fails open when the daemon cannot start at all', async () => { + initDaemonPtyProviderMock.mockRejectedValue(new Error('node-pty is missing')) + daemonOwnsFreshPersistentPtysMock.mockReturnValue(false) + // Fail-open, like the desktop: git, worktrees and non-persistent terminals must still + // serve. What must not happen is a thrown startup or a claim of persistence. + await expect(startOrcadDaemon()).resolves.toEqual({ + state: 'unavailable', + reason: 'node-pty is missing' + }) + }) +}) + +describe('stopOrcadDaemon', () => { + it('disconnects and never shuts the daemon down', async () => { + await stopOrcadDaemon() + expect(disconnectDaemonMock).toHaveBeenCalledTimes(1) + // The whole point of item 4: an orcad restart is non-destructive only if the daemon + // outlives it. shutdownDaemon() kills the daemon and every PTY under it. + expect(shutdownDaemonMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/orcad/orcad-daemon-supervision.ts b/src/main/orcad/orcad-daemon-supervision.ts new file mode 100644 index 00000000000..d35b03048d3 --- /dev/null +++ b/src/main/orcad/orcad-daemon-supervision.ts @@ -0,0 +1,67 @@ +/** + * orcad's half of the supervision contract: it spawns and supervises the terminal daemon. + * + * The whole reason the peer model is recommended over an SSH target is that daemon-backed + * PTYs stay `live` across a runtime restart (docs/reference/ssh-execution-boundary.md). + * Without a daemon here, every orcad restart, update and rollback is a SIGKILL for every + * running terminal — on the host whose selling point is that work survives the client going + * away. + * + * The constraint that makes a restart non-destructive lives in `stopOrcadDaemon` below: the + * daemon is DETACHED and must outlive this process. Anything that tears it down on the way + * out silently converts a restart back into data loss. + */ +import { + disconnectDaemon, + daemonOwnsFreshPersistentPtys, + initDaemonPtyProvider, + readDaemonPidRecord +} from '../daemon/daemon-init' + +export type OrcadDaemonStartup = + | { state: 'live'; pid: number | null } + | { state: 'degraded'; reason: string } + | { state: 'unavailable'; reason: string } + +/** + * Bring the daemon up and install it as the local PTY provider. + * + * Fail-open, like the desktop: a host that cannot start a daemon must still serve git, + * worktrees and non-persistent terminals. What it must NOT do is keep claiming persistence + * — `daemonOwnsFreshPersistentPtys()` is what the runtime reads for that, and it answers + * false here without any extra bookkeeping. + */ +export async function startOrcadDaemon(): Promise { + try { + // Why no login-session watch: that retires the daemon when the spawning macOS GUI login + // session dies. An orcad daemon must survive its SSH session ending — that is the point. + await initDaemonPtyProvider(undefined, { macosLoginSessionWatch: false }) + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + console.error( + `[orcad] The terminal daemon did not start: ${reason}\n` + + '[orcad] Terminals will run in-process and WILL NOT survive an orcad restart.' + ) + return { state: 'unavailable', reason } + } + if (!daemonOwnsFreshPersistentPtys()) { + const reason = 'daemon adopted in degraded mode; fresh terminals run on the local provider' + console.warn( + `[orcad] ${reason}. Existing daemon sessions keep working, but new terminals will not ` + + 'survive an orcad restart until the daemon is restarted.' + ) + return { state: 'degraded', reason } + } + return { state: 'live', pid: readDaemonPidRecord()?.pid ?? null } +} + +/** + * Release the daemon without killing it. + * + * Why `disconnectDaemon` and never `shutdownDaemon`: shutdown kills the daemon process and + * every PTY under it. Calling it here would make orcad's own restart destructive, which is + * the exact property this whole item exists to buy. + */ +export async function stopOrcadDaemon(): Promise { + await disconnectDaemon() +} diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 6afdcdb50e3..4af2a87c45d 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -18,6 +18,16 @@ import type { ServeReadiness } from '../server/serve-readiness' import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' import { resolveOrcadBrowserProvider, type OrcadBrowserProvider } from './orcad-browser-provider' import { resolveOrcadInstallRoot, resolveOrcadPath, resolveUserDataPath } from './orcad-app-paths' +import { + describeOrcadBindExposure, + OrcadBindAddressError, + resolveOrcadBindHost +} from './orcad-bind-address' +import { + acquireOrcadInstanceLock, + OrcadInstanceLockError, + type OrcadInstanceLock +} from './orcad-instance-lock' let runOrcadQuitHandlers = (): void => {} @@ -80,6 +90,8 @@ export type OrcadOptions = { json?: boolean noPairing?: boolean pairingAddress?: string + /** Literal IP to bind. Defaults to loopback; see orcad-bind-address.ts. */ + bind?: string } export type OrcadHandle = { @@ -95,24 +107,30 @@ export type OrcadHandle = { export async function startOrcad(options: OrcadOptions = {}): Promise { installOrcadHostAdapters() const userDataPath = resolveUserDataPath() + // Why before anything else touches the root: the profile index, the store and the daemon + // runtime dir all live under it, and two orcads sharing them corrupt state silently. This + // is also the last point at which refusing costs nothing. + const instanceLock = acquireOrcadInstanceLock(userDataPath) const browserProvider = await resolveOrcadBrowserProvider({ userDataPath }) setRuntimeBrowserCommandsFactory(browserProvider?.factory ?? null, { headless: browserProvider !== null, ...(browserProvider ? { isAvailable: () => browserProvider.isAvailable() } : {}) }) try { - return await startOrcadRuntime(options, browserProvider) + return await startOrcadRuntime(options, browserProvider, instanceLock) } catch (error) { await browserProvider?.stop() setRuntimeBrowserCommandsFactory(null) runOrcadQuitHandlers() + instanceLock.release() throw error } } async function startOrcadRuntime( options: OrcadOptions, - browserProvider: OrcadBrowserProvider | null + browserProvider: OrcadBrowserProvider | null, + instanceLock: OrcadInstanceLock ): Promise { const { OrcaRuntimeService } = await import('../runtime/orca-runtime') const { OrcaRuntimeRpcServer } = await import('../runtime/runtime-rpc') @@ -125,6 +143,9 @@ async function startOrcadRuntime( const { ensureActiveOrcaProfile, initOrcaProfilePaths } = await import('../orca-profiles/profile-index-store') const { initSshHostKeyStoreFile } = await import('../ssh/ssh-host-key-store') + const { startOrcadDaemon, stopOrcadDaemon } = await import('./orcad-daemon-supervision') + const { daemonOwnsFreshPersistentPtys } = await import('../daemon/daemon-init') + const { collectOrcadHealth } = await import('./orcad-health') const runtimeUserDataPath = getAppEnvironment().getPath('userData') initOrcaProfilePaths() @@ -139,6 +160,11 @@ async function startOrcadRuntime( // which is safe but silently discards accept records on every launch. initSshHostKeyStoreFile(profile.dataFile) + // Why before the runtime and the PTY handlers: `setLocalPtyProvider` installs the daemon + // adapter as THE local provider, and the registry's contract is that it lands before + // registerPtyHandlers so the IPC layer routes through the daemon from the first call. + await startOrcadDaemon() + const runtime = new OrcaRuntimeService(store, undefined, { // Why lazy: a daemon swap replaces the provider after construction, so an eager // reference would freeze the pre-daemon one. @@ -146,10 +172,11 @@ async function startOrcadRuntime( // Why: destructive worktree removal refuses to run without a provider to stop // processes through — correctly, since it cannot otherwise verify the tree is idle. getSshProvider: (connectionId) => getSshPtyProvider(connectionId), - // Why false: this host does not run the terminal daemon, so persistent local PTYs - // cannot be recovered. The constructor defaults this to true, which would claim a - // capability orcad does not have. - canRecoverPersistentLocalPtys: () => false, + // Why the daemon predicate and not a constant: orcad now spawns the terminal daemon, so + // its PTYs DO survive an orcad restart — but only while a daemon that owns fresh + // sessions is installed. A failed or degraded launch has to answer false, and this reads + // that live rather than snapshotting it at construction. + canRecoverPersistentLocalPtys: () => daemonOwnsFreshPersistentPtys(), // Why 'blocked': `'openable'` means a desktop window can be opened here, which is // what powers serve→desktop promotion. A Node host can never do that, and the // constructor's default would advertise it. @@ -173,14 +200,20 @@ async function startOrcadRuntime( await runtime.refreshRestoredOrchestrationAuthority() await runtime.reconcileLegacyWorkerTerminals() + const bindHost = resolveOrcadBindHost(options.bind) const rpc = new OrcaRuntimeRpcServer({ runtime, userDataPath: runtimeUserDataPath, enableWebSocket: true, - exposeNetworkByDefault: true, + // Why pinned and not `exposeNetworkByDefault`: an unattended host's exposure must be + // exactly what the operator asked for, on every launch. The default path widens itself + // once a device has connected, so a loopback deployment would silently go wide one + // restart after its first client paired. + pinnedBindHost: bindHost, ...(options.port !== undefined ? { wsPort: options.port, preferPinnedWsPort: true } : {}) }) await rpc.start() + console.error(`[orcad] ${describeOrcadBindExposure(bindHost)}`) const boundEndpoint = rpc.getWebSocketEndpoint() const advertised = boundEndpoint @@ -215,7 +248,11 @@ async function startOrcadRuntime( scope: 'runtime', qr: null } - : offer + : offer, + // Why in the readiness payload: this is the one message a supervisor and a deploy + // transaction both read, and a green orcad with a dead daemon is exactly the + // looks-healthy-but-useless state they must not activate. + health: await collectOrcadHealth(getAppEnvironment().getVersion()) } await new ServeReadinessPublisher().publish(readiness, { @@ -228,15 +265,20 @@ async function startOrcadRuntime( try { await rpc.stop() } finally { + // Why disconnect and not shut down: the daemon must outlive this process, or an + // orcad restart goes back to killing every running terminal. See + // orcad-daemon-supervision.ts. + await stopOrcadDaemon() await browserProvider?.stop() setRuntimeBrowserCommandsFactory(null) runOrcadQuitHandlers() + instanceLock.release() } } } } -function parseArgs(argv: string[]): OrcadOptions { +export function parseArgs(argv: string[]): OrcadOptions { const options: OrcadOptions = {} for (let i = 0; i < argv.length; i += 1) { const arg = argv[i] @@ -252,6 +294,13 @@ function parseArgs(argv: string[]): OrcadOptions { options.json = true } else if (arg === '--no-pairing') { options.noPairing = true + } else if (arg === '--bind') { + const value = argv[i + 1] + if (value === undefined) { + throw new Error('--bind expects a value') + } + options.bind = value + i += 1 } else if (arg === '--pairing-address') { const value = argv[i + 1] if (!value) { @@ -266,22 +315,56 @@ function parseArgs(argv: string[]): OrcadOptions { return options } +/** + * Exit codes a supervisor can act on. Closed set — see docs/reference/orcad-operations.md. + * + * `ORCAD_EXIT_CONFIGURATION` is the load-bearing one: a data root owned by someone else, or + * held by another orcad, is not fixed by restarting. Restarting on it is the crash-loop the + * supervision contract has to prevent, so systemd's `RestartPreventExitStatus` needs a code + * that means "do not retry" and nothing else does. + */ +export const ORCAD_EXIT_OK = 0 +export const ORCAD_EXIT_FAILED = 1 +export const ORCAD_EXIT_CONFIGURATION = 78 + +/** Bounded so a wedged transport cannot hold a supervisor's stop past its own deadline. */ +export const ORCAD_SHUTDOWN_DEADLINE_MS = 15_000 + +export function resolveOrcadExitCode(error: unknown): number { + return error instanceof OrcadInstanceLockError || error instanceof OrcadBindAddressError + ? ORCAD_EXIT_CONFIGURATION + : ORCAD_EXIT_FAILED +} + export async function main(argv: string[] = process.argv.slice(2)): Promise { const handle = await startOrcad(parseArgs(argv)) let stopping = false const shutdown = (signal: NodeJS.Signals): void => { if (stopping) { - return + // Why escalate rather than ignore: a supervisor's second signal means the first + // deadline elapsed. Continuing to wait silently is what makes a stop hang until + // SIGKILL, which is the one teardown that skips the daemon handoff entirely. + console.error(`orcad: second ${signal} during shutdown — exiting immediately`) + process.exit(ORCAD_EXIT_FAILED) } stopping = true + // Why a self-imposed deadline as well: the supervisor's SIGKILL leaves no exit code and + // no log line. Exiting ourselves keeps the failure attributable. + const deadline = setTimeout(() => { + console.error( + `orcad: shutdown after ${signal} exceeded ${ORCAD_SHUTDOWN_DEADLINE_MS}ms — exiting` + ) + process.exit(ORCAD_EXIT_FAILED) + }, ORCAD_SHUTDOWN_DEADLINE_MS) + deadline.unref() handle .stop() - .then(() => process.exit(0)) + .then(() => process.exit(ORCAD_EXIT_OK)) // Why not rethrow: we are already tearing down on a signal, and an exit code is // the only thing a supervisor can act on. .catch((error) => { console.error(`orcad: shutdown after ${signal} failed:`, error) - process.exit(1) + process.exit(ORCAD_EXIT_FAILED) }) } process.on('SIGINT', () => shutdown('SIGINT')) diff --git a/src/main/orcad/orcad-health.test.ts b/src/main/orcad/orcad-health.test.ts new file mode 100644 index 00000000000..7a1a2a269ff --- /dev/null +++ b/src/main/orcad/orcad-health.test.ts @@ -0,0 +1,152 @@ +import { mkdtempSync, 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 { ParsedDaemonPid } from '../daemon/daemon-pid-file-parse' +import type { DaemonHealth } from '../daemon/daemon-health' + +const { + checkDaemonHealthMock, + getDaemonEndpointFactsMock, + readDaemonPidRecordMock, + daemonOwnsFreshPersistentPtysMock +} = vi.hoisted(() => ({ + checkDaemonHealthMock: vi.fn<() => Promise>(), + getDaemonEndpointFactsMock: vi.fn<() => unknown>(), + readDaemonPidRecordMock: vi.fn<() => ParsedDaemonPid | null>(), + daemonOwnsFreshPersistentPtysMock: vi.fn<() => boolean>() +})) + +vi.mock('../daemon/daemon-health', () => ({ checkDaemonHealth: checkDaemonHealthMock })) +vi.mock('../daemon/daemon-init', () => ({ + getDaemonEndpointFacts: getDaemonEndpointFactsMock, + readDaemonPidRecord: readDaemonPidRecordMock, + daemonOwnsFreshPersistentPtys: daemonOwnsFreshPersistentPtysMock +})) + +const { collectOrcadHealth, collectTerminalDaemonHealth, computeOrcadBuildHash } = + await import('./orcad-health') + +const LIVE_FACTS = { + runtimeDir: '/data/daemon', + socketPath: '/data/daemon/daemon-v36.sock', + tokenPath: '/data/daemon/daemon-v36.token', + pidPath: '/data/daemon/daemon-v36.pid', + protocolVersion: 36 +} + +const PID_RECORD: ParsedDaemonPid = { + pid: 4242, + startedAtMs: 1_000, + entryPath: '/opt/orcad/daemon-entry.js', + appVersion: '1.2.2', + launchNonce: 'n', + linuxStartTicks: null, + bootId: null, + spawnerExecPath: null +} + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! + +beforeEach(() => { + getDaemonEndpointFactsMock.mockReturnValue(LIVE_FACTS) + readDaemonPidRecordMock.mockReturnValue(PID_RECORD) + daemonOwnsFreshPersistentPtysMock.mockReturnValue(true) + checkDaemonHealthMock.mockResolvedValue('healthy') +}) + +afterEach(() => { + Object.defineProperty(process, 'platform', originalPlatform) + vi.clearAllMocks() +}) + +describe('collectTerminalDaemonHealth', () => { + it('reports live only when the daemon answered its own PTY spawn probe', async () => { + const health = await collectTerminalDaemonHealth() + expect(health.state).toBe('live') + expect(health.selfTest).toMatchObject({ ok: true, verdict: 'healthy', coverage: 'pty-spawn' }) + expect(health.pid).toBe(4242) + // The build the LIVE daemon came from, which can legitimately predate this orcad. + expect(health.buildVersion).toBe('1.2.2') + expect(health.entryPath).toBe('/opt/orcad/daemon-entry.js') + expect(health.protocolVersion).toBe(36) + // Why assert the coordinates: a self-test that probed some other endpoint would prove + // nothing about the daemon this process installed. + expect(checkDaemonHealthMock).toHaveBeenCalledWith(LIVE_FACTS.socketPath, LIVE_FACTS.tokenPath) + }) + + it('is not green when the daemon is up but cannot spawn a PTY', async () => { + checkDaemonHealthMock.mockResolvedValue('pty-spawn-unhealthy') + const health = await collectTerminalDaemonHealth() + expect(health.selfTest.ok).toBe(false) + expect(health.selfTest.verdict).toBe('pty-spawn-unhealthy') + // Degraded, not absent: it still owns live sessions, and calling those exited would be + // the verdict the execution-boundary vocabulary forbids guessing. + expect(health.state).toBe('degraded') + }) + + it('is not green when the daemon stopped answering entirely', async () => { + checkDaemonHealthMock.mockResolvedValue('unreachable') + const health = await collectTerminalDaemonHealth() + expect(health.selfTest.ok).toBe(false) + expect(health.state).toBe('degraded') + }) + + it('is not green when fresh terminals fall back to the local provider', async () => { + daemonOwnsFreshPersistentPtysMock.mockReturnValue(false) + const health = await collectTerminalDaemonHealth() + // The socket answers and the probe passes, but new terminals would die with this + // process — reporting live here is precisely the looks-healthy-but-useless shape. + expect(health.selfTest.ok).toBe(true) + expect(health.ownsFreshSessions).toBe(false) + expect(health.state).toBe('degraded') + }) + + it('reports absent, and probes nothing, when no daemon was ever installed', async () => { + getDaemonEndpointFactsMock.mockReturnValue(null) + daemonOwnsFreshPersistentPtysMock.mockReturnValue(false) + const health = await collectTerminalDaemonHealth() + expect(health.state).toBe('absent') + expect(health.selfTest).toMatchObject({ ok: false, verdict: 'no-daemon' }) + expect(health.pid).toBeNull() + expect(checkDaemonHealthMock).not.toHaveBeenCalled() + }) + + it('declares handshake-only coverage on win32, where the spawn probe is a no-op', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const health = await collectTerminalDaemonHealth() + // `checkPtySpawnHealth` returns immediately on win32 without spawning anything, so a + // green verdict there must not be reported as a PTY round trip. + expect(health.selfTest.coverage).toBe('handshake') + }) +}) + +describe('collectOrcadHealth', () => { + it('carries build identity and the Node ABI native addons must match', async () => { + const health = await collectOrcadHealth('1.2.3') + expect(health.buildVersion).toBe('1.2.3') + expect(health.nodeVersion).toBe(process.versions.node) + expect(health.nodeAbi).toBe(process.versions.modules) + expect(health.platform).toBe(process.platform) + expect(health.terminalDaemon.state).toBe('live') + }) +}) + +describe('computeOrcadBuildHash', () => { + it('changes when the bundle bytes change, even at the same version string', () => { + const dir = mkdtempSync(join(tmpdir(), 'orcad-build-hash-')) + const entry = join(dir, 'orcad.js') + writeFileSync(entry, 'build-a') + const first = computeOrcadBuildHash(entry) + writeFileSync(entry, 'build-b') + // A rollback that did not actually replace the file is what this has to catch, and a + // version string cannot. + expect(computeOrcadBuildHash(entry)).not.toBe(first) + }) + + it('answers unknown rather than throwing when the entry cannot be read', () => { + expect(computeOrcadBuildHash(join(tmpdir(), 'definitely-absent-orcad.js'))).toBe('unknown') + // A process with no argv[1] (an embedded host) still has to publish a readiness payload. + expect(computeOrcadBuildHash('')).toBe('unknown') + }) +}) diff --git a/src/main/orcad/orcad-health.ts b/src/main/orcad/orcad-health.ts new file mode 100644 index 00000000000..9f271c55d84 --- /dev/null +++ b/src/main/orcad/orcad-health.ts @@ -0,0 +1,155 @@ +/** + * orcad's health surface: the facts a supervisor needs to decide whether this deployment + * is actually serving, as opposed to merely listening. + * + * The load-bearing one is the terminal-daemon verdict. orcad answers RPC from its own + * process, so "the port is open" stays true while the daemon that owns every terminal is + * dead — a green host that cannot run a single command. The self-test below therefore has + * to cross the process boundary: orcad drives it, the daemon performs it, and the verdict + * travels back over the daemon's socket. + */ +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import process from 'node:process' +import { checkDaemonHealth, type DaemonHealth } from '../daemon/daemon-health' +import { + daemonOwnsFreshPersistentPtys, + getDaemonEndpointFacts, + readDaemonPidRecord +} from '../daemon/daemon-init' + +/** + * How much a green self-test actually proves. + * + * `pty-spawn` — the daemon spawned a real PTY inside its own process and it worked. + * `handshake` — the daemon answered its protocol handshake, but its spawn probe is a no-op + * on this platform (win32: `checkPtySpawnHealth` returns without spawning). Reported + * separately rather than folded into `ok`, because claiming a PTY round trip we did not + * perform is the failure mode this surface exists to prevent. + */ +export type PtySelfTestCoverage = 'pty-spawn' | 'handshake' + +export type PtySelfTest = { + ok: boolean + coverage: PtySelfTestCoverage + /** The daemon's own verdict word, so a failure is diagnosable without re-probing. */ + verdict: DaemonHealth | 'no-daemon' + durationMs: number +} + +export type TerminalDaemonHealth = { + /** `live` requires the daemon to have answered; absence is never inferred from silence. */ + state: 'live' | 'degraded' | 'absent' + /** True only when FRESH terminals are daemon-owned, i.e. survive an orcad restart. */ + ownsFreshSessions: boolean + pid: number | null + /** The build the LIVE daemon was forked from, which may predate this orcad after an update. */ + buildVersion: string | null + entryPath: string | null + protocolVersion: number | null + selfTest: PtySelfTest +} + +export type OrcadHealth = { + /** Content hash of the running orcad bundle — the deployed build's identity. */ + buildHash: string + buildVersion: string + nodeVersion: string + /** `process.versions.modules`: the ABI every native addon on this host must match. */ + nodeAbi: string + platform: NodeJS.Platform + arch: string + pid: number + terminalDaemon: TerminalDaemonHealth +} + +/** + * Identity of the exact bytes running. + * + * Why hash the entry and not read a version string: `ORCA_VERSION` is whatever the deploy + * exported, so two different builds can carry one version. A rollback that did not actually + * replace the file is precisely what this has to catch. + */ +export function computeOrcadBuildHash(entryPath = process.argv[1]): string { + if (!entryPath) { + return 'unknown' + } + try { + return createHash('sha256').update(readFileSync(entryPath)).digest('hex').slice(0, 16) + } catch { + return 'unknown' + } +} + +/** + * Probe the daemon across the process boundary. + * + * `checkDaemonHealth` is the cross-process test: it opens the daemon's socket, completes the + * protocol handshake, and asks the daemon to run `ptySpawnHealth` — a real short-lived PTY + * spawned inside the daemon. Only a daemon that is alive AND can create terminals answers + * `healthy`; a wedged one times out to `unreachable`, and one whose node-pty or login session + * is broken answers `pty-spawn-unhealthy`. + */ +export async function runTerminalDaemonSelfTest( + now: () => number = () => Date.now() +): Promise { + const startedAt = now() + // Why: `checkPtySpawnHealth` returns immediately on win32 without spawning anything, so a + // green verdict there covers the handshake only. Say so instead of overclaiming. + const coverage: PtySelfTestCoverage = process.platform === 'win32' ? 'handshake' : 'pty-spawn' + const facts = getDaemonEndpointFacts() + if (!facts) { + return { ok: false, coverage, verdict: 'no-daemon', durationMs: now() - startedAt } + } + const verdict = await checkDaemonHealth(facts.socketPath, facts.tokenPath) + return { ok: verdict === 'healthy', coverage, verdict, durationMs: now() - startedAt } +} + +export async function collectTerminalDaemonHealth(): Promise { + const facts = getDaemonEndpointFacts() + const selfTest = await runTerminalDaemonSelfTest() + if (!facts) { + return { + state: 'absent', + ownsFreshSessions: false, + pid: null, + buildVersion: null, + entryPath: null, + protocolVersion: null, + selfTest + } + } + const record = readDaemonPidRecord() + const ownsFreshSessions = daemonOwnsFreshPersistentPtys() + return { + // Why `degraded` and not `absent` on a failed self-test: a daemon that answered its + // handshake but failed the spawn probe is still holding live sessions. Reporting it gone + // would invite a caller to treat those terminals as exited, which is the one verdict the + // execution-boundary vocabulary forbids guessing. + state: + selfTest.ok && ownsFreshSessions + ? 'live' + : selfTest.verdict === 'no-daemon' + ? 'absent' + : 'degraded', + ownsFreshSessions, + pid: record?.pid ?? null, + buildVersion: record?.appVersion ?? null, + entryPath: record?.entryPath ?? null, + protocolVersion: facts.protocolVersion, + selfTest + } +} + +export async function collectOrcadHealth(buildVersion: string): Promise { + return { + buildHash: computeOrcadBuildHash(), + buildVersion, + nodeVersion: process.versions.node, + nodeAbi: process.versions.modules ?? 'unknown', + platform: process.platform, + arch: process.arch, + pid: process.pid, + terminalDaemon: await collectTerminalDaemonHealth() + } +} diff --git a/src/main/orcad/orcad-instance-lock.test.ts b/src/main/orcad/orcad-instance-lock.test.ts new file mode 100644 index 00000000000..45f7543c515 --- /dev/null +++ b/src/main/orcad/orcad-instance-lock.test.ts @@ -0,0 +1,151 @@ +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + acquireOrcadInstanceLock, + ORCAD_LOCK_FILE_NAME, + OrcadInstanceLockError, + type OrcadInstanceLockHooks +} from './orcad-instance-lock' + +const roots: string[] = [] + +function makeRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'orcad-lock-')) + roots.push(root) + return root +} + +/** Deterministic identity/liveness so the assertions do not depend on this machine's pids. */ +function hooks(overrides: OrcadInstanceLockHooks = {}): OrcadInstanceLockHooks { + return { + identity: () => 'uid-1000', + version: () => '1.0.0-test', + startedAtMs: () => 1_000, + startTimeMatches: () => true, + processIsAlive: () => false, + ...overrides + } +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('acquireOrcadInstanceLock', () => { + it('publishes a record naming the holder and removes it on release', () => { + const root = makeRoot() + const lock = acquireOrcadInstanceLock(root, hooks()) + const record = JSON.parse(readFileSync(join(root, ORCAD_LOCK_FILE_NAME), 'utf8')) + expect(record.pid).toBe(process.pid) + expect(record.identity).toBe('uid-1000') + expect(record.version).toBe('1.0.0-test') + lock.release() + expect(() => readFileSync(join(root, ORCAD_LOCK_FILE_NAME), 'utf8')).toThrow() + }) + + it('refuses a second instance while the holder is alive', () => { + const root = makeRoot() + acquireOrcadInstanceLock(root, hooks()) + expect(() => acquireOrcadInstanceLock(root, hooks({ processIsAlive: () => true }))).toThrow( + OrcadInstanceLockError + ) + expect(() => acquireOrcadInstanceLock(root, hooks({ processIsAlive: () => true }))).toThrow( + expect.objectContaining({ code: 'orcad_instance_lock_held' }) + ) + }) + + it('reclaims the record of a holder that is gone', () => { + const root = makeRoot() + writeFileSync( + join(root, ORCAD_LOCK_FILE_NAME), + JSON.stringify({ pid: 424242, identity: 'uid-1000', startedAtMs: 1, nonce: 'stale' }) + ) + const lock = acquireOrcadInstanceLock(root, hooks({ processIsAlive: () => false })) + expect(JSON.parse(readFileSync(lock.path, 'utf8')).pid).toBe(process.pid) + }) + + it('treats a live pid whose start time does not match as a recycled pid, not a holder', () => { + const root = makeRoot() + writeFileSync( + join(root, ORCAD_LOCK_FILE_NAME), + JSON.stringify({ pid: 424242, identity: 'uid-1000', startedAtMs: 1, nonce: 'stale' }) + ) + const lock = acquireOrcadInstanceLock( + root, + hooks({ processIsAlive: () => true, startTimeMatches: () => false }) + ) + expect(JSON.parse(readFileSync(lock.path, 'utf8')).pid).toBe(process.pid) + }) + + it('never reclaims a lock held by a different identity, even a dead one', () => { + const root = makeRoot() + writeFileSync( + join(root, ORCAD_LOCK_FILE_NAME), + JSON.stringify({ pid: 424242, identity: 'uid-2000', startedAtMs: 1, nonce: 'other' }) + ) + expect(() => acquireOrcadInstanceLock(root, hooks({ processIsAlive: () => false }))).toThrow( + expect.objectContaining({ code: 'orcad_instance_lock_foreign_identity' }) + ) + }) + + it('does not delete a record that a later instance already replaced', () => { + const root = makeRoot() + const lock = acquireOrcadInstanceLock(root, hooks()) + // A successor reclaimed the root while this process was wedged. + writeFileSync( + lock.path, + JSON.stringify({ pid: 777, identity: 'uid-1000', startedAtMs: 2, nonce: 'successor' }) + ) + lock.release() + expect(JSON.parse(readFileSync(lock.path, 'utf8')).nonce).toBe('successor') + }) + + it.runIf(process.platform !== 'win32')( + 'tightens a group/world-accessible data root rather than refusing when it can', + () => { + const root = makeRoot() + chmodSync(root, 0o755) + acquireOrcadInstanceLock(root, hooks()) + expect(statSync(root).mode & 0o777).toBe(0o700) + } + ) + + it.runIf(process.platform !== 'win32')('refuses a data root owned by another uid', () => { + const root = makeRoot() + // /tmp itself is root-owned and sticky on every supported platform, so it stands in for + // "a data root this process does not own" without needing privileges to create one. + expect(() => acquireOrcadInstanceLock('/tmp', hooks())).toThrow( + expect.objectContaining({ code: 'orcad_data_root_wrong_owner' }) + ) + // And the private root this test made is still acceptable, so the refusal is about + // ownership rather than a blanket rejection. + expect(acquireOrcadInstanceLock(root, hooks()).record.identity).toBe('uid-1000') + }) + + it('leaves the terminal daemon alone: the lock covers only the runtime role', () => { + const root = makeRoot() + // The daemon lives here and deliberately outlives the runtime. Releasing the runtime's + // lock must not touch it, or a restart would stop being non-destructive. + const daemonDir = join(root, 'daemon') + mkdirSync(daemonDir, { recursive: true }) + writeFileSync(join(daemonDir, 'daemon-v36.pid'), JSON.stringify({ pid: 99, startedAtMs: 1 })) + const lock = acquireOrcadInstanceLock(root, hooks()) + lock.release() + expect(JSON.parse(readFileSync(join(daemonDir, 'daemon-v36.pid'), 'utf8')).pid).toBe(99) + // And a fresh instance takes the root back while that daemon record still stands. + const next = acquireOrcadInstanceLock(root, hooks()) + expect(next.record.pid).toBe(process.pid) + }) +}) diff --git a/src/main/orcad/orcad-instance-lock.ts b/src/main/orcad/orcad-instance-lock.ts new file mode 100644 index 00000000000..052140bcd2e --- /dev/null +++ b/src/main/orcad/orcad-instance-lock.ts @@ -0,0 +1,324 @@ +/** + * Single-instance ownership of orcad's data root, taken BEFORE the profile is loaded. + * + * Two orcads on one data root corrupt it quietly: both load the same profile, both write + * the same store file, and the loser's writes disappear on the next flush. The refusal is + * therefore a startup gate, not a warning. + * + * What this lock does NOT cover, and must not: the terminal daemon. The daemon is a second + * long-lived process living under `/daemon`, it deliberately outlives the orcad that + * spawned it, and it fences its own endpoint with a PID record of its own. A lock that + * asked "is any process using this root" would refuse every restart that a live daemon + * makes worthwhile. This lock scopes exactly one role — who is the runtime — so releasing + * it says nothing about the daemon, which is what makes a non-destructive restart possible. + */ +import { randomUUID } from 'node:crypto' +import { + chmodSync, + mkdirSync, + readFileSync, + renameSync, + statSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { userInfo } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' +import { getProcessStartedAtMs, startTimeMatches } from '../daemon/daemon-process-start-time' + +export const ORCAD_LOCK_FILE_NAME = 'orcad.lock' + +export type OrcadInstanceLockCode = + | 'orcad_data_root_unusable' + | 'orcad_data_root_wrong_owner' + | 'orcad_data_root_shared' + | 'orcad_instance_lock_held' + | 'orcad_instance_lock_foreign_identity' + +export class OrcadInstanceLockError extends Error { + constructor( + readonly code: OrcadInstanceLockCode, + message: string + ) { + super(message) + this.name = 'OrcadInstanceLockError' + } +} + +export type OrcadLockRecord = { + pid: number + /** Null where the platform cannot read it; PID alone is then the (weaker) fence. */ + startedAtMs: number | null + /** POSIX uid, or the Windows username. Compared as an opaque string. */ + identity: string + version: string + acquiredAt: string + /** Distinguishes our record from a replacement written after we lost the race. */ + nonce: string +} + +export type OrcadInstanceLock = { + readonly path: string + readonly record: OrcadLockRecord + release(): void +} + +export type OrcadInstanceLockHooks = { + identity?: () => string + version?: () => string + now?: () => Date + /** Whether a PID is running. EPERM counts as alive: it proves the process exists. */ + processIsAlive?: (pid: number) => boolean + startedAtMs?: (pid: number) => number | null + startTimeMatches?: (pid: number, expected: number | null) => boolean +} + +function defaultIdentity(): string { + // Why uid and not the name on POSIX: two accounts can share a login name across a + // container boundary while the uid is what the filesystem actually enforces. + return process.platform === 'win32' + ? (userInfo().username ?? 'unknown') + : String(process.getuid?.() ?? 'unknown') +} + +function defaultProcessIsAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return isErrorCode(error, 'EPERM') + } +} + +function isErrorCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code +} + +function parseLockRecord(content: string): OrcadLockRecord | null { + try { + const parsed: unknown = JSON.parse(content) + if (!parsed || typeof parsed !== 'object') { + return null + } + const record = parsed as Partial + if (typeof record.pid !== 'number' || typeof record.identity !== 'string') { + return null + } + return { + pid: record.pid, + startedAtMs: typeof record.startedAtMs === 'number' ? record.startedAtMs : null, + identity: record.identity, + version: typeof record.version === 'string' ? record.version : 'unknown', + acquiredAt: typeof record.acquiredAt === 'string' ? record.acquiredAt : '', + nonce: typeof record.nonce === 'string' ? record.nonce : '' + } + } catch { + return null + } +} + +/** + * Fail closed on a data root other identities can read or write. + * + * Why self-heal first and refuse second: orcad stores credentials unsealed (there is no OS + * keyring on this host), so a group- or world-accessible root is a real exposure — but if + * we own the directory, tightening it is strictly better than refusing to start. We refuse + * only when the permissions are not ours to fix. + */ +function assertDataRootIsPrivate(dataRoot: string): void { + // Windows ACLs are not expressible as a POSIX mode, and `statSync().mode` there reports a + // synthesized one. Checking it would refuse correct deployments and pass wrong ones. + if (process.platform === 'win32') { + return + } + let stats + try { + stats = statSync(dataRoot) + } catch (error) { + throw new OrcadInstanceLockError( + 'orcad_data_root_unusable', + `Cannot stat the orcad data root ${dataRoot}: ${(error as Error).message}` + ) + } + const uid = process.getuid?.() + if (uid !== undefined && stats.uid !== uid) { + throw new OrcadInstanceLockError( + 'orcad_data_root_wrong_owner', + `The orcad data root ${dataRoot} is owned by uid ${stats.uid}, not by uid ${uid} running ` + + 'this process. Give orcad its own data root (ORCA_USER_DATA) or chown this one.' + ) + } + if ((stats.mode & 0o077) === 0) { + return + } + try { + chmodSync(dataRoot, 0o700) + } catch { + // Fall through to the re-stat, which produces the actionable message. + } + let mode: number + try { + mode = statSync(dataRoot).mode + } catch (error) { + throw new OrcadInstanceLockError( + 'orcad_data_root_unusable', + `Cannot stat the orcad data root ${dataRoot}: ${(error as Error).message}` + ) + } + if ((mode & 0o077) !== 0) { + throw new OrcadInstanceLockError( + 'orcad_data_root_shared', + `The orcad data root ${dataRoot} is accessible to other users (mode ` + + `${(mode & 0o777).toString(8)}) and could not be tightened. orcad stores credentials ` + + 'there unsealed, so it refuses to start. Run `chmod 700` on it, or point ORCA_USER_DATA ' + + 'at a private directory.' + ) + } +} + +/** + * Take the lock, or throw an `OrcadInstanceLockError` naming why. + * + * A dead holder's record is reclaimed; a live one, or one belonging to a different identity, + * is never touched. + */ +export function acquireOrcadInstanceLock( + dataRoot: string, + hooks: OrcadInstanceLockHooks = {} +): OrcadInstanceLock { + const identity = (hooks.identity ?? defaultIdentity)() + const isAlive = hooks.processIsAlive ?? defaultProcessIsAlive + const readStartedAt = hooks.startedAtMs ?? getProcessStartedAtMs + const matchesStartTime = hooks.startTimeMatches ?? startTimeMatches + + try { + mkdirSync(dataRoot, { recursive: true, mode: 0o700 }) + } catch (error) { + throw new OrcadInstanceLockError( + 'orcad_data_root_unusable', + `Cannot create the orcad data root ${dataRoot}: ${(error as Error).message}` + ) + } + assertDataRootIsPrivate(dataRoot) + + const lockPath = join(dataRoot, ORCAD_LOCK_FILE_NAME) + const record: OrcadLockRecord = { + pid: process.pid, + startedAtMs: readStartedAt(process.pid), + identity, + version: (hooks.version ?? (() => process.env.ORCA_VERSION ?? 'unknown'))(), + acquiredAt: (hooks.now ?? (() => new Date()))().toISOString(), + nonce: randomUUID() + } + const serialized = JSON.stringify(record) + + const publish = (): boolean => { + try { + writeFileSync(lockPath, serialized, { flag: 'wx', mode: 0o600 }) + return true + } catch (error) { + if (isErrorCode(error, 'EEXIST')) { + return false + } + throw new OrcadInstanceLockError( + 'orcad_data_root_unusable', + `Cannot write the orcad instance lock ${lockPath}: ${(error as Error).message}` + ) + } + } + + if (publish()) { + return makeLock(lockPath, record) + } + + const existing = parseLockRecord(safeRead(lockPath) ?? '') + if (existing && existing.identity !== identity) { + throw new OrcadInstanceLockError( + 'orcad_instance_lock_foreign_identity', + `The orcad data root ${dataRoot} is locked by identity ${existing.identity} (pid ` + + `${existing.pid}); this process runs as ${identity}. Two identities sharing one data ` + + 'root corrupts it. Give each its own ORCA_USER_DATA.' + ) + } + if (existing && isAlive(existing.pid) && matchesStartTime(existing.pid, existing.startedAtMs)) { + throw new OrcadInstanceLockError( + 'orcad_instance_lock_held', + `Another orcad (pid ${existing.pid}, started ${existing.acquiredAt || 'unknown'}) already ` + + `owns the data root ${dataRoot}. Stop it before starting another, or use a different ` + + 'ORCA_USER_DATA.' + ) + } + if (!existing) { + console.warn( + `[orcad] The instance lock at ${lockPath} is unreadable; reclaiming it. If another orcad ` + + 'is running on this data root, stop it now.' + ) + } + + // Why rename-and-then-publish rather than unlink-and-write: rename claims one exact + // directory entry, so a replacement written between our read and our write stays at the + // canonical path and wins — we never delete a record we did not inspect. + const claimPath = `${lockPath}.stale-${process.pid}-${randomUUID()}` + try { + renameSync(lockPath, claimPath) + } catch { + throw new OrcadInstanceLockError( + 'orcad_instance_lock_held', + `Could not reclaim the stale orcad instance lock at ${lockPath}; another process is ` + + 'holding it. Retry, or stop the other orcad.' + ) + } + if (!publish()) { + // Someone else claimed it first. Their record is authoritative; ours is not. + try { + unlinkSync(claimPath) + } catch { + // A uniquely named claim is inert. + } + throw new OrcadInstanceLockError( + 'orcad_instance_lock_held', + `Another orcad took the data root ${dataRoot} while this one was reclaiming a stale lock.` + ) + } + try { + unlinkSync(claimPath) + } catch { + // The canonical record is authoritative; the claim is inert. + } + return makeLock(lockPath, record) +} + +function safeRead(path: string): string | null { + try { + return readFileSync(path, 'utf8') + } catch { + return null + } +} + +function makeLock(lockPath: string, record: OrcadLockRecord): OrcadInstanceLock { + let released = false + return { + path: lockPath, + record, + release: () => { + if (released) { + return + } + released = true + // Why re-read before unlinking: a reclaim by a later orcad (after, say, a SIGKILL that + // this process somehow survived enough to run handlers) leaves a record that is not + // ours. Deleting it would unlock a live runtime. + const current = parseLockRecord(safeRead(lockPath) ?? '') + if (!current || current.nonce !== record.nonce) { + return + } + try { + unlinkSync(lockPath) + } catch { + // Best-effort: a leftover record with a dead pid is reclaimed on the next start. + } + } + } +} diff --git a/src/main/orcad/orcad-launch-contract.test.ts b/src/main/orcad/orcad-launch-contract.test.ts new file mode 100644 index 00000000000..b22dc74f0e4 --- /dev/null +++ b/src/main/orcad/orcad-launch-contract.test.ts @@ -0,0 +1,43 @@ +/** + * The two things a supervisor reads off a launch: what the arguments mean, and what an exit + * code means. Both are part of the ops contract in docs/reference/orcad-operations.md. + */ +import { describe, expect, it } from 'vitest' +import { + ORCAD_EXIT_CONFIGURATION, + ORCAD_EXIT_FAILED, + parseArgs, + resolveOrcadExitCode +} from './orcad-entry' +import { OrcadBindAddressError } from './orcad-bind-address' +import { OrcadInstanceLockError } from './orcad-instance-lock' + +describe('parseArgs', () => { + it('accepts --bind and leaves it unset when absent', () => { + expect(parseArgs(['--bind', '0.0.0.0'])).toEqual({ bind: '0.0.0.0' }) + expect(parseArgs([])).toEqual({}) + expect(parseArgs(['--port', '6768', '--bind', '10.0.0.5', '--json'])).toEqual({ + port: 6768, + bind: '10.0.0.5', + json: true + }) + }) + + it('rejects --bind with no value rather than silently binding the default', () => { + expect(() => parseArgs(['--bind'])).toThrow('--bind expects a value') + expect(() => parseArgs(['--bind', '--json'])).not.toThrow() + }) +}) + +describe('resolveOrcadExitCode', () => { + it('separates a configuration fault from a generic failure', () => { + // A supervisor must be able to stop restarting on faults that restarting cannot fix: + // a data root owned by someone else, held by another instance, or a bad bind address. + expect( + resolveOrcadExitCode(new OrcadInstanceLockError('orcad_instance_lock_held', 'held')) + ).toBe(ORCAD_EXIT_CONFIGURATION) + expect(resolveOrcadExitCode(new OrcadBindAddressError('bad'))).toBe(ORCAD_EXIT_CONFIGURATION) + expect(resolveOrcadExitCode(new Error('port in use'))).toBe(ORCAD_EXIT_FAILED) + expect(ORCAD_EXIT_CONFIGURATION).not.toBe(ORCAD_EXIT_FAILED) + }) +}) diff --git a/src/main/orcad/orcad-native-preflight.test.ts b/src/main/orcad/orcad-native-preflight.test.ts new file mode 100644 index 00000000000..6c697a3ecce --- /dev/null +++ b/src/main/orcad/orcad-native-preflight.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + ORCAD_NATIVE_PRECONDITION_EXIT_CODE, + runOrcadNativePreflight +} from './orcad-native-preflight' +import { + runtimeTerminalDegradation, + setRuntimeTerminalUnavailableCause +} from '../runtime/native-terminal-availability' +import type { NodePtyPreconditionVerdict } from './node-pty-precondition' + +const ABI = { + platform: 'linux' as NodeJS.Platform, + arch: 'x64', + libc: 'glibc' as const, + glibcVersion: '2.31', + nodeAbi: '127' +} + +const verdict = (over: Partial): NodePtyPreconditionVerdict => ({ + status: 'ok', + slot: 'linux-x64-glibc', + abi: ABI, + ...over +}) + +const harness = (given: NodePtyPreconditionVerdict) => { + const warn = vi.fn() + const fail = vi.fn() + const exit = vi.fn(() => undefined as never) + const continued = runOrcadNativePreflight({ + check: () => given, + toolchainHints: () => [' sudo apt-get install -y build-essential python3'], + warn, + fail, + exit + }) + return { warn, fail, exit, continued } +} + +afterEach(() => { + setRuntimeTerminalUnavailableCause(null) +}) + +describe('runOrcadNativePreflight', () => { + it('stops the boot on a proven-unloadable binary instead of reaching the require', () => { + // Continuing here would hit the very dlopen the probe just proved fatal, and the + // operator would get the loader's stack trace instead of the sentence below. + const { fail, exit, warn } = harness( + verdict({ status: 'blocked', reason: 'libc_floor', detail: 'the binary requires GLIBC_2.34' }) + ) + + expect(exit).toHaveBeenCalledWith(ORCAD_NATIVE_PRECONDITION_EXIT_CODE) + expect(warn).not.toHaveBeenCalled() + const message = fail.mock.calls[0][0] as string + expect(message).toContain('newer C library') + expect(message).toContain('the binary requires GLIBC_2.34') + expect(message).toContain('libc glibc 2.31') + // The toolchain hint is what makes it actionable rather than merely accurate. + expect(message).toContain('sudo apt-get install -y build-essential python3') + }) + + it('exits with EX_CONFIG so a supervisor does not restart an unequippable host forever', () => { + expect(ORCAD_NATIVE_PRECONDITION_EXIT_CODE).toBe(78) + expect(ORCAD_NATIVE_PRECONDITION_EXIT_CODE).not.toBe(1) + }) + + it('publishes the blocked cause as a status degradation', () => { + harness(verdict({ status: 'blocked', reason: 'abi_mismatch', detail: 'built for ABI 115' })) + + expect(runtimeTerminalDegradation()).toEqual({ + code: 'terminal_unavailable', + capability: 'terminal.pty.v1', + reason: 'abi_mismatch', + detail: 'built for ABI 115', + message: + "This host's node-pty binary was built for a different Node ABI than the running Node, so it cannot be loaded. Rebuild node-pty against this Node version. (built for ABI 115)" + }) + }) + + it('boots on a spawn-time-only fault and reports it rather than refusing to serve', () => { + const { continued, exit, warn } = harness( + verdict({ status: 'degraded', reason: 'spawn_helper_missing', detail: 'no spawn-helper' }) + ) + + expect(continued).toBe(true) + expect(exit).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledOnce() + expect(runtimeTerminalDegradation()?.reason).toBe('spawn_helper_missing') + }) + + it('boots when the probe established nothing, because that is not evidence of a fault', () => { + const { continued, exit } = harness( + verdict({ status: 'unverifiable', reason: 'unknown', detail: 'probe timed out' }) + ) + + expect(continued).toBe(true) + expect(exit).not.toHaveBeenCalled() + // Still reported: an unverifiable host must not look identical to a proven-healthy one. + expect(runtimeTerminalDegradation()?.reason).toBe('unknown') + }) + + it('reports nothing when the load was proved good', () => { + setRuntimeTerminalUnavailableCause({ reason: 'load_failed' }) + + const { continued, warn, fail } = harness(verdict({ status: 'ok' })) + + expect(continued).toBe(true) + expect(warn).not.toHaveBeenCalled() + expect(fail).not.toHaveBeenCalled() + expect(runtimeTerminalDegradation()).toBeNull() + }) + + it('does not run the toolchain probe for a verdict that is not about a missing build', () => { + const toolchainHints = vi.fn(() => [] as string[]) + runOrcadNativePreflight({ + check: () => verdict({ status: 'degraded', reason: 'spawn_helper_missing' }), + toolchainHints, + warn: vi.fn(), + fail: vi.fn(), + exit: vi.fn(() => undefined as never) + }) + expect(toolchainHints).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/orcad/orcad-native-preflight.ts b/src/main/orcad/orcad-native-preflight.ts new file mode 100644 index 00000000000..0f3c2cf8970 --- /dev/null +++ b/src/main/orcad/orcad-native-preflight.ts @@ -0,0 +1,72 @@ +/** + * The native precondition orcad runs before its runtime loads anything native. + * + * Split from `node-pty-precondition.ts` so the decision (what to do about a verdict) is + * testable apart from the detection (what the verdict is). + */ +import process from 'node:process' +import { setRuntimeTerminalUnavailableCause } from '../runtime/native-terminal-availability' +import { terminalUnavailableMessage } from '../../shared/runtime-types' +import { + checkNodePtyPrecondition, + formatNodePtyPreconditionReport, + probeLocalBuildToolchainHints, + type NodePtyPreconditionVerdict +} from './node-pty-precondition' + +/** + * EX_CONFIG. Why not 1: a supervisor that restarts on 1 would restart forever against a + * host that can never load this binary. This code says "the host is not equipped", which + * is a different instruction from "it crashed". + */ +export const ORCAD_NATIVE_PRECONDITION_EXIT_CODE = 78 + +export type NativePreflightHooks = { + check?: () => NodePtyPreconditionVerdict + toolchainHints?: (platform: NodeJS.Platform) => string[] + warn?: (message: string) => void + fail?: (message: string) => void + exit?: (code: number) => never +} + +/** + * Returns true when boot may continue. + * + * A `blocked` verdict never returns: continuing would reach the very `require` the probe + * just proved fatal, and the operator would get the loader's stack trace instead of the + * sentence printed here. + */ +export function runOrcadNativePreflight(hooks: NativePreflightHooks = {}): boolean { + const check = hooks.check ?? checkNodePtyPrecondition + const warn = hooks.warn ?? ((message: string) => console.warn(message)) + const fail = hooks.fail ?? ((message: string) => console.error(message)) + const exit = hooks.exit ?? ((code: number) => process.exit(code) as never) + const verdict = check() + + if (verdict.status === 'ok') { + // Why clear rather than leave alone: a previous run in this process may have recorded + // a cause, and status.get must not keep reporting a degradation that no longer holds. + setRuntimeTerminalUnavailableCause(null) + return true + } + + const reason = verdict.reason ?? 'unknown' + setRuntimeTerminalUnavailableCause({ + reason, + ...(verdict.detail ? { detail: verdict.detail } : {}) + }) + const message = terminalUnavailableMessage(reason, verdict.detail) + + if (verdict.status === 'blocked') { + const hints = (hooks.toolchainHints ?? probeLocalBuildToolchainHints)(verdict.abi.platform) + fail(`orcad: ${formatNodePtyPreconditionReport(verdict, message, hints)}`) + exit(ORCAD_NATIVE_PRECONDITION_EXIT_CODE) + return false + } + + // `degraded` and `unverifiable` both boot. The first is a proven spawn-time fault the + // host can still serve around; the second established nothing, and refusing to boot on + // an inconclusive probe would take down hosts that work. + warn(`orcad: ${formatNodePtyPreconditionReport(verdict, message)}`) + return true +} diff --git a/src/main/ports/port-scan-command-client.test.ts b/src/main/ports/port-scan-command-client.test.ts index d1a15d29774..c2bece624f8 100644 --- a/src/main/ports/port-scan-command-client.test.ts +++ b/src/main/ports/port-scan-command-client.test.ts @@ -298,3 +298,29 @@ describe('PortScanCommandClient on a real worker thread', () => { } }, 30_000) }) + +describe('resolveWorkerEntryPath on a non-Electron host', () => { + // Why: orcad reports isPackaged true (it is a production build), but + // process.resourcesPath is Electron-only and undefined there. Joining undefined threw + // a TypeError instead of failing as a missing worker — a crash where a clean + // "worker unavailable" was the honest outcome. + it('does not join an undefined resourcesPath', () => { + expect(() => + resolveWorkerEntryPath({ + isPackaged: true, + resourcesPath: undefined, + moduleDir: '/opt/orcad' + }) + ).not.toThrow() + }) + + it('falls back to the module directory when there is no resources tree', () => { + expect( + resolveWorkerEntryPath({ + isPackaged: true, + resourcesPath: undefined, + moduleDir: '/opt/orcad' + }) + ).toBe(join('/opt/orcad', 'port-scan-command-worker-entry.js')) + }) +}) diff --git a/src/main/ports/port-scan-command-client.ts b/src/main/ports/port-scan-command-client.ts index c2bc80bc883..9c01b4dccda 100644 --- a/src/main/ports/port-scan-command-client.ts +++ b/src/main/ports/port-scan-command-client.ts @@ -303,7 +303,8 @@ const WORKER_ENTRY_FILENAME = 'port-scan-command-worker-entry.js' /** Where the built worker entry can live: packaged resources or the build dir. */ export type WorkerEntryLayout = { isPackaged: boolean - resourcesPath: string + /** Undefined on a non-Electron host: `process.resourcesPath` is Electron-only. */ + resourcesPath: string | undefined moduleDir: string } @@ -318,7 +319,12 @@ export function resolveWorkerEntryPath(layout: WorkerEntryLayout): string { // the bundler's __dirname, matching the shipped stt/warp/opencode workers. // Split out from the electron read so the packaged branch is testable without // a packaged build. - if (layout.isPackaged) { + // Why the resourcesPath guard: `isPackaged` is true on orcad too, but + // `process.resourcesPath` is Electron-only and undefined under plain Node — joining + // it threw a TypeError rather than failing as a missing worker. A host without an + // Electron resources tree has no asar to look in, so fall back to the module dir and + // let the caller report a missing worker honestly. + if (layout.isPackaged && layout.resourcesPath) { return join(layout.resourcesPath, 'app.asar', 'out', 'main', WORKER_ENTRY_FILENAME) } return join(layout.moduleDir, WORKER_ENTRY_FILENAME) diff --git a/src/main/runtime/native-terminal-availability.ts b/src/main/runtime/native-terminal-availability.ts new file mode 100644 index 00000000000..85eb3df57f0 --- /dev/null +++ b/src/main/runtime/native-terminal-availability.ts @@ -0,0 +1,51 @@ +import { + type RuntimeTerminalUnavailableReason, + terminalUnavailableMessage, + TERMINAL_PTY_DEGRADATION_CAPABILITY, + TERMINAL_UNAVAILABLE_ERROR_CODE, + type RuntimeDegradation +} from '../../shared/runtime-types' + +/** + * A note left by whoever proved this host cannot load or spawn PTYs, read back when + * `status.get` assembles `degradations[]`. + * + * Why a module-level note rather than a constructor argument: only the host entry point + * can run the out-of-process load probe, and it must run before anything requires + * `node-pty` — long before `OrcaRuntimeService` exists. This is the same shape + * `runtime-browser-commands-factory` uses for `browser_unavailable`, for the same reason. + * + * Silence means "nothing proved it broken", never "proved working". Hosts that never + * run a precondition therefore report no degradation, which is the honest answer. + */ +export type RuntimeTerminalUnavailableCause = { + reason: RuntimeTerminalUnavailableReason + detail?: string +} + +let unavailableCause: RuntimeTerminalUnavailableCause | null = null + +export function setRuntimeTerminalUnavailableCause( + cause: RuntimeTerminalUnavailableCause | null +): void { + unavailableCause = cause +} + +export function runtimeTerminalUnavailableCause(): RuntimeTerminalUnavailableCause | null { + return unavailableCause +} + +/** The degradation entry for the recorded cause, or null when nothing is degraded. */ +export function runtimeTerminalDegradation(): RuntimeDegradation | null { + const cause = unavailableCause + if (!cause) { + return null + } + return { + code: TERMINAL_UNAVAILABLE_ERROR_CODE, + capability: TERMINAL_PTY_DEGRADATION_CAPABILITY, + message: terminalUnavailableMessage(cause.reason, cause.detail), + reason: cause.reason, + ...(cause.detail ? { detail: cause.detail } : {}) + } +} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 60926fc873c..40c9eafd621 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -5,6 +5,7 @@ import { setRuntimeBrowserCommandsFactory, setRuntimeBrowserUnavailableCause } from './runtime-browser-commands-factory' +import { setRuntimeTerminalUnavailableCause } from './native-terminal-availability' import { setRuntimeDesktopSurface } from './runtime-desktop-surface' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' import type * as GitUsernameModule from '../git/git-username' @@ -697,6 +698,7 @@ function resetRuntimeTestMocks(): void { // browser RPCs reject rather than silently succeeding. setRuntimeBrowserCommandsFactory((host) => new RuntimeBrowserCommands(host)) setRuntimeBrowserUnavailableCause(null) + setRuntimeTerminalUnavailableCause(null) // Why: the runtime's notification, window lookup and tab-create-reply channel are // injected now, so the electron mock alone is inert. Back the surface with the same // mocks so every existing expectation still holds. @@ -2769,6 +2771,43 @@ describe('OrcaRuntimeService', () => { ) }) + it('reports a host that cannot load node-pty, instead of that host never answering', () => { + // The alternative to reporting it is the process dying inside the dynamic loader, + // which reaches a client as a dropped connection with no cause attached. + setRuntimeTerminalUnavailableCause({ + reason: 'libc_floor', + detail: 'the binary requires GLIBC_2.34' + }) + + const degradations = createRuntime().getStatus().degradations ?? [] + + expect(degradations).toContainEqual({ + code: 'terminal_unavailable', + capability: 'terminal.pty.v1', + reason: 'libc_floor', + detail: 'the binary requires GLIBC_2.34', + message: + "This host's node-pty binary was built against a newer C library than the host provides, so the dynamic loader refuses it. Rebuild node-pty on this host, or deploy a build whose prebuilt binary matches this platform's libc. (the binary requires GLIBC_2.34)" + }) + }) + + it('reports browser and terminal loss together, because they fail independently', () => { + setRuntimeBrowserCommandsFactory(null) + setRuntimeBrowserUnavailableCause({ reason: 'unconfigured' }) + setRuntimeTerminalUnavailableCause({ reason: 'dependency_missing' }) + + const codes = (createRuntime().getStatus().degradations ?? []).map((entry) => entry.code) + + expect(codes).toEqual(['browser_unavailable', 'terminal_unavailable']) + }) + + it('says nothing about terminals when no precondition proved them broken', () => { + // Silence must mean "nothing proved it broken", never "proved working" — a host that + // never ran the precondition has no verdict to publish. + const degradations = createRuntime().getStatus().degradations ?? [] + expect(degradations.map((entry) => entry.code)).not.toContain('terminal_unavailable') + }) + it('closes a worktree’s offscreen browser pages when its metadata is removed (leak fix)', () => { const runtime = createRuntime() const closeTab = vi.fn().mockResolvedValue(undefined) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index ee1f977ca88..0b25b3cef46 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -448,6 +448,7 @@ import type { LinearTeamStatesResult, LinearStatusSetResult } from '../../shared/linear/agent-access' +import { runtimeTerminalDegradation } from './native-terminal-availability' import { BROWSER_UNAVAILABLE_ERROR_CODE, browserUnavailableMessage, @@ -6482,6 +6483,12 @@ export class OrcaRuntimeService { } ] : [] + // Why appended rather than merged into the ternary: PTY loss and browser loss are + // independent, and a host can be degraded on both at once. + const terminalDegradation = runtimeTerminalDegradation() + if (terminalDegradation) { + degradations.push(terminalDegradation) + } return { runtimeId: this.runtimeId, rendererGraphEpoch: this.rendererGraphEpoch, diff --git a/src/main/runtime/runtime-rpc-websocket-bind-host.test.ts b/src/main/runtime/runtime-rpc-websocket-bind-host.test.ts index a74c48b896b..8210b0f06d4 100644 --- a/src/main/runtime/runtime-rpc-websocket-bind-host.test.ts +++ b/src/main/runtime/runtime-rpc-websocket-bind-host.test.ts @@ -518,6 +518,109 @@ describe('OrcaRuntimeRpcServer WebSocket bind host (STA-2370)', () => { } }) + it('honours a pinned bind host over exposeNetworkByDefault (orcad --bind)', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0, + // Why both: an unattended host passes an explicit answer, and it must outrank every + // implicit widen — otherwise "default loopback" is only true until something else wins. + exposeNetworkByDefault: true, + pinnedBindHost: '127.0.0.1' + }) + + await server.start() + try { + expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1') + expect(new URL(server.getWebSocketEndpoint()!).hostname).toBe('127.0.0.1') + } finally { + await server.stop() + } + }) + + it('stays pinned to loopback even after a device has connected once', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + // Why this case specifically: the unpinned default widens at the NEXT startup once any + // network-reach device has connected. A loopback orcad would therefore go wide one + // restart after its first client paired — silently, and without the operator asking. + const registry = new DeviceRegistry(userDataPath) + const device = registry.getOrCreatePendingDevice('CLI', 'runtime', 'network') + registry.updateLastSeen(device.deviceId) + + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0, + pinnedBindHost: '127.0.0.1' + }) + + await server.start() + try { + expect( + server + .getDeviceRegistry() + ?.listDevices() + .some((d) => d.lastSeenAt > 0) + ).toBe(true) + expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1') + } finally { + await server.stop() + } + }) + + it('refuses a runtime-widening pairing offer while the bind is pinned to loopback', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0, + pinnedBindHost: '127.0.0.1' + }) + + await server.start() + try { + // A paired client can reach this RPC. Without the pin's refusal it would rebind the + // listener to every interface, undoing the operator's bind policy from the outside. + const offer = await server.createMobilePairingOffer({ + address: '100.64.1.20', + connectionMode: 'local-only' + }) + expect(offer.available).toBe(false) + if (!offer.available) { + expect(offer.reason).toBe('network_exposure_failed') + } + expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1') + } finally { + await server.stop() + errorSpy.mockRestore() + } + }) + + it('still widens on request when the operator pinned the wide address', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0, + pinnedBindHost: '0.0.0.0' + }) + + await server.start() + try { + expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0') + await server.ensureNetworkExposure() + expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0') + } finally { + await server.stop() + } + }) + it('refuses to widen a pairing offer that arrives after the server has stopped', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const server = new OrcaRuntimeRpcServer({ diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 1b5899f346c..b28619b2eed 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -59,6 +59,12 @@ const DEFAULT_WS_PORT = 6768 const WS_BIND_HOST_LOOPBACK = '127.0.0.1' const WS_BIND_HOST_ALL_INTERFACES = '0.0.0.0' +// Why brackets: `ws://::1:6768` is not a URL, and every consumer of this endpoint parses it +// with `new URL`. An IPv6 bind address would otherwise publish an unparseable endpoint. +function formatWsEndpoint(host: string, port: number): string { + return `ws://${host.includes(':') ? `[${host}]` : host}:${port}` +} + type OrcaRuntimeRpcServerOptions = { runtime: OrcaRuntimeService userDataPath: string @@ -71,6 +77,16 @@ type OrcaRuntimeRpcServerOptions = { // Why: STA-2370 — bind the WS listener to all interfaces at startup instead of loopback-until-paired. // Only `orca serve` (explicit remote opt-in) and E2E set this; the desktop app widens lazily on pairing. exposeNetworkByDefault?: boolean + /** + * Pin the WS listener to exactly this address for the process's whole life. + * + * Why a pin and not another default: the two paths below both widen on their own — + * `exposeNetworkByDefault` at startup, and a device that has connected once at every + * later startup. An unattended host (orcad) whose operator asked for loopback must + * still be on loopback after a client pairs and the service restarts, so the answer + * has to outrank both, and `ensureNetworkExposure()` has to refuse rather than widen. + */ + pinnedBindHost?: string webClientRoot?: string // Why: test-only overrides for the two constants below; production must not pass these (defaults set by §3.1). keepaliveIntervalMs?: number @@ -495,6 +511,7 @@ export class OrcaRuntimeRpcServer { private readonly wsPort: number private readonly preferPinnedWsPort: boolean private readonly exposeNetworkByDefault: boolean + private readonly pinnedBindHost: string | null private readonly webClientRoot: string | undefined // Why: STA-2370 — the host the WS listener is currently bound to, so pairing can widen loopback→all-interfaces once. private wsBoundHost: string | null = null @@ -555,6 +572,7 @@ export class OrcaRuntimeRpcServer { wsPort = DEFAULT_WS_PORT, preferPinnedWsPort = false, exposeNetworkByDefault = false, + pinnedBindHost, webClientRoot, keepaliveIntervalMs = KEEPALIVE_INTERVAL_MS, longPollCap = LONG_POLL_CAP, @@ -570,6 +588,7 @@ export class OrcaRuntimeRpcServer { this.wsPort = wsPort this.preferPinnedWsPort = preferPinnedWsPort this.exposeNetworkByDefault = exposeNetworkByDefault + this.pinnedBindHost = pinnedBindHost ?? null this.webClientRoot = webClientRoot this.keepaliveIntervalMs = keepaliveIntervalMs this.longPollCap = longPollCap @@ -1241,6 +1260,9 @@ export class OrcaRuntimeRpcServer { // A grant minted for "This computer only" is excluded: its client is a browser on this machine, so // counting it would republish the runtime on every interface one restart after the user declined that. private resolveInitialWebSocketBindHost(): string { + if (this.pinnedBindHost) { + return this.pinnedBindHost + } if (this.exposeNetworkByDefault) { return WS_BIND_HOST_ALL_INTERFACES } @@ -1286,7 +1308,7 @@ export class OrcaRuntimeRpcServer { this.wsBoundHost = options.host return { transport: wsTransport, - endpoint: `ws://${options.host}:${wsTransport.resolvedPort}` + endpoint: formatWsEndpoint(options.host, wsTransport.resolvedPort) } } @@ -1359,6 +1381,14 @@ export class OrcaRuntimeRpcServer { // connected when a later LAN/QR offer opts in. Rebinding terminates them (ws cannot move a listener), // so the resolved port is reused — already-issued endpoints stay valid and clients reconnect in place. async ensureNetworkExposure(): Promise { + if (this.pinnedBindHost && this.pinnedBindHost !== WS_BIND_HOST_ALL_INTERFACES) { + // Why throw and not return: callers widen so they can ADVERTISE a LAN endpoint. Returning + // quietly would let them publish one that nothing can reach; the throw lands in their + // existing network_exposure_failed branch, which reports the offer unavailable instead. + throw new Error( + `Runtime bind address is pinned to ${this.pinnedBindHost}; refusing to widen to all interfaces` + ) + } if ( !this.enableWebSocket || this.stopping || diff --git a/src/main/server/serve-readiness.test.ts b/src/main/server/serve-readiness.test.ts index 7e037eba025..3e4b77e695f 100644 --- a/src/main/server/serve-readiness.test.ts +++ b/src/main/server/serve-readiness.test.ts @@ -4,6 +4,7 @@ import { ServeReadinessPublisher, type ServeReadiness } from './serve-readiness' +import type { OrcadHealth } from '../orcad/orcad-health' const ready: ServeReadiness = { runtimeId: 'runtime-1', @@ -21,6 +22,25 @@ const ready: ServeReadiness = { } } +const health: OrcadHealth = { + buildHash: 'abc123def4567890', + buildVersion: '1.4.0', + nodeVersion: '20.11.0', + nodeAbi: '115', + platform: 'linux', + arch: 'x64', + pid: 1234, + terminalDaemon: { + state: 'live', + ownsFreshSessions: true, + pid: 4242, + buildVersion: '1.4.0', + entryPath: '/opt/orcad/daemon-entry.js', + protocolVersion: 36, + selfTest: { ok: true, coverage: 'pty-spawn', verdict: 'healthy', durationMs: 12 } + } +} + describe('ServeReadinessPublisher', () => { it('writes one complete human-readable ready block', async () => { const write = vi.fn(async () => {}) @@ -90,6 +110,50 @@ describe('ServeReadinessPublisher', () => { ).toThrow('websocket_unavailable. Choose an unused --port.') }) + it('omits the health block entirely when a host does not report one', () => { + const payload = JSON.parse(renderServeReadiness(ready, { mode: 'json' })) + // Why absent rather than a null/empty object: a reader must be able to tell "this host + // does not publish health" from "this host published a green verdict". + expect('health' in payload).toBe(false) + expect(renderServeReadiness(ready, { mode: 'human' })).not.toContain('Terminal daemon') + }) + + it('carries build identity, Node ABI and the daemon self-test in the JSON contract', () => { + const payload = JSON.parse(renderServeReadiness({ ...ready, health }, { mode: 'json' })) + expect(payload.health.buildHash).toBe('abc123def4567890') + expect(payload.health.nodeAbi).toBe('115') + expect(payload.health.terminalDaemon.selfTest).toEqual({ + ok: true, + coverage: 'pty-spawn', + verdict: 'healthy', + durationMs: 12 + }) + }) + + it('says out loud when the daemon self-test failed', () => { + const failed: ServeReadiness = { + ...ready, + health: { + ...health, + terminalDaemon: { + ...health.terminalDaemon, + state: 'degraded', + ownsFreshSessions: false, + selfTest: { + ok: false, + coverage: 'pty-spawn', + verdict: 'pty-spawn-unhealthy', + durationMs: 3_000 + } + } + } + } + const human = renderServeReadiness(failed, { mode: 'human' }) + // An operator reading the ready block must not have to infer this from a missing line. + expect(human).toContain('PTY self-test FAILED') + expect(human).toContain('terminals survive an orcad restart: NO') + }) + it('rejects concurrent and later duplicate publications', async () => { let finishWrite: (() => void) | undefined const publisher = new ServeReadinessPublisher( diff --git a/src/main/server/serve-readiness.ts b/src/main/server/serve-readiness.ts index e4ab3db8925..b0bdf14daba 100644 --- a/src/main/server/serve-readiness.ts +++ b/src/main/server/serve-readiness.ts @@ -1,4 +1,5 @@ import type { PairingOfferUnavailableReason } from '../runtime/runtime-rpc' +import type { OrcadHealth } from '../orcad/orcad-health' export type ServePairingUnavailableReason = PairingOfferUnavailableReason | 'disabled_by_operator' @@ -24,6 +25,14 @@ export type ServeReadiness = { advertisedEndpoint: string | null managedWslCliReconciliation: 'pending' | 'settled' | 'failed' pairing: ServePairingReadiness + /** + * Build identity, Node ABI and the cross-process terminal-daemon self-test. + * + * Optional because the Electron `--serve` host does not publish one yet; readers must + * treat its absence as "not reported", never as healthy. Additive, so an older client + * parsing this payload is unaffected. + */ + health?: OrcadHealth } export type ServeReadinessOutput = @@ -77,7 +86,8 @@ export function renderServeReadiness( boundEndpoint: readiness.boundEndpoint, advertisedEndpoint: readiness.advertisedEndpoint, managedWslCliReconciliation: readiness.managedWslCliReconciliation, - pairing: readiness.pairing + pairing: readiness.pairing, + ...(readiness.health ? { health: readiness.health } : {}) }) } return renderHumanReadiness(readiness) @@ -89,6 +99,18 @@ function renderHumanReadiness(readiness: ServeReadiness): string { `Bound endpoint: ${readiness.boundEndpoint ?? 'websocket unavailable'}`, `Advertised endpoint: ${readiness.advertisedEndpoint ?? 'unavailable'}` ] + if (readiness.health) { + const daemon = readiness.health.terminalDaemon + lines.push( + `Build: ${readiness.health.buildVersion} (${readiness.health.buildHash}), Node ` + + `${readiness.health.nodeVersion} ABI ${readiness.health.nodeAbi}` + ) + lines.push( + `Terminal daemon: ${daemon.state} — PTY self-test ${daemon.selfTest.ok ? 'passed' : 'FAILED'}` + + ` (${daemon.selfTest.coverage}: ${daemon.selfTest.verdict})` + + `; terminals survive an orcad restart: ${daemon.ownsFreshSessions ? 'yes' : 'NO'}` + ) + } if (readiness.pairing.available) { if (readiness.pairing.webClientUrl) { lines.push(`Web client URL: ${readiness.pairing.webClientUrl}`) diff --git a/src/main/ssh/build-toolchain-diagnosis.ts b/src/main/ssh/build-toolchain-diagnosis.ts new file mode 100644 index 00000000000..446601518c9 --- /dev/null +++ b/src/main/ssh/build-toolchain-diagnosis.ts @@ -0,0 +1,166 @@ +/** + * Diagnose a host's C/C++ build toolchain from a POSIX-sh probe. + * + * Why this is separate from `ssh-relay-build-toolchain.ts`: everything here is pure — + * it builds a shell command, parses its output, and formats messages. The relay reaches + * it over SSH; `orcad` reaches it over a local shell. Keeping it transport-free is what + * lets the Node-only bundle reuse it without pulling `ssh2` in behind it. + */ +// Why: node-pty@1.1.0 ships no Linux prebuild, so the remote `npm install` falls +// back to `node-gyp rebuild` and needs a C/C++ toolchain. A missing toolchain is +// the dominant first-connect failure on Linux relays (#1693); node-gyp surfaces +// it as an opaque `not found: make`. We probe for the tools so we can replace +// that with an actionable "install build-essential" message. +const PROBED_TOOLS = [ + 'make', + 'gcc', + 'g++', + 'cc', + 'c++', + 'clang', + 'clang++', + 'python3', + 'python' +] as const + +// Package managers mapped to the one-liner that installs a C/C++ toolchain on +// the matching distro family. Ordered by detection priority. +const PACKAGE_MANAGER_HINTS: readonly { bin: string; install: string }[] = [ + { bin: 'apt-get', install: 'sudo apt-get install -y build-essential python3' }, + { bin: 'dnf', install: 'sudo dnf install -y make gcc gcc-c++ python3' }, + { bin: 'yum', install: 'sudo yum install -y make gcc gcc-c++ python3' }, + { bin: 'pacman', install: 'sudo pacman -S --needed base-devel python' }, + { bin: 'apk', install: 'sudo apk add build-base python3' }, + { bin: 'zypper', install: 'sudo zypper install -y gcc gcc-c++ make python3' } +] + +export type BuildToolchainStatus = { + present: string[] + packageManager: string | null + // node-gyp needs make, Python, and a C++ compiler. The caller only uses this + // verdict after npm/node-gyp output already points at a native-build failure, + // so custom Python paths do not make unrelated npm failures look toolchainy. + toolchainMissing: boolean +} + +function hasCxxCompiler(present: ReadonlySet): boolean { + return present.has('g++') || present.has('c++') || present.has('clang++') +} + +function hasPython(present: ReadonlySet): boolean { + return present.has('python3') || present.has('python') +} + +// POSIX-sh probe: echo a `HAVE ` line per resolvable build tool and a +// single `PKG ` line for the host's package manager. Runs under +// `/bin/sh -c` (see wrapRemoteCommandForPosixShell), so it stays portable. +export function buildToolchainProbeCommand(): string { + const toolLoop = `for t in ${PROBED_TOOLS.join( + ' ' + )}; do if command -v "$t" >/dev/null 2>&1; then echo "HAVE $t"; fi; done` + const pkgList = PACKAGE_MANAGER_HINTS.map((hint) => hint.bin).join(' ') + const pkgLoop = `for p in ${pkgList}; do if command -v "$p" >/dev/null 2>&1; then echo "PKG $p"; break; fi; done` + return `${toolLoop}; ${pkgLoop}` +} + +export function parseBuildToolchainProbe(output: string): BuildToolchainStatus { + const present = new Set() + let packageManager: string | null = null + for (const line of output.split('\n')) { + const haveMatch = line.trim().match(/^HAVE (\S+)$/) + if (haveMatch) { + present.add(haveMatch[1]) + continue + } + const pkgMatch = line.trim().match(/^PKG (\S+)$/) + if (pkgMatch && !packageManager) { + packageManager = pkgMatch[1] + } + } + return { + present: PROBED_TOOLS.filter((tool) => present.has(tool)), + packageManager, + toolchainMissing: !present.has('make') || !hasCxxCompiler(present) || !hasPython(present) + } +} + +export function shouldProbeBuildToolchainAfterNativeDepsFailure(message: string): boolean { + const lower = message.toLowerCase() + if (!lower.includes('gyp') && !lower.includes('node-gyp')) { + return false + } + return ( + /\bnot found:\s*(make|gmake|gcc|g\+\+|cc|c\+\+|clang|clang\+\+|python|python3)\b/i.test( + message + ) || + /\b(make|gmake|gcc|g\+\+|cc|c\+\+|clang|clang\+\+|python|python3)\b.*\bnot found\b/i.test( + message + ) || + lower.includes('could not find any python installation') || + lower.includes('no xcode or clt version detected') + ) +} + +function missingToolNames(status: BuildToolchainStatus): string[] { + const present = new Set(status.present) + const missing: string[] = [] + if (!present.has('make')) { + missing.push('make') + } + if (!hasCxxCompiler(present)) { + missing.push('a C++ compiler (g++ or clang++)') + } + if (!hasPython(present)) { + missing.push('python3') + } + return missing +} + +/** Install hint for the host's package manager, or the cross-distro list when it is unknown. */ +export function toolchainInstallHintLines(status: BuildToolchainStatus): string[] { + const tailored = status.packageManager + ? PACKAGE_MANAGER_HINTS.find((hint) => hint.bin === status.packageManager)?.install + : null + if (tailored) { + return [` ${tailored}`] + } + return [ + ' Debian/Ubuntu: sudo apt-get install -y build-essential python3', + ' Fedora/RHEL: sudo dnf install -y make gcc gcc-c++ python3', + ' Arch: sudo pacman -S --needed base-devel python', + ' Alpine: sudo apk add build-base python3' + ] +} + +/** One-line summary for the deploy log when node-pty is skipped rather than compiled. */ +export function formatSkippedNodePtyWarning(status: BuildToolchainStatus): string { + // Why: with no package manager detected the hint list is the cross-distro menu, whose first line + // is Debian's — quoting it alone would name the wrong distro, so stay neutral instead. + const hintLines = toolchainInstallHintLines(status) + const hint = + hintLines.length === 1 + ? hintLines[0].trim() + : 'install a C/C++ toolchain (make, a C++ compiler, python3)' + return ( + `missing build tools (${missingToolNames(status).join(', ')}); skipping node-pty so the ` + + `connection still serves files and git. Remote terminals need: ${hint}` + ) +} + +export function formatMissingToolchainError( + status: BuildToolchainStatus, + underlyingError: string +): string { + const lines = [ + `The remote host is missing the C/C++ build tools (${missingToolNames(status).join(', ')}) ` + + `needed to compile Orca's relay native modules (node-pty, @parcel/watcher). node-pty has no ` + + `prebuilt binary for Linux, so they must be compiled on the remote host.`, + '', + 'Install the build tools on the remote host, then reconnect:', + ...toolchainInstallHintLines(status), + '', + `Underlying install error: ${underlyingError}` + ] + return lines.join('\n') +} + diff --git a/src/main/ssh/orcad-activation-gate.test.ts b/src/main/ssh/orcad-activation-gate.test.ts new file mode 100644 index 00000000000..534d41c08f0 --- /dev/null +++ b/src/main/ssh/orcad-activation-gate.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest' + +import { evaluateOrcadActivation } from './orcad-activation-gate' +import type { ServeReadiness } from '../server/serve-readiness' +import type { OrcadHealth, TerminalDaemonHealth } from '../orcad/orcad-health' + +const EXPECTED = { buildHash: 'abc123def4567890', fullVersion: '0.2.0+bb01' } + +function daemon(overrides: Partial = {}): TerminalDaemonHealth { + return { + state: 'live', + ownsFreshSessions: true, + pid: 4242, + buildVersion: '0.2.0+bb01', + entryPath: '/home/u/.orca-remote/orcad-0.2.0+bb01/daemon-entry.js', + protocolVersion: 3, + selfTest: { ok: true, coverage: 'pty-spawn', verdict: 'healthy', durationMs: 12 }, + ...overrides + } +} + +function health(overrides: Partial = {}): OrcadHealth { + return { + buildHash: EXPECTED.buildHash, + buildVersion: EXPECTED.fullVersion, + nodeVersion: '20.11.0', + nodeAbi: '115', + platform: 'linux', + arch: 'x64', + pid: 4200, + terminalDaemon: daemon(), + ...overrides + } +} + +function readiness(overrides: Partial = {}): ServeReadiness { + return { + runtimeId: 'runtime-1', + boundEndpoint: 'ws://127.0.0.1:7777', + advertisedEndpoint: null, + managedWslCliReconciliation: 'settled', + pairing: { available: false, reason: 'disabled_by_operator', guidance: 'n/a' }, + health: health(), + ...overrides + } +} + +describe('evaluateOrcadActivation', () => { + it('activates a candidate that proved a real PTY round trip', () => { + const verdict = evaluateOrcadActivation(readiness(), EXPECTED) + expect(verdict).toEqual({ decision: 'activate', coverage: 'pty-spawn', warnings: [] }) + }) + + it('refuses when the candidate never published readiness', () => { + const verdict = evaluateOrcadActivation(null, EXPECTED) + expect(verdict).toMatchObject({ decision: 'reject', code: 'orcad_activation_no_readiness' }) + }) + + it('refuses a readiness payload with no health, rather than reading silence as healthy', () => { + const { health: _dropped, ...withoutHealth } = readiness() + const verdict = evaluateOrcadActivation(withoutHealth as ServeReadiness, EXPECTED) + expect(verdict).toMatchObject({ decision: 'reject', code: 'orcad_activation_no_health' }) + }) + + it('refuses when a different build answered — a stale process holding the port', () => { + const verdict = evaluateOrcadActivation( + readiness({ health: health({ buildHash: '0000000000000000' }) }), + EXPECTED + ) + expect(verdict).toMatchObject({ decision: 'reject', code: 'orcad_activation_build_mismatch' }) + }) + + it('refuses a listening orcad whose terminal daemon is absent', () => { + const verdict = evaluateOrcadActivation( + readiness({ + health: health({ + terminalDaemon: daemon({ + state: 'absent', + ownsFreshSessions: false, + selfTest: { ok: false, coverage: 'pty-spawn', verdict: 'no-daemon', durationMs: 1 } + }) + }) + }), + EXPECTED + ) + expect(verdict).toMatchObject({ decision: 'reject', code: 'orcad_activation_daemon_absent' }) + }) + + it('refuses a degraded daemon, whose fresh terminals would not survive a restart', () => { + const verdict = evaluateOrcadActivation( + readiness({ + health: health({ terminalDaemon: daemon({ state: 'degraded', ownsFreshSessions: false }) }) + }), + EXPECTED + ) + expect(verdict).toMatchObject({ decision: 'reject', code: 'orcad_activation_daemon_degraded' }) + }) + + it('refuses a live daemon that failed its PTY spawn probe', () => { + const verdict = evaluateOrcadActivation( + readiness({ + health: health({ + terminalDaemon: daemon({ + selfTest: { + ok: false, + coverage: 'pty-spawn', + verdict: 'pty-spawn-unhealthy', + durationMs: 30 + } + }) + }) + }), + EXPECTED + ) + expect(verdict).toMatchObject({ + decision: 'reject', + code: 'orcad_activation_pty_self_test_failed' + }) + }) + + it('refuses a green daemon that does not own fresh sessions', () => { + const verdict = evaluateOrcadActivation( + readiness({ health: health({ terminalDaemon: daemon({ ownsFreshSessions: false }) }) }), + EXPECTED + ) + expect(verdict).toMatchObject({ + decision: 'reject', + code: 'orcad_activation_no_persistent_terminals' + }) + }) + + it('refuses a candidate that is not listening', () => { + const verdict = evaluateOrcadActivation(readiness({ boundEndpoint: null }), EXPECTED) + expect(verdict).toMatchObject({ decision: 'reject', code: 'orcad_activation_not_listening' }) + }) + + it('activates handshake-only coverage but never records it as a proven PTY', () => { + const verdict = evaluateOrcadActivation( + readiness({ + health: health({ + platform: 'win32', + terminalDaemon: daemon({ + selfTest: { ok: true, coverage: 'handshake', verdict: 'healthy', durationMs: 5 } + }) + }) + }), + EXPECTED + ) + expect(verdict).toMatchObject({ decision: 'activate', coverage: 'handshake' }) + expect(verdict.decision === 'activate' && verdict.warnings[0]).toContain( + 'covered the daemon handshake only' + ) + }) + + it('checks identity before health, so a wrong-build green payload cannot pass', () => { + const verdict = evaluateOrcadActivation( + readiness({ + health: health({ buildHash: 'ffffffffffffffff', terminalDaemon: daemon() }) + }), + EXPECTED + ) + expect(verdict).toMatchObject({ code: 'orcad_activation_build_mismatch' }) + }) +}) diff --git a/src/main/ssh/orcad-activation-gate.ts b/src/main/ssh/orcad-activation-gate.ts new file mode 100644 index 00000000000..c9581919117 --- /dev/null +++ b/src/main/ssh/orcad-activation-gate.ts @@ -0,0 +1,155 @@ +/** + * Whether a freshly launched orcad has earned the right to become the active one. + * + * The failure this exists to prevent is the one `docs/design/shipping-orcad.html` names + * throughout: a deployment that reports success because a port opened. orcad answers RPC + * from its own process, so "listening" stays true while the terminal daemon that owns every + * terminal is dead — a green host that cannot run a single command. Activation therefore + * reads the cross-process health payload the candidate published, not the exit code of the + * command that started it. + * + * A refusal here is not a failure to deploy. The bytes are installed and the previous + * version is still active; nothing was lost. Activating on a bad verdict is what loses + * things. + */ +import type { ServeReadiness } from '../server/serve-readiness' + +export type OrcadActivationRejectCode = + | 'orcad_activation_no_readiness' + | 'orcad_activation_no_health' + | 'orcad_activation_build_mismatch' + | 'orcad_activation_not_listening' + | 'orcad_activation_daemon_absent' + | 'orcad_activation_daemon_degraded' + | 'orcad_activation_pty_self_test_failed' + | 'orcad_activation_no_persistent_terminals' + +export type OrcadActivationVerdict = + | { + decision: 'activate' + /** + * `pty-spawn` means a real PTY was created and torn down inside the daemon. + * `handshake` means the daemon answered but its spawn probe is a no-op on this + * platform (win32). Carried through so an activation is never recorded as proving + * more than it did. + */ + coverage: 'pty-spawn' | 'handshake' + warnings: string[] + } + | { decision: 'reject'; code: OrcadActivationRejectCode; reason: string } + +export type OrcadActivationExpectation = { + /** sha256(orcad.js).slice(0,16) computed from the bytes this client just uploaded. */ + buildHash: string + /** The full content-hashed version this deploy installed. */ + fullVersion: string +} + +/** + * Gate an activation on what the candidate actually reported. + * + * Order matters: identity before health. A health payload from the wrong process is worse + * than no payload, because it is green and about something else. + */ +export function evaluateOrcadActivation( + readiness: ServeReadiness | null, + expected: OrcadActivationExpectation +): OrcadActivationVerdict { + if (!readiness) { + return { + decision: 'reject', + code: 'orcad_activation_no_readiness', + reason: + 'The candidate orcad never published an `orca_server_ready` line. It may have exited, ' + + 'failed to bind, or be wedged before readiness. Nothing was activated.' + } + } + const health = readiness.health + if (!health) { + return { + decision: 'reject', + code: 'orcad_activation_no_health', + reason: + 'The candidate published readiness without a health payload, so its terminal daemon ' + + 'is unverified. Absence of a verdict is not a healthy verdict — treat this build as ' + + 'too old to gate on and do not activate it.' + } + } + // Why identity first: a stale orcad already holding the port would answer readiness and + // report its own (healthy) daemon. Activating on that record points the pointer at bytes + // nobody is running. + if (health.buildHash !== expected.buildHash) { + return { + decision: 'reject', + code: 'orcad_activation_build_mismatch', + reason: + `The process that answered is running build ${health.buildHash}, not the ` + + `${expected.buildHash} this deploy installed. Something else owns that port, or the ` + + 'upload did not land. Nothing was activated.' + } + } + if (!readiness.boundEndpoint) { + return { + decision: 'reject', + code: 'orcad_activation_not_listening', + reason: + 'The candidate reported no bound endpoint, so no client could reach it. Nothing was ' + + 'activated.' + } + } + const daemon = health.terminalDaemon + if (daemon.state === 'absent') { + return { + decision: 'reject', + code: 'orcad_activation_daemon_absent', + reason: + 'The candidate has no terminal daemon. Every terminal on this host would run in the ' + + 'orcad process and die with it, which is the exact regression the daemon exists to ' + + 'prevent. Nothing was activated.' + } + } + if (daemon.state === 'degraded') { + return { + decision: 'reject', + code: 'orcad_activation_daemon_degraded', + reason: + `The candidate's terminal daemon is degraded (self-test: ${daemon.selfTest.verdict}). ` + + 'Existing sessions keep working, but fresh terminals would not survive a restart. ' + + 'Nothing was activated; the previous version is still serving.' + } + } + if (!daemon.selfTest.ok) { + return { + decision: 'reject', + code: 'orcad_activation_pty_self_test_failed', + reason: + `The candidate's PTY self-test failed (${daemon.selfTest.verdict}). The host is ` + + 'listening but cannot create a terminal. Nothing was activated.' + } + } + if (!daemon.ownsFreshSessions) { + return { + decision: 'reject', + code: 'orcad_activation_no_persistent_terminals', + reason: + 'The candidate answered healthy but does not own fresh sessions, so new terminals ' + + 'would not survive its own restart. Nothing was activated.' + } + } + const warnings: string[] = [] + if (daemon.selfTest.coverage === 'handshake') { + warnings.push( + 'The PTY self-test covered the daemon handshake only — this platform does not spawn a ' + + 'probe PTY. Terminal creation is unproven on this host.' + ) + } + if (health.buildVersion !== expected.fullVersion) { + // Not a rejection: the hash already proved identity, and ORCA_VERSION is whatever the + // launch command exported. Worth saying, because a mismatch means the launch env is wrong. + warnings.push( + `The candidate reports version ${health.buildVersion} but was installed as ` + + `${expected.fullVersion}; check ORCA_VERSION in the launch command.` + ) + } + return { decision: 'activate', coverage: daemon.selfTest.coverage, warnings } +} diff --git a/src/main/ssh/orcad-activation-record-store.ts b/src/main/ssh/orcad-activation-record-store.ts new file mode 100644 index 00000000000..d60634ec525 --- /dev/null +++ b/src/main/ssh/orcad-activation-record-store.ts @@ -0,0 +1,48 @@ +/** + * Reading the activation record off a host. + * + * Split out from the deploy driver because the rollback path needs it too, and because an + * unreadable record must fail loudly in both: treating "I cannot parse this" as "nothing is + * activated" would deploy over a live install and lose its rollback target. + */ +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { RELAY_REMOTE_DIR } from './relay-protocol' +import { + ORCAD_ACTIVATION_FILENAME, + emptyOrcadActivationRecord, + parseOrcadActivationRecord, + type OrcadActivationRecord +} from './orcad-activation-record' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' + +export function orcadActivationPath(host: RemoteHostPlatform, remoteHome: string): string { + return joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR, ORCAD_ACTIVATION_FILENAME) +} + +export async function readOrcadActivationRecord(options: { + conn: SshConnection + host: RemoteHostPlatform + remoteHome: string + signal?: AbortSignal +}): Promise { + const path = orcadActivationPath(options.host, options.remoteHome) + const raw = await execCommand(options.conn, `cat ${shellQuote(path)} 2>/dev/null || true`, { + wrapCommand: options.host.commandDialect !== 'powershell', + signal: options.signal + }).catch(() => '') + const parsed = parseOrcadActivationRecord(raw) + if (parsed.state === 'ok') { + return parsed.record + } + if (parsed.state === 'unreadable') { + // Why throw: an unreadable record is not an empty one. Treating it as empty would + // activate over a live install and orphan its rollback target. + throw new Error(`Cannot read this host's orcad activation record: ${parsed.reason}`) + } + return emptyOrcadActivationRecord() +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'` +} diff --git a/src/main/ssh/orcad-activation-record.test.ts b/src/main/ssh/orcad-activation-record.test.ts new file mode 100644 index 00000000000..dd625238bb1 --- /dev/null +++ b/src/main/ssh/orcad-activation-record.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' + +import { + emptyOrcadActivationRecord, + orcadGcPinnedDirNames, + parseOrcadActivationRecord, + serializeOrcadActivationRecord, + withActivatedVersion, + withRolledBackVersion, + type OrcadStateSnapshot +} from './orcad-activation-record' + +const SNAPSHOT: OrcadStateSnapshot = { + dirName: 'pre-0.2.0+bb01-1000', + takenBeforeVersion: '0.2.0+bb01', + readableByVersion: '0.1.0+aa01', + takenAt: '2026-01-01T00:00:00.000Z' +} +const NOW = new Date('2026-01-02T00:00:00.000Z') + +describe('orcad activation record', () => { + it('round-trips through the host', () => { + const record = withActivatedVersion( + { ...emptyOrcadActivationRecord(), active: '0.1.0+aa01' }, + '0.2.0+bb01', + SNAPSHOT, + NOW + ) + const parsed = parseOrcadActivationRecord(serializeOrcadActivationRecord(record)) + expect(parsed).toEqual({ state: 'ok', record }) + }) + + it('reports an absent record as absent', () => { + expect(parseOrcadActivationRecord(null)).toEqual({ state: 'absent' }) + expect(parseOrcadActivationRecord(' ')).toEqual({ state: 'absent' }) + }) + + it('reports a newer schema as unreadable, never as absent', () => { + const parsed = parseOrcadActivationRecord(JSON.stringify({ schemaVersion: 2, active: 'x' })) + expect(parsed.state).toBe('unreadable') + }) + + it('reports corrupt JSON as unreadable, never as absent', () => { + expect(parseOrcadActivationRecord('{not json').state).toBe('unreadable') + }) + + it('names the outgoing version as the rollback target', () => { + const record = withActivatedVersion( + { ...emptyOrcadActivationRecord(), active: '0.1.0+aa01' }, + '0.2.0+bb01', + SNAPSHOT, + NOW + ) + expect(record).toMatchObject({ active: '0.2.0+bb01', previous: '0.1.0+aa01' }) + }) + + it('does not let a re-deploy of the active version erase the rollback target', () => { + const before = { + ...emptyOrcadActivationRecord(), + active: '0.2.0+bb01', + previous: '0.1.0+aa01', + snapshot: SNAPSHOT + } + const after = withActivatedVersion(before, '0.2.0+bb01', null, NOW) + expect(after).toMatchObject({ active: '0.2.0+bb01', previous: '0.1.0+aa01' }) + expect(after.snapshot).toEqual(SNAPSHOT) + }) + + it('clears the rollback target after rolling back, so it cannot walk into the bad build', () => { + const before = { + ...emptyOrcadActivationRecord(), + active: '0.2.0+bb01', + previous: '0.1.0+aa01', + snapshot: SNAPSHOT + } + expect(withRolledBackVersion(before, NOW)).toMatchObject({ + active: '0.1.0+aa01', + previous: null, + snapshot: null + }) + }) + + it('pins the active version, the rollback target and the live daemon"s bundle against GC', () => { + const pinned = orcadGcPinnedDirNames( + { + ...emptyOrcadActivationRecord(), + active: '0.3.0+cc01', + previous: '0.2.0+bb01' + }, + '0.1.0+aa01' + ) + expect(pinned).toEqual(['orcad-0.3.0+cc01', 'orcad-0.2.0+bb01', 'orcad-0.1.0+aa01']) + }) + + it('deduplicates pins when the live daemon came from the active bundle', () => { + const pinned = orcadGcPinnedDirNames( + { ...emptyOrcadActivationRecord(), active: '0.3.0+cc01', previous: null }, + '0.3.0+cc01' + ) + expect(pinned).toEqual(['orcad-0.3.0+cc01']) + }) +}) diff --git a/src/main/ssh/orcad-activation-record.ts b/src/main/ssh/orcad-activation-record.ts new file mode 100644 index 00000000000..8c32e6c355d --- /dev/null +++ b/src/main/ssh/orcad-activation-record.ts @@ -0,0 +1,175 @@ +/** + * Which installed orcad is the live one, and which one a rollback goes back to. + * + * A versioned install directory decides where bytes land; it does not decide which version + * runs. Without this record, "roll back" means "deploy the old version again" — which needs + * the client that has those bytes, on a host that may be the only thing still working. The + * record is the host-side half: it names an active version, a rollback target, and the + * pre-activation state snapshot that makes going back to that target sound. + * + * It lives beside the version dirs (`~/.orca-remote/orcad-active.json`), not inside one, + * because it has to outlive whichever version GC removes. + */ +import { remoteInstallDirName, ORCAD_INSTALL_MODEL } from './remote-install-model' + +export const ORCAD_ACTIVATION_FILENAME = 'orcad-active.json' +export const ORCAD_ACTIVATION_SCHEMA_VERSION = 1 + +/** Where a pre-activation copy of the shared data root lives, relative to `.orca-remote/`. */ +export const ORCAD_STATE_SNAPSHOT_DIR = 'orcad-state-snapshots' + +export type OrcadStateSnapshot = { + /** Directory name under `ORCAD_STATE_SNAPSHOT_DIR`. */ + dirName: string + /** The version whose activation this snapshot was taken FOR — i.e. taken before it ran. */ + takenBeforeVersion: string + /** The version that produced the state, i.e. the rollback target it is readable by. */ + readableByVersion: string | null + takenAt: string +} + +export type OrcadActivationRecord = { + schemaVersion: typeof ORCAD_ACTIVATION_SCHEMA_VERSION + /** Full content-hashed version, e.g. `0.1.0+9f2a1c`. Null before the first activation. */ + active: string | null + /** The version `active` replaced. The rollback target, and pinned against GC. */ + previous: string | null + activatedAt: string | null + snapshot: OrcadStateSnapshot | null +} + +export function emptyOrcadActivationRecord(): OrcadActivationRecord { + return { + schemaVersion: ORCAD_ACTIVATION_SCHEMA_VERSION, + active: null, + previous: null, + activatedAt: null, + snapshot: null + } +} + +/** + * Parse the record read off the host. + * + * Why a null return and not a throw on a newer schema: a client older than the host must not + * treat "I cannot read this" as "nothing is activated" — that would deploy over a live + * install. Callers distinguish the two through `OrcadActivationReadResult`. + */ +export type OrcadActivationReadResult = + | { state: 'absent' } + | { state: 'ok'; record: OrcadActivationRecord } + | { state: 'unreadable'; reason: string } + +export function parseOrcadActivationRecord(raw: string | null): OrcadActivationReadResult { + if (raw === null || raw.trim() === '') { + return { state: 'absent' } + } + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch (error) { + return { + state: 'unreadable', + reason: `activation record is not JSON: ${error instanceof Error ? error.message : String(error)}` + } + } + if (typeof parsed !== 'object' || parsed === null) { + return { state: 'unreadable', reason: 'activation record is not an object' } + } + const record = parsed as Partial + if (record.schemaVersion !== ORCAD_ACTIVATION_SCHEMA_VERSION) { + return { + state: 'unreadable', + reason: + `activation record schemaVersion ${String(record.schemaVersion)} is not ` + + `${ORCAD_ACTIVATION_SCHEMA_VERSION}; this client cannot safely interpret it` + } + } + return { + state: 'ok', + record: { + schemaVersion: ORCAD_ACTIVATION_SCHEMA_VERSION, + active: typeof record.active === 'string' ? record.active : null, + previous: typeof record.previous === 'string' ? record.previous : null, + activatedAt: typeof record.activatedAt === 'string' ? record.activatedAt : null, + snapshot: parseSnapshot(record.snapshot) + } + } +} + +function parseSnapshot(value: unknown): OrcadStateSnapshot | null { + if (typeof value !== 'object' || value === null) { + return null + } + const snapshot = value as Partial + if (typeof snapshot.dirName !== 'string' || typeof snapshot.takenBeforeVersion !== 'string') { + return null + } + return { + dirName: snapshot.dirName, + takenBeforeVersion: snapshot.takenBeforeVersion, + readableByVersion: + typeof snapshot.readableByVersion === 'string' ? snapshot.readableByVersion : null, + takenAt: typeof snapshot.takenAt === 'string' ? snapshot.takenAt : '' + } +} + +export function serializeOrcadActivationRecord(record: OrcadActivationRecord): string { + return `${JSON.stringify(record, null, 2)}\n` +} + +/** The record that results from activating `version`, keeping the outgoing one as the target. */ +export function withActivatedVersion( + record: OrcadActivationRecord, + version: string, + snapshot: OrcadStateSnapshot | null, + now: Date +): OrcadActivationRecord { + return { + schemaVersion: ORCAD_ACTIVATION_SCHEMA_VERSION, + active: version, + // Why keep the OLD previous when re-activating the same version: a repeated deploy of + // an already-active build is not a version change, so it must not erase the rollback + // target by naming the active version as its own predecessor. + previous: record.active === version ? record.previous : record.active, + activatedAt: now.toISOString(), + snapshot: record.active === version ? record.snapshot : snapshot + } +} + +/** The record that results from rolling `active` back to `previous`. */ +export function withRolledBackVersion( + record: OrcadActivationRecord, + now: Date +): OrcadActivationRecord { + return { + schemaVersion: ORCAD_ACTIVATION_SCHEMA_VERSION, + active: record.previous, + // Why null and not the version we just left: it is the build we are rolling back FROM, + // so offering it as the next rollback target would walk straight back into the failure. + previous: null, + activatedAt: now.toISOString(), + // The snapshot was taken before `active` ran; once restored it has been consumed. + snapshot: null + } +} + +/** + * Version dirs GC must not remove, as directory names. + * + * `previous` is here because a rollback target that GC deleted is not a rollback target. + * `daemonEntryVersion` is here because an update preserves a live daemon forked from the + * OUTGOING bundle (see orcad-update-plan.ts) — deleting the tree under a running process is + * how a later respawn finds no entry point. + */ +export function orcadGcPinnedDirNames( + record: OrcadActivationRecord, + daemonEntryVersion?: string | null +): string[] { + const versions = [record.active, record.previous, daemonEntryVersion ?? null].filter( + (v): v is string => typeof v === 'string' && v.length > 0 + ) + return [...new Set(versions)].map((version) => + remoteInstallDirName(ORCAD_INSTALL_MODEL, version) + ) +} diff --git a/src/main/ssh/orcad-local-build-hash.ts b/src/main/ssh/orcad-local-build-hash.ts new file mode 100644 index 00000000000..3740ea6d55c --- /dev/null +++ b/src/main/ssh/orcad-local-build-hash.ts @@ -0,0 +1,22 @@ +/** + * The build identity the deploy expects the host to answer with. + * + * It must be computed the same way `computeOrcadBuildHash` computes it on the host — + * sha256 of `orcad.js`, first 16 hex characters — or the activation gate would reject every + * healthy candidate. Keeping the two in one comment is deliberate: they are one contract + * split across a network, and the version string cannot stand in for it, because + * `ORCA_VERSION` is whatever the launch command exported and two builds can carry one value. + */ +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +export const ORCAD_BUILD_HASH_LENGTH = 16 + +export function computeLocalOrcadBuildHash(localOrcadDir: string): string { + const entry = join(localOrcadDir, 'orcad.js') + return createHash('sha256') + .update(readFileSync(entry)) + .digest('hex') + .slice(0, ORCAD_BUILD_HASH_LENGTH) +} diff --git a/src/main/ssh/orcad-remote-deploy.test.ts b/src/main/ssh/orcad-remote-deploy.test.ts new file mode 100644 index 00000000000..799193f3384 --- /dev/null +++ b/src/main/ssh/orcad-remote-deploy.test.ts @@ -0,0 +1,264 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + execCommand: vi.fn(), + isUnconfirmedSshCommandTermination: () => false +})) +vi.mock('./ssh-connection-utils', () => ({ shellEscape: (s: string) => `'${s}'` })) +vi.mock('./ssh-relay-install-lock', () => ({ + acquireInstallLock: vi.fn().mockResolvedValue(undefined), + RELAY_INSTALL_LOCK_NAME: '.install-lock' +})) +vi.mock('./ssh-relay-install-transfers', () => ({ + uploadRelayDirectory: vi.fn().mockResolvedValue(undefined), + writeRelayFile: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('./orcad-local-build-hash', () => ({ + computeLocalOrcadBuildHash: () => 'abc123def4567890' +})) + +import { execCommand } from './ssh-relay-deploy-helpers' +import { acquireInstallLock } from './ssh-relay-install-lock' +import { uploadRelayDirectory, writeRelayFile } from './ssh-relay-install-transfers' +import { deployOrcad, type OrcadDeployOptions } from './orcad-remote-deploy' +import { emptyOrcadActivationRecord, withActivatedVersion } from './orcad-activation-record' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import type { SshConnection } from './ssh-connection' + +const mockExec = vi.mocked(execCommand) +const NEW_VERSION = '0.2.0+bb01' +const OLD_VERSION = '0.1.0+aa01' + +vi.mock('./ssh-relay-versioned-install', async (importOriginal) => ({ + ...(await importOriginal>()), + readLocalFullVersion: () => '0.2.0+bb01', + isRemoteInstallComplete: vi.fn().mockResolvedValue(false), + finalizeInstall: vi.fn().mockResolvedValue(undefined), + abandonInstall: vi.fn().mockResolvedValue(undefined) +})) + +function readyLine(overrides: { + buildHash?: string + daemonState?: 'live' | 'degraded' | 'absent' + selfTestOk?: boolean +}): string { + return JSON.stringify({ + type: 'orca_server_ready', + schemaVersion: 1, + runtimeId: 'r1', + boundEndpoint: 'ws://127.0.0.1:7777', + advertisedEndpoint: null, + managedWslCliReconciliation: 'settled', + pairing: { available: false, reason: 'disabled_by_operator', guidance: 'n/a' }, + health: { + buildHash: overrides.buildHash ?? 'abc123def4567890', + buildVersion: NEW_VERSION, + nodeVersion: '20.11.0', + nodeAbi: '115', + platform: 'linux', + arch: 'x64', + pid: 1, + terminalDaemon: { + state: overrides.daemonState ?? 'live', + ownsFreshSessions: (overrides.daemonState ?? 'live') === 'live', + pid: 2, + buildVersion: NEW_VERSION, + entryPath: '/x/daemon-entry.js', + protocolVersion: 3, + selfTest: { + ok: overrides.selfTestOk ?? true, + coverage: 'pty-spawn', + verdict: (overrides.selfTestOk ?? true) ? 'healthy' : 'pty-spawn-unhealthy', + durationMs: 5 + } + } + } + }) +} + +type HostScript = { + activationRecord: string + /** Readiness content per version dir, keyed by the version in the path. */ + readiness: Record + log: string[] +} + +function scriptHost(script: HostScript): void { + mockExec.mockImplementation(async (_conn, command: string) => { + const text = String(command) + if (text.startsWith('cat ') && text.includes('orcad-active.json')) { + return script.activationRecord + } + if (text.includes('.orcad-readiness') && text.startsWith('cat ')) { + const version = Object.keys(script.readiness).find((v) => text.includes(v)) + return version ? script.readiness[version] : '' + } + if (text.includes('nohup')) { + script.log.push(`launch:${text.includes(NEW_VERSION) ? NEW_VERSION : OLD_VERSION}`) + return '9999' + } + if (text.includes('kill -TERM')) { + script.log.push(`stop:${text.includes(NEW_VERSION) ? NEW_VERSION : OLD_VERSION}`) + return 'STOPPED' + } + if (text.includes('tar -C') && text.includes('-cf')) { + script.log.push('snapshot') + return 'CAPTURED' + } + return '' + }) +} + +function options(overrides: Partial = {}): OrcadDeployOptions { + return { + conn: {} as SshConnection, + host: getRemoteHostPlatform('linux-x64'), + remoteHome: '/home/u', + localOrcadDir: '/local/out/orcad', + nodePath: '/usr/bin/node', + userDataDir: '/home/u/.orca', + bindHost: '127.0.0.1', + port: 7777, + census: { liveSessions: 0, startedSinceActivation: 0 }, + readinessTimeoutMs: 50, + sleep: async () => {}, + now: () => new Date('2026-02-02T00:00:00.000Z'), + ...overrides + } +} + +const ACTIVE_OLD = JSON.stringify( + withActivatedVersion(emptyOrcadActivationRecord(), OLD_VERSION, null, new Date(0)) +) + +describe('deployOrcad', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('installs under the orcad namespace, not the relay one', async () => { + const script: HostScript = { + activationRecord: '', + readiness: { [NEW_VERSION]: readyLine({}) }, + log: [] + } + scriptHost(script) + await deployOrcad(options()) + expect(vi.mocked(acquireInstallLock).mock.calls[0][1]).toBe( + `/home/u/.orca-remote/orcad-${NEW_VERSION}` + ) + expect(vi.mocked(uploadRelayDirectory).mock.calls[0][2]).toContain(`orcad-${NEW_VERSION}`) + }) + + it('activates a healthy candidate and records the outgoing version as the rollback target', async () => { + const script: HostScript = { + activationRecord: ACTIVE_OLD, + readiness: { [NEW_VERSION]: readyLine({}) }, + log: [] + } + scriptHost(script) + const result = await deployOrcad(options()) + expect(result).toMatchObject({ outcome: 'installed-and-activated', fullVersion: NEW_VERSION }) + const written = vi + .mocked(writeRelayFile) + .mock.calls.find((call) => String(call[2]).endsWith('orcad-active.json')) + expect(JSON.parse(String(written?.[3]))).toMatchObject({ + active: NEW_VERSION, + previous: OLD_VERSION + }) + }) + + it('snapshots the shared data root before the candidate ever runs', async () => { + const script: HostScript = { + activationRecord: ACTIVE_OLD, + readiness: { [NEW_VERSION]: readyLine({}) }, + log: [] + } + scriptHost(script) + await deployOrcad(options()) + expect(script.log.indexOf('snapshot')).toBeGreaterThan(-1) + expect(script.log.indexOf('snapshot')).toBeLessThan(script.log.indexOf(`launch:${NEW_VERSION}`)) + }) + + it('installs but does not activate when terminals are running', async () => { + const script: HostScript = { + activationRecord: ACTIVE_OLD, + readiness: { [NEW_VERSION]: readyLine({}) }, + log: [] + } + scriptHost(script) + const result = await deployOrcad( + options({ census: { liveSessions: 2, startedSinceActivation: 0 } }) + ) + expect(result).toMatchObject({ + outcome: 'installed-not-activated', + code: 'orcad_update_terminals_running' + }) + // The bytes landed; nothing was stopped, launched or snapshotted. + expect(vi.mocked(uploadRelayDirectory)).toHaveBeenCalled() + expect(script.log).toEqual([]) + }) + + it('does not write the activation record when the candidate fails its health gate', async () => { + const script: HostScript = { + activationRecord: ACTIVE_OLD, + readiness: { [NEW_VERSION]: readyLine({ daemonState: 'degraded' }) }, + log: [] + } + scriptHost(script) + const result = await deployOrcad(options()) + expect(result).toMatchObject({ code: 'orcad_activation_daemon_degraded' }) + expect( + vi.mocked(writeRelayFile).mock.calls.some((call) => String(call[2]).endsWith('orcad-active.json')) + ).toBe(false) + }) + + it('puts the previous version back after a rejected candidate, rather than leaving the host down', async () => { + const script: HostScript = { + activationRecord: ACTIVE_OLD, + readiness: { + [NEW_VERSION]: readyLine({ selfTestOk: false }), + [OLD_VERSION]: readyLine({}) + }, + log: [] + } + scriptHost(script) + const result = await deployOrcad(options()) + expect(result).toMatchObject({ outcome: 'installed-not-activated' }) + expect(script.log).toEqual([ + 'snapshot', + `stop:${OLD_VERSION}`, + `launch:${NEW_VERSION}`, + `stop:${NEW_VERSION}`, + `launch:${OLD_VERSION}` + ]) + expect(result.outcome === 'installed-not-activated' && result.reason).toContain( + `orcad ${OLD_VERSION} was restarted and is serving again` + ) + }) + + it('refuses to activate when a different build answered the port', async () => { + const script: HostScript = { + activationRecord: ACTIVE_OLD, + readiness: { + [NEW_VERSION]: readyLine({ buildHash: 'deadbeefdeadbeef' }), + [OLD_VERSION]: readyLine({}) + }, + log: [] + } + scriptHost(script) + const result = await deployOrcad(options()) + expect(result).toMatchObject({ code: 'orcad_activation_build_mismatch' }) + }) + + it('refuses to treat an unreadable activation record as an empty one', async () => { + const script: HostScript = { + activationRecord: JSON.stringify({ schemaVersion: 99, active: 'x' }), + readiness: { [NEW_VERSION]: readyLine({}) }, + log: [] + } + scriptHost(script) + await expect(deployOrcad(options())).rejects.toThrow('activation record') + expect(vi.mocked(uploadRelayDirectory)).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ssh/orcad-remote-deploy.ts b/src/main/ssh/orcad-remote-deploy.ts new file mode 100644 index 00000000000..1f8a4099a82 --- /dev/null +++ b/src/main/ssh/orcad-remote-deploy.ts @@ -0,0 +1,340 @@ +/** + * Installing orcad on a host and, only if it proves itself, making it the active one. + * + * The install half is the relay's transaction, parameterized: the same per-version lock, + * staged SFTP write, `.install-complete` sentinel and stale-lock recovery, under + * `orcad-/` instead of `relay-/`. That is what §02 marks reusable. + * + * The activation half has no relay equivalent, because the relay has no notion of a version + * being *selected*. Bytes landing in a versioned directory neither picks a version nor rolls + * one back; the activation record does, and it is written only after the candidate publishes + * a health payload that survives `evaluateOrcadActivation`. A rejected candidate leaves the + * previous version running and its own bytes on disk — nothing is lost, and a retry costs no + * upload. + */ +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { ORCAD_INSTALL_MODEL } from './remote-install-model' +import { acquireInstallLock } from './ssh-relay-install-lock' +import { uploadRelayDirectory, writeRelayFile } from './ssh-relay-install-transfers' +import { + abandonInstall, + computeRemoteInstallDir, + finalizeInstall, + isRemoteInstallComplete, + readLocalFullVersion +} from './ssh-relay-versioned-install' +import { RELAY_REMOTE_DIR } from './relay-protocol' +import { + ORCAD_STATE_SNAPSHOT_DIR, + serializeOrcadActivationRecord, + withActivatedVersion, + type OrcadActivationRecord, + type OrcadStateSnapshot +} from './orcad-activation-record' +import { + orcadActivationPath, + readOrcadActivationRecord +} from './orcad-activation-record-store' +import { evaluateOrcadActivation, type OrcadActivationVerdict } from './orcad-activation-gate' +import { planOrcadUpdate, type OrcadTerminalCensus } from './orcad-update-plan' +import { + ORCAD_LOG_FILENAME, + orcadLaunchCommand, + parseOrcadReadinessOutput, + readOrcadReadinessCommand, + type OrcadLaunchSpec +} from './orcad-remote-launch' +import { + captureOrcadStateSnapshotCommand, + orcadSnapshotDirName, + parseOrcadSnapshotCapture +} from './orcad-state-snapshot' +import { + orcadStopFreedTheHost, + parseOrcadStopOutcome, + stopOrcadCommand +} from './orcad-remote-process-control' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { computeLocalOrcadBuildHash } from './orcad-local-build-hash' + +export type OrcadDeployOptions = { + conn: SshConnection + host: RemoteHostPlatform + remoteHome: string + /** Local `out/orcad`, containing the artifacts and the `.version` marker. */ + localOrcadDir: string + nodePath: string + userDataDir: string + bindHost: string + port: number + /** + * Live-terminal counts, supplied by the caller from the runtime it is already connected + * to. Not probed here: counting the daemon's sessions needs its protocol, and a deploy + * that guessed zero from silence would be the "loss of contact means death" mistake. + */ + census: OrcadTerminalCensus + force?: boolean + readinessTimeoutMs?: number + now?: () => Date + sleep?: (ms: number) => Promise + signal?: AbortSignal +} + +export type OrcadDeployResult = + | { outcome: 'installed-and-activated'; fullVersion: string; verdict: OrcadActivationVerdict } + | { outcome: 'already-active'; fullVersion: string } + | { outcome: 'installed-not-activated'; fullVersion: string; code: string; reason: string } + +const DEFAULT_READINESS_TIMEOUT_MS = 90_000 +const READINESS_POLL_MS = 500 +const STOP_WAIT_SECONDS = 20 + +function exec( + options: OrcadDeployOptions, + command: string, + signal = options.signal +): Promise { + return execCommand(options.conn, command, { + wrapCommand: options.host.commandDialect !== 'powershell', + signal + }) +} + +function baseDir(options: OrcadDeployOptions): string { + return joinRemotePath(options.host, options.remoteHome, RELAY_REMOTE_DIR) +} + +/** Install the bytes under `orcad-/`, using the relay's install transaction. */ +async function installOrcadBundle( + options: OrcadDeployOptions, + fullVersion: string, + remoteDir: string +): Promise { + if (await isRemoteInstallComplete(options.conn, ORCAD_INSTALL_MODEL, remoteDir, options.host, { + signal: options.signal + })) { + return + } + await acquireInstallLock(options.conn, remoteDir, options.host, { signal: options.signal }) + try { + // Re-probe under the lock: a sibling deploy may have finished while we waited. + if ( + await isRemoteInstallComplete(options.conn, ORCAD_INSTALL_MODEL, remoteDir, options.host, { + signal: options.signal + }) + ) { + return + } + await uploadRelayDirectory(options.conn, options.localOrcadDir, remoteDir, options.host, { + signal: options.signal + }) + await writeRelayFile( + options.conn, + options.host, + joinRemotePath(options.host, remoteDir, ORCAD_INSTALL_MODEL.versionFilename), + fullVersion, + { signal: options.signal } + ) + await finalizeInstall(options.conn, remoteDir, options.host, { signal: options.signal }) + } catch (error) { + // Leave a recoverable partial rather than a dir that probes complete. + await abandonInstall(options.conn, remoteDir, options.host) + throw error + } +} + +async function captureSnapshot( + options: OrcadDeployOptions, + fullVersion: string, + outgoingVersion: string | null, + takenAt: Date +): Promise { + const dirName = orcadSnapshotDirName(fullVersion, takenAt.getTime()) + const snapshotDir = joinRemotePath(options.host, baseDir(options), ORCAD_STATE_SNAPSHOT_DIR, dirName) + const capture = parseOrcadSnapshotCapture( + await exec( + options, + captureOrcadStateSnapshotCommand(options.host, options.userDataDir, snapshotDir) + ) + ) + if (capture === 'failed') { + throw new Error( + `Could not snapshot ${options.userDataDir} before activating ${fullVersion}. Orca's ` + + 'persisted state carries no schema version, so without a snapshot a rollback has no ' + + 'way back. Refusing to activate.' + ) + } + if (capture === 'empty') { + // Nothing on the host to lose: a first deployment. Rollback will correctly report that + // it has no snapshot, rather than restoring an archive of nothing over a populated root. + return null + } + return { + dirName, + takenBeforeVersion: fullVersion, + readableByVersion: outgoingVersion, + takenAt: takenAt.toISOString() + } +} + +async function launchAndAwaitReadiness( + options: OrcadDeployOptions, + spec: OrcadLaunchSpec +): Promise> { + await exec(options, orcadLaunchCommand(options.host, spec)) + const deadline = Date.now() + (options.readinessTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS) + const sleep = options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))) + let last = parseOrcadReadinessOutput('') + while (Date.now() < deadline) { + options.signal?.throwIfAborted() + last = parseOrcadReadinessOutput( + await exec(options, readOrcadReadinessCommand(options.host, spec.remoteInstallDir)) + ) + if (last.state !== 'pending') { + return last + } + await sleep(READINESS_POLL_MS) + } + return last +} + +/** + * Put the previous version back after a rejected candidate. + * + * Why this exists at all: activating means swapping which process owns the data root and the + * port, so the incumbent has to stop before the candidate can start. A gate that rejected + * and returned would leave the host with nothing running — a careful deploy causing the + * outage it was being careful about. The returned sentence goes into the caller's reason so + * the operator learns the host's actual state, not just why the candidate failed. + */ +async function restoreIncumbent( + options: OrcadDeployOptions, + record: OrcadActivationRecord, + candidateDir: string +): Promise { + const stopped = parseOrcadStopOutcome( + await exec( + options, + stopOrcadCommand(options.host, candidateDir, { waitSeconds: STOP_WAIT_SECONDS }) + ) + ) + if (!orcadStopFreedTheHost(stopped)) { + return `The candidate itself did not stop (${stopped}); the host may still be serving the rejected build.` + } + if (!record.active) { + return 'No previous version was active, so this host is now serving nothing.' + } + const incumbentDir = computeRemoteInstallDir( + ORCAD_INSTALL_MODEL, + options.remoteHome, + record.active + ) + const parsed = await launchAndAwaitReadiness(options, { + remoteInstallDir: incumbentDir, + nodePath: options.nodePath, + fullVersion: record.active, + userDataDir: options.userDataDir, + bindHost: options.bindHost, + port: options.port + }) + return parsed.state === 'ready' + ? `orcad ${record.active} was restarted and is serving again.` + : `orcad ${record.active} was relaunched but has not published readiness; this host may be down.` +} + +/** + * Install, then activate only on a green cross-process health verdict. + * + * Every early return past the install leaves the bytes on disk and the previous version + * serving, which is why they all report `installed-not-activated` rather than throwing: a + * refusal to switch is a successful outcome of a deploy that was asked to be careful. + */ +export async function deployOrcad(options: OrcadDeployOptions): Promise { + const now = options.now ?? ((): Date => new Date()) + const fullVersion = readLocalFullVersion(options.localOrcadDir) + const remoteDir = computeRemoteInstallDir(ORCAD_INSTALL_MODEL, options.remoteHome, fullVersion) + const record = await readOrcadActivationRecord(options) + + await installOrcadBundle(options, fullVersion, remoteDir) + + const plan = planOrcadUpdate({ + record, + candidateVersion: fullVersion, + census: options.census, + ...(options.force !== undefined ? { force: options.force } : {}) + }) + if (plan.action === 'noop') { + return { outcome: 'already-active', fullVersion } + } + if (plan.action === 'defer') { + return { + outcome: 'installed-not-activated', + fullVersion, + code: plan.code, + reason: plan.reason + } + } + + const snapshot = record.active + ? await captureSnapshot(options, fullVersion, record.active, now()) + : null + + if (record.active) { + const outgoingDir = computeRemoteInstallDir( + ORCAD_INSTALL_MODEL, + options.remoteHome, + record.active + ) + const stopped = parseOrcadStopOutcome( + await exec(options, stopOrcadCommand(options.host, outgoingDir, { + waitSeconds: STOP_WAIT_SECONDS + })) + ) + if (!orcadStopFreedTheHost(stopped)) { + return { + outcome: 'installed-not-activated', + fullVersion, + code: 'orcad_outgoing_stop_incomplete', + reason: + `orcad ${record.active} did not exit within ${STOP_WAIT_SECONDS}s of SIGTERM ` + + `(${stopped}). It is still holding the data root and the port, so the candidate ` + + 'cannot start. Not escalating to SIGKILL: that skips the shutdown that releases ' + + 'the instance lock, and the successor would then refuse to start.' + } + } + } + + const parsed = await launchAndAwaitReadiness(options, { + remoteInstallDir: remoteDir, + nodePath: options.nodePath, + fullVersion, + userDataDir: options.userDataDir, + bindHost: options.bindHost, + port: options.port + }) + const verdict = evaluateOrcadActivation(parsed.state === 'ready' ? parsed.readiness : null, { + buildHash: computeLocalOrcadBuildHash(options.localOrcadDir), + fullVersion + }) + if (verdict.decision === 'reject') { + const restored = await restoreIncumbent(options, record, remoteDir) + return { + outcome: 'installed-not-activated', + fullVersion, + code: verdict.code, + reason: + `${verdict.reason} Candidate stderr is at ` + + `${joinRemotePath(options.host, remoteDir, ORCAD_LOG_FILENAME)}. ${restored}` + } + } + + await writeRelayFile( + options.conn, + options.host, + orcadActivationPath(options.host, options.remoteHome), + serializeOrcadActivationRecord(withActivatedVersion(record, fullVersion, snapshot, now())), + { signal: options.signal } + ) + return { outcome: 'installed-and-activated', fullVersion, verdict } +} diff --git a/src/main/ssh/orcad-remote-gc.test.ts b/src/main/ssh/orcad-remote-gc.test.ts new file mode 100644 index 00000000000..3716aaddaf9 --- /dev/null +++ b/src/main/ssh/orcad-remote-gc.test.ts @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + execCommand: vi.fn(), + isUnconfirmedSshCommandTermination: () => false +})) +vi.mock('./ssh-connection-utils', () => ({ shellEscape: (s: string) => `'${s}'` })) +vi.mock('./ssh-relay-gc-claim', () => ({ + isRelayGcClaimOwned: vi.fn().mockResolvedValue(true), + releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), + tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('token') +})) +vi.mock('./ssh-relay-gc-tombstone', () => ({ + cleanupRelayGcTombstones: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('./ssh-relay-install-lock', () => ({ + RELAY_INSTALL_LOCK_NAME: '.install-lock', + isRelayInstallLockStale: vi.fn().mockResolvedValue(false) +})) + +import { execCommand } from './ssh-relay-deploy-helpers' +import { gcOldOrcadVersions } from './orcad-remote-gc' +import { emptyOrcadActivationRecord } from './orcad-activation-record' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import type { SshConnection } from './ssh-connection' + +const conn = {} as SshConnection +const host = getRemoteHostPlatform('linux-x64') +const mockExec = vi.mocked(execCommand) + +/** + * Drive one GC pass against a scripted host. `listing` is what the remote listing command + * returns; every other command answers from `responses`, defaulting to the happy path + * (unlocked, complete, dead) so a candidate is removed unless a test says otherwise. + */ +function scriptHost(options: { + listing: string[] + liveness?: Record + removed: string[] +}): void { + mockExec.mockImplementation(async (_conn, command: string) => { + if (command.includes('-mindepth 1 -maxdepth 1')) { + return options.listing.join('\n') + } + if (command.includes('.install-lock')) { + return 'OPEN' + } + if (command.includes('.install-complete')) { + return 'COMPLETE' + } + if (command.includes('.orcad-pid')) { + const dir = Object.keys(options.liveness ?? {}).find((name) => command.includes(name)) + return dir ? (options.liveness?.[dir] ?? 'DEAD') : 'DEAD' + } + if (command.startsWith('mv ')) { + const match = command.match(/'([^']*)'/) + if (match) { + options.removed.push(match[1].split('/').pop() ?? '') + } + return 'MOVED' + } + return '' + }) +} + +describe('orcad GC', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('scopes its remote listing to the orcad namespace', async () => { + const removed: string[] = [] + scriptHost({ listing: [], removed }) + await gcOldOrcadVersions({ + conn, + host, + remoteHome: '/home/u', + currentDirAbsPath: '/home/u/.orca-remote/orcad-0.2.0+bb', + record: emptyOrcadActivationRecord() + }) + const listCommand = mockExec.mock.calls.map((call) => String(call[1])).find((c) => c.includes('find')) + expect(listCommand).toContain("-name 'orcad-*'") + expect(listCommand).not.toContain("-name 'relay-*'") + }) + + it('leaves relay directories alone even when the host hands them to it', async () => { + // The host is lying — or a future listing bug widened the glob. GC must still refuse. + const removed: string[] = [] + scriptHost({ + listing: ['relay-0.1.0+aa', 'relay-0.0.9+ff', 'orcad-0.1.0+aa'], + removed + }) + await gcOldOrcadVersions({ + conn, + host, + remoteHome: '/home/u', + currentDirAbsPath: '/home/u/.orca-remote/orcad-0.2.0+bb', + record: emptyOrcadActivationRecord() + }) + expect(removed).toEqual(['orcad-0.1.0+aa']) + }) + + it('never removes the active or the previous version', async () => { + const removed: string[] = [] + scriptHost({ + listing: ['orcad-0.1.0+01d', 'orcad-0.2.0+9ee0', 'orcad-0.3.0+cc0'], + removed + }) + await gcOldOrcadVersions({ + conn, + host, + remoteHome: '/home/u', + currentDirAbsPath: '/home/u/.orca-remote/orcad-0.3.0+cc0', + record: { + ...emptyOrcadActivationRecord(), + active: '0.3.0+cc0', + previous: '0.2.0+9ee0' + } + }) + expect(removed).toEqual(['orcad-0.1.0+01d']) + }) + + it('never removes the version a live daemon was forked from', async () => { + const removed: string[] = [] + scriptHost({ listing: ['orcad-0.1.0+01d', 'orcad-0.0.9+01de'], removed }) + await gcOldOrcadVersions({ + conn, + host, + remoteHome: '/home/u', + currentDirAbsPath: '/home/u/.orca-remote/orcad-0.3.0+cc0', + record: { ...emptyOrcadActivationRecord(), active: '0.3.0+cc0' }, + liveDaemonVersion: '0.1.0+01d' + }) + expect(removed).toEqual(['orcad-0.0.9+01de']) + }) + + it('treats an unanswerable liveness probe as in use', async () => { + const removed: string[] = [] + scriptHost({ + listing: ['orcad-0.1.0+bb0', 'orcad-0.0.9+dead'], + liveness: { 'orcad-0.1.0+bb0': 'UNKNOWN', 'orcad-0.0.9+dead': 'DEAD' }, + removed + }) + await gcOldOrcadVersions({ + conn, + host, + remoteHome: '/home/u', + currentDirAbsPath: '/home/u/.orca-remote/orcad-0.3.0+cc0', + record: emptyOrcadActivationRecord() + }) + expect(removed).toEqual(['orcad-0.0.9+dead']) + }) +}) diff --git a/src/main/ssh/orcad-remote-gc.ts b/src/main/ssh/orcad-remote-gc.ts new file mode 100644 index 00000000000..a96b386fc1b --- /dev/null +++ b/src/main/ssh/orcad-remote-gc.ts @@ -0,0 +1,74 @@ +/** + * orcad's garbage collection, and the half of §06 falsifier 1 that says who owns it. + * + * **Each model GCs only its own namespace, permanently.** orcad removes `orcad-/` + * directories; the relay removes `relay-/` directories; neither ever removes the other's, + * and no plan item makes one the winner. That is not a migration compromise — the two models + * serve different users on the same machine (SSH target vs paired peer), so there is no + * moment at which one of them is entitled to clean up after the other. A pass that deleted + * the sibling's tree would be reaching across the execution boundary the whole design exists + * to keep intact. + * + * On top of the ownership rule, orcad pins three directories that are idle-looking but + * load-bearing: the active version, the rollback target, and whichever version the LIVE + * terminal daemon was forked from. + */ +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { ORCAD_INSTALL_MODEL } from './remote-install-model' +import { gcOldRemoteInstallVersions } from './ssh-relay-versioned-install' +import { orcadGcPinnedDirNames, type OrcadActivationRecord } from './orcad-activation-record' +import { + orcadLivenessBlocksGc, + orcadLivenessProbeCommand, + parseOrcadLiveness +} from './orcad-remote-launch' +import type { RemoteHostPlatform } from './ssh-remote-platform' + +export type OrcadGcOptions = { + conn: SshConnection + host: RemoteHostPlatform + remoteHome: string + /** Absolute path of the version dir this client just used; never a candidate. */ + currentDirAbsPath: string + record: OrcadActivationRecord + /** + * The full version the live daemon's PID record names, when it can be read. + * + * An update preserves a daemon forked from the OUTGOING bundle whenever terminals are + * live, so this is routinely a version that is neither active nor previous. Deleting it + * would remove the tree under a running process. + */ + liveDaemonVersion?: string | null + signal?: AbortSignal +} + +export async function gcOldOrcadVersions(options: OrcadGcOptions): Promise { + await gcOldRemoteInstallVersions( + options.conn, + ORCAD_INSTALL_MODEL, + options.remoteHome, + options.currentDirAbsPath, + options.host, + { + pinnedDirNames: orcadGcPinnedDirNames(options.record, options.liveDaemonVersion), + isDirLive: async (dir) => { + try { + const probe = await execCommand( + options.conn, + orcadLivenessProbeCommand(options.host, dir), + { + wrapCommand: options.host.commandDialect !== 'powershell', + signal: options.signal + } + ) + return orcadLivenessBlocksGc(parseOrcadLiveness(probe)) + } catch { + // Why true: an unanswered probe is not evidence a tree is idle. Same rule the + // relay's socket probe applies, for the same reason. + return true + } + } + } + ) +} diff --git a/src/main/ssh/orcad-remote-host-support.ts b/src/main/ssh/orcad-remote-host-support.ts new file mode 100644 index 00000000000..dfcb0f4b6e4 --- /dev/null +++ b/src/main/ssh/orcad-remote-host-support.ts @@ -0,0 +1,50 @@ +/** + * Which hosts the orcad launch/lifecycle path actually supports, declared rather than + * discovered at runtime. + * + * The install transaction is host-agnostic — it is the relay's, and the relay runs on + * Windows. The launch, liveness and stop path is not: it uses `nohup`, a redirected stdout, + * `kill -0` and `ps`. Emitting a PowerShell-shaped approximation of that would produce a + * deploy that reports success on a host where nothing is running. + */ +import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' + +export class OrcadRemoteLaunchUnsupportedError extends Error { + readonly code = 'orcad_remote_launch_unsupported_host' + constructor(hostLabel: string) { + super( + `Deploying orcad to a ${hostLabel} host is not implemented. The install transaction is ` + + 'host-agnostic, but the launch and readiness path is POSIX-only: it uses nohup, a ' + + 'redirected stdout and `kill -0` liveness. Use the relay for this host.' + ) + this.name = 'OrcadRemoteLaunchUnsupportedError' + } +} + +export function assertPosixOrcadHost(host: RemoteHostPlatform): void { + if (isWindowsRemoteHost(host)) { + throw new OrcadRemoteLaunchUnsupportedError('Windows') + } +} + +/** PID of the launched orcad, written into its own version dir at launch. */ +export const ORCAD_PID_FILENAME = '.orcad-pid' + +/** + * A shell function answering whether a PID is a *running* process. + * + * `kill -0` alone is not that question. It succeeds for a zombie — a process that has + * exited but whose parent has not reaped it — so a stop loop built on it reports + * `STILL_RUNNING` for a process that is already gone, and GC reports a dead version dir as + * in use. Verified against a real zombie on macOS; the `ps` state check is what separates + * the two. + * + * A host without `ps` yields an empty state, which falls through to "alive" — the safe + * direction for both callers. + */ +export function posixProcessAliveShellFunction(): string { + return ( + 'orcad_alive() { kill -0 "$1" 2>/dev/null || return 1; ' + + 'case "$(ps -o stat= -p "$1" 2>/dev/null)" in Z*) return 1;; esac; return 0; };' + ) +} diff --git a/src/main/ssh/orcad-remote-launch.test.ts b/src/main/ssh/orcad-remote-launch.test.ts new file mode 100644 index 00000000000..068ae588f65 --- /dev/null +++ b/src/main/ssh/orcad-remote-launch.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' + +import { + ORCAD_READINESS_FILENAME, + orcadLaunchCommand, + orcadLivenessBlocksGc, + orcadLivenessProbeCommand, + OrcadRemoteLaunchUnsupportedError, + parseOrcadLiveness, + parseOrcadReadinessOutput +} from './orcad-remote-launch' +import { + orcadStopFreedTheHost, + parseOrcadStopOutcome, + stopOrcadCommand +} from './orcad-remote-process-control' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const posix = getRemoteHostPlatform('linux-x64') +const windows = getRemoteHostPlatform('win32-x64') + +const SPEC = { + remoteInstallDir: '/home/u/.orca-remote/orcad-0.2.0+bb01', + nodePath: '/usr/bin/node', + fullVersion: '0.2.0+bb01', + userDataDir: '/home/u/.orca', + bindHost: '127.0.0.1', + port: 7777 +} + +const READY_LINE = JSON.stringify({ + type: 'orca_server_ready', + schemaVersion: 1, + runtimeId: 'r1', + boundEndpoint: 'ws://127.0.0.1:7777', + advertisedEndpoint: null, + managedWslCliReconciliation: 'settled', + pairing: { available: false, reason: 'disabled_by_operator', guidance: 'n/a' }, + health: { buildHash: 'abc', terminalDaemon: { state: 'live' } } +}) + +describe('orcadLaunchCommand', () => { + it('states the bind posture rather than inheriting the build default', () => { + expect(orcadLaunchCommand(posix, SPEC)).toContain("--bind '127.0.0.1'") + }) + + it('truncates the readiness file, so a stale line cannot be activated on', () => { + const command = orcadLaunchCommand(posix, SPEC) + const truncate = command.indexOf(`: > '${SPEC.remoteInstallDir}/${ORCAD_READINESS_FILENAME}'`) + const launch = command.indexOf('nohup') + expect(truncate).toBeGreaterThan(-1) + expect(truncate).toBeLessThan(launch) + }) + + it('exports the version and the shared data root the deploy decided on', () => { + const command = orcadLaunchCommand(posix, SPEC) + expect(command).toContain(`ORCA_VERSION '${SPEC.fullVersion}'`.replace(' ', '=')) + expect(command).toContain(`ORCA_USER_DATA='${SPEC.userDataDir}'`) + }) + + it('declares the Windows refusal instead of emitting a command that cannot work', () => { + expect(() => orcadLaunchCommand(windows, SPEC)).toThrow(OrcadRemoteLaunchUnsupportedError) + }) +}) + +describe('readiness parsing', () => { + it('extracts the orca_server_ready payload', () => { + const parsed = parseOrcadReadinessOutput(`${READY_LINE}\n`) + expect(parsed).toMatchObject({ state: 'ready' }) + expect(parsed.state === 'ready' && parsed.readiness.boundEndpoint).toBe('ws://127.0.0.1:7777') + }) + + it('treats an empty or half-written file as pending, not as a failure', () => { + expect(parseOrcadReadinessOutput('')).toEqual({ state: 'pending' }) + expect(parseOrcadReadinessOutput('{"type":"orca_serv')).toEqual({ state: 'pending' }) + }) + + it('reports a complete JSON line that is not a readiness payload as malformed', () => { + expect(parseOrcadReadinessOutput('{"type":"something_else"}')).toMatchObject({ + state: 'malformed' + }) + }) +}) + +describe('liveness', () => { + it('reads the pid recorded in the version dir', () => { + expect(orcadLivenessProbeCommand(posix, SPEC.remoteInstallDir)).toContain('.orcad-pid') + }) + + it.each([ + ['LIVE', 'LIVE', true], + ['DEAD', 'DEAD', false], + ['', 'UNKNOWN', true], + ['garbage', 'UNKNOWN', true] + ])('parses %s and blocks GC = %s', (output, expected, blocks) => { + expect(parseOrcadLiveness(output)).toBe(expected) + expect(orcadLivenessBlocksGc(parseOrcadLiveness(output))).toBe(blocks) + }) +}) + +describe('stopping a running orcad', () => { + it('sends SIGTERM and never SIGKILL', () => { + const command = stopOrcadCommand(posix, SPEC.remoteInstallDir, { waitSeconds: 20 }) + expect(command).toContain('kill -TERM') + for (const kill of ['kill -9', 'kill -KILL', 'kill -SIGKILL', 'pkill']) { + expect(command).not.toContain(kill) + } + }) + + it.each([ + ['STOPPED', 'stopped', true], + ['ALREADY_EXITED', 'already-exited', true], + ['NO_PID', 'no-pid', true], + ['STILL_RUNNING', 'still-running', false], + ['SIGNAL_FAILED', 'signal-failed', false], + ['', 'unknown', false] + ])('parses %s and frees the host = %s', (output, expected, frees) => { + expect(parseOrcadStopOutcome(output)).toBe(expected) + expect(orcadStopFreedTheHost(parseOrcadStopOutcome(output))).toBe(frees) + }) +}) diff --git a/src/main/ssh/orcad-remote-launch.ts b/src/main/ssh/orcad-remote-launch.ts new file mode 100644 index 00000000000..e82be95faec --- /dev/null +++ b/src/main/ssh/orcad-remote-launch.ts @@ -0,0 +1,178 @@ +/** + * Starting a candidate orcad on the host and reading back what it says about itself. + * + * This is the piece `docs/design/shipping-orcad.html` §02 marks **fork**, not reuse: the + * relay launches detached and proves itself by printing an `ORCA-RELAY` sentinel, and orcad + * has no such interface. It publishes a single `orca_server_ready` JSON line on stdout, + * carrying the health payload activation is gated on — so the handshake here is "capture + * that line", not "match a marker". + * + * The candidate is launched detached with stdout redirected to a file inside its own version + * directory. Reading readiness off the exec channel would mean holding the channel open for + * the process's whole life; redirecting means the deploy can disconnect and the supervisor + * still owns a running service. + */ +import { shellEscape } from './ssh-connection-utils' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { + assertPosixOrcadHost as assertPosixHost, + ORCAD_PID_FILENAME, + posixProcessAliveShellFunction +} from './orcad-remote-host-support' +import type { ServeReadiness } from '../server/serve-readiness' + +/** Stdout of the launched candidate: exactly one `orca_server_ready` line, then nothing. */ +export const ORCAD_READINESS_FILENAME = '.orcad-readiness' +/** Stderr, including the bind-exposure line and every supervision message. */ +export const ORCAD_LOG_FILENAME = 'orcad.log' +export { + ORCAD_PID_FILENAME, + OrcadRemoteLaunchUnsupportedError +} from './orcad-remote-host-support' + +export type OrcadLaunchSpec = { + remoteInstallDir: string + nodePath: string + fullVersion: string + /** Shared across versions, and the reason rollback needs a snapshot. */ + userDataDir: string + /** Loopback by default; the client reaches it through an SSH local port-forward. */ + bindHost: string + port: number +} + +/** + * Launch the candidate detached and echo its PID. + * + * Why `--bind` is always passed explicitly: orcad defaults to loopback, but a default is a + * thing a future version can change. The deploy states the posture it intends rather than + * inheriting whatever the installed build happens to default to. + */ +export function orcadLaunchCommand(host: RemoteHostPlatform, spec: OrcadLaunchSpec): string { + assertPosixHost(host) + const dir = shellEscape(spec.remoteInstallDir) + const readiness = shellEscape(joinRemotePath(host, spec.remoteInstallDir, ORCAD_READINESS_FILENAME)) + const log = shellEscape(joinRemotePath(host, spec.remoteInstallDir, ORCAD_LOG_FILENAME)) + const pidFile = shellEscape(joinRemotePath(host, spec.remoteInstallDir, ORCAD_PID_FILENAME)) + const entry = shellEscape(joinRemotePath(host, spec.remoteInstallDir, 'orcad.js')) + return [ + `cd ${dir} &&`, + // Why truncate: a re-launch into a dir that already holds a previous readiness line would + // otherwise let the deploy activate on the OLD process's health payload. + `: > ${readiness} &&`, + 'umask 077 &&', + `ORCA_VERSION=${shellEscape(spec.fullVersion)}`, + `ORCA_USER_DATA=${shellEscape(spec.userDataDir)}`, + `nohup ${shellEscape(spec.nodePath)} ${entry}`, + `--json --bind ${shellEscape(spec.bindHost)} --port ${String(spec.port)}`, + `> ${readiness} 2>> ${log} < /dev/null &`, + `echo $! > ${pidFile} && cat ${pidFile}` + ].join(' ') +} + +export function readOrcadReadinessCommand( + host: RemoteHostPlatform, + remoteInstallDir: string +): string { + assertPosixHost(host) + const readiness = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_READINESS_FILENAME)) + return `cat ${readiness} 2>/dev/null || true` +} + +/** + * Is the process recorded in this version dir still running? + * + * Answers `LIVE`, `DEAD`, or `UNKNOWN`. `UNKNOWN` covers a missing or unparseable PID file + * and a `kill -0` that failed for a reason other than "no such process" — a permission + * error means someone else's process holds that PID, which is not evidence of death. + */ +export function orcadLivenessProbeCommand( + host: RemoteHostPlatform, + remoteInstallDir: string +): string { + assertPosixHost(host) + const pidFile = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_PID_FILENAME)) + return [ + posixProcessAliveShellFunction(), + `pid=$(cat ${pidFile} 2>/dev/null);`, + 'case "$pid" in', + '"" ) echo UNKNOWN;;', + '*[!0-9]* ) echo UNKNOWN;;', + // Why EPERM is LIVE and not DEAD: a permission error means some process holds that PID, + // and deleting a tree because we could not signal its owner is the wrong direction. + '* ) if orcad_alive "$pid"; then echo LIVE;', + 'elif kill -0 "$pid" 2>&1 | grep -qi "not permitted"; then echo LIVE;', + 'else echo DEAD; fi;;', + 'esac' + ].join(' ') +} + +export type OrcadLiveness = 'LIVE' | 'DEAD' | 'UNKNOWN' + +export function parseOrcadLiveness(output: string): OrcadLiveness { + const value = output.trim().split('\n').pop()?.trim() + return value === 'LIVE' || value === 'DEAD' ? value : 'UNKNOWN' +} + +/** True when GC must leave this directory alone. Inconclusive counts as in use. */ +export function orcadLivenessBlocksGc(liveness: OrcadLiveness): boolean { + return liveness !== 'DEAD' +} + +export type OrcadReadinessParse = + | { state: 'ready'; readiness: ServeReadiness } + | { state: 'pending' } + | { state: 'malformed'; reason: string } + +/** + * Pull the `orca_server_ready` payload out of whatever the candidate has written so far. + * + * Why scan for the type tag rather than parsing the last line: stdout is a file being + * appended to, so a poll can catch a half-written line. A partial JSON line is `pending`, + * not `malformed` — reporting a parse failure for a race would fail deploys that were fine. + */ +export function parseOrcadReadinessOutput(raw: string): OrcadReadinessParse { + const lines = raw.split('\n') + let sawCandidate = false + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed.startsWith('{')) { + continue + } + sawCandidate = true + let parsed: unknown + try { + parsed = JSON.parse(trimmed) + } catch { + continue + } + if (typeof parsed !== 'object' || parsed === null) { + continue + } + const payload = parsed as { type?: unknown } + if (payload.type !== 'orca_server_ready') { + return { + state: 'malformed', + reason: `expected an orca_server_ready line, got type=${JSON.stringify(payload.type)}` + } + } + return { state: 'ready', readiness: toServeReadiness(payload as Record) } + } + return sawCandidate ? { state: 'pending' } : { state: 'pending' } +} + +function toServeReadiness(payload: Record): ServeReadiness { + return { + runtimeId: typeof payload.runtimeId === 'string' ? payload.runtimeId : '', + boundEndpoint: typeof payload.boundEndpoint === 'string' ? payload.boundEndpoint : null, + advertisedEndpoint: + typeof payload.advertisedEndpoint === 'string' ? payload.advertisedEndpoint : null, + managedWslCliReconciliation: + payload.managedWslCliReconciliation === 'pending' || + payload.managedWslCliReconciliation === 'failed' + ? payload.managedWslCliReconciliation + : 'settled', + pairing: payload.pairing as ServeReadiness['pairing'], + ...(payload.health ? { health: payload.health as ServeReadiness['health'] } : {}) + } +} diff --git a/src/main/ssh/orcad-remote-process-control.ts b/src/main/ssh/orcad-remote-process-control.ts new file mode 100644 index 00000000000..6a9038ea3d6 --- /dev/null +++ b/src/main/ssh/orcad-remote-process-control.ts @@ -0,0 +1,73 @@ +/** + * Stopping a running orcad on the host without taking its terminals with it. + * + * `SIGKILL` is absent on purpose. orcad's own escalation contract (`orcad-entry.ts`) is + * SIGTERM, then a second SIGTERM meaning "your deadline elapsed, exit now"; a kill skips the + * teardown that releases the instance lock and disconnects — rather than shuts down — the + * terminal daemon. The daemon is detached and would survive a kill, but a stop that leaves + * the lock file behind makes the successor refuse to start with + * `orcad_data_root_shared`, so the update turns into an outage for no gain. + */ +import { shellEscape } from './ssh-connection-utils' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { + assertPosixOrcadHost as assertPosixHost, + ORCAD_PID_FILENAME, + posixProcessAliveShellFunction +} from './orcad-remote-host-support' + +/** + * Signal the orcad recorded in a version dir and wait for it to go. + * + * `escalate` sends the second SIGTERM orcad reads as "exit immediately". Callers use it only + * after the first deadline elapses, so the two signals are never in the same command. + */ +export function stopOrcadCommand( + host: RemoteHostPlatform, + remoteInstallDir: string, + options: { waitSeconds: number } +): string { + assertPosixHost(host) + const pidFile = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_PID_FILENAME)) + return [ + posixProcessAliveShellFunction(), + `pid=$(cat ${pidFile} 2>/dev/null);`, + 'case "$pid" in "" | *[!0-9]* ) echo NO_PID; exit 0;; esac;', + 'orcad_alive "$pid" || { echo ALREADY_EXITED; exit 0; };', + 'kill -TERM "$pid" 2>/dev/null || { echo SIGNAL_FAILED; exit 0; };', + `i=0; while [ "$i" -lt ${options.waitSeconds} ]; do`, + 'orcad_alive "$pid" || { echo STOPPED; exit 0; };', + 'sleep 1; i=$((i + 1)); done;', + 'echo STILL_RUNNING' + ].join(' ') +} + +export type OrcadStopOutcome = + | 'stopped' + | 'already-exited' + | 'no-pid' + | 'still-running' + | 'signal-failed' + | 'unknown' + +export function parseOrcadStopOutcome(output: string): OrcadStopOutcome { + switch (output.trim().split('\n').pop()?.trim() ?? '') { + case 'STOPPED': + return 'stopped' + case 'ALREADY_EXITED': + return 'already-exited' + case 'NO_PID': + return 'no-pid' + case 'STILL_RUNNING': + return 'still-running' + case 'SIGNAL_FAILED': + return 'signal-failed' + default: + return 'unknown' + } +} + +/** True when the port is free and a successor may bind. */ +export function orcadStopFreedTheHost(outcome: OrcadStopOutcome): boolean { + return outcome === 'stopped' || outcome === 'already-exited' || outcome === 'no-pid' +} diff --git a/src/main/ssh/orcad-remote-rollback.test.ts b/src/main/ssh/orcad-remote-rollback.test.ts new file mode 100644 index 00000000000..59f3d46e330 --- /dev/null +++ b/src/main/ssh/orcad-remote-rollback.test.ts @@ -0,0 +1,206 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + execCommand: vi.fn(), + isUnconfirmedSshCommandTermination: () => false +})) +vi.mock('./ssh-connection-utils', () => ({ shellEscape: (s: string) => `'${s}'` })) +vi.mock('./ssh-relay-install-transfers', () => ({ + writeRelayFile: vi.fn().mockResolvedValue(undefined), + uploadRelayDirectory: vi.fn().mockResolvedValue(undefined) +})) + +import { execCommand } from './ssh-relay-deploy-helpers' +import { writeRelayFile } from './ssh-relay-install-transfers' +import { rollbackOrcad, type OrcadRollbackOptions } from './orcad-remote-rollback' +import { + emptyOrcadActivationRecord, + type OrcadActivationRecord +} from './orcad-activation-record' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import type { SshConnection } from './ssh-connection' + +const mockExec = vi.mocked(execCommand) +const ACTIVE = '0.2.0+bb01' +const TARGET = '0.1.0+aa01' +const BUILD_HASH = 'abc123def4567890' + +function record(overrides: Partial = {}): OrcadActivationRecord { + return { + ...emptyOrcadActivationRecord(), + active: ACTIVE, + previous: TARGET, + activatedAt: '2026-01-01T00:00:00.000Z', + snapshot: { + dirName: 'pre-0.2.0+bb01-1000', + takenBeforeVersion: ACTIVE, + readableByVersion: TARGET, + takenAt: '2026-01-01T00:00:00.000Z' + }, + ...overrides + } +} + +function readyLine(version: string): string { + return JSON.stringify({ + type: 'orca_server_ready', + schemaVersion: 1, + runtimeId: 'r1', + boundEndpoint: 'ws://127.0.0.1:7777', + advertisedEndpoint: null, + managedWslCliReconciliation: 'settled', + pairing: { available: false, reason: 'disabled_by_operator', guidance: 'n/a' }, + health: { + buildHash: BUILD_HASH, + buildVersion: version, + nodeVersion: '20.11.0', + nodeAbi: '115', + platform: 'linux', + arch: 'x64', + pid: 1, + terminalDaemon: { + state: 'live', + ownsFreshSessions: true, + pid: 2, + buildVersion: version, + entryPath: '/x/daemon-entry.js', + protocolVersion: 3, + selfTest: { ok: true, coverage: 'pty-spawn', verdict: 'healthy', durationMs: 5 } + } + } + }) +} + +function scriptHost(log: string[], overrides: { restore?: string } = {}): void { + mockExec.mockImplementation(async (_conn, command: string) => { + const text = String(command) + if (text.includes('state.tar') && text.includes('test -f') && !text.includes('tar -C')) { + return 'PRESENT' + } + if (text.includes('find ') && text.includes('stat')) { + return 'UNKNOWN' + } + if (text.includes('kill -TERM')) { + log.push(`stop:${text.includes(ACTIVE) ? ACTIVE : TARGET}`) + return 'STOPPED' + } + if (text.includes('tar -C') && text.includes('-xf')) { + log.push('restore') + return overrides.restore ?? 'RESTORED' + } + if (text.includes('nohup')) { + log.push(`launch:${text.includes(ACTIVE) ? ACTIVE : TARGET}`) + return '9999' + } + if (text.startsWith('cat ') && text.includes('.orcad-readiness')) { + return readyLine(TARGET) + } + return '' + }) +} + +function options(overrides: Partial = {}): OrcadRollbackOptions { + return { + conn: {} as SshConnection, + host: getRemoteHostPlatform('linux-x64'), + remoteHome: '/home/u', + record: record(), + nodePath: '/usr/bin/node', + userDataDir: '/home/u/.orca', + bindHost: '127.0.0.1', + port: 7777, + census: { liveSessions: 0, startedSinceActivation: 0 }, + targetBuildHash: BUILD_HASH, + readinessTimeoutMs: 50, + sleep: async () => {}, + now: () => new Date('2026-02-02T00:00:00.000Z'), + ...overrides + } +} + +describe('rollbackOrcad', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('stops, restores state, then starts the target — in that order', async () => { + const log: string[] = [] + scriptHost(log) + const result = await rollbackOrcad(options()) + expect(result).toMatchObject({ outcome: 'rolled-back', target: TARGET }) + // Restoring under a running orcad would replace the store beneath a process holding it; + // starting first would let the older build migrate the newer build's state. + expect(log).toEqual([`stop:${ACTIVE}`, 'restore', `launch:${TARGET}`]) + }) + + it('refuses before touching anything when terminals started after activation', async () => { + const log: string[] = [] + scriptHost(log) + const result = await rollbackOrcad( + options({ census: { liveSessions: 3, startedSinceActivation: 2 } }) + ) + expect(result).toMatchObject({ + outcome: 'refused', + code: 'orcad_rollback_orphans_live_terminals' + }) + expect(log).toEqual([]) + expect(vi.mocked(writeRelayFile)).not.toHaveBeenCalled() + }) + + it('refuses when the snapshot is gone from the host', async () => { + const log: string[] = [] + mockExec.mockImplementation(async (_conn, command: string) => + String(command).includes('state.tar') ? 'ABSENT' : '' + ) + const result = await rollbackOrcad(options()) + expect(result).toMatchObject({ outcome: 'refused', code: 'orcad_rollback_snapshot_missing' }) + expect(log).toEqual([]) + }) + + it('does not start the old build when the restore failed', async () => { + const log: string[] = [] + scriptHost(log, { restore: 'FAILED' }) + const result = await rollbackOrcad(options()) + expect(result).toMatchObject({ outcome: 'failed', code: 'orcad_rollback_restore_failed' }) + expect(log).toEqual([`stop:${ACTIVE}`, 'restore']) + expect(result.outcome === 'failed' && result.reason).toContain('Do NOT start the older build') + }) + + it('leaves the record naming the newer version when the target fails to come up', async () => { + const log: string[] = [] + scriptHost(log) + mockExec.mockImplementation(async (_conn, command: string) => { + const text = String(command) + if (text.includes('state.tar') && text.includes('test -f') && !text.includes('tar -C')) { + return 'PRESENT' + } + if (text.includes('kill -TERM')) { + return 'STOPPED' + } + if (text.includes('tar -C') && text.includes('-xf')) { + return 'RESTORED' + } + // The target never publishes readiness. + return '' + }) + const result = await rollbackOrcad(options()) + expect(result).toMatchObject({ outcome: 'failed', code: 'orcad_activation_no_readiness' }) + // Until the target is proven serving, `active` must still name the version an operator + // would have to bring back. + expect(vi.mocked(writeRelayFile)).not.toHaveBeenCalled() + }) + + it('records the rollback only after the target answers healthy', async () => { + const log: string[] = [] + scriptHost(log) + await rollbackOrcad(options()) + const written = vi + .mocked(writeRelayFile) + .mock.calls.find((call) => String(call[2]).endsWith('orcad-active.json')) + expect(JSON.parse(String(written?.[3]))).toMatchObject({ + active: TARGET, + previous: null, + snapshot: null + }) + }) +}) diff --git a/src/main/ssh/orcad-remote-rollback.ts b/src/main/ssh/orcad-remote-rollback.ts new file mode 100644 index 00000000000..165ccfc037f --- /dev/null +++ b/src/main/ssh/orcad-remote-rollback.ts @@ -0,0 +1,244 @@ +/** + * Going back to the previously active orcad. + * + * Rollback is a state operation, not a binary swap. The version dirs are immutable and both + * are still on disk, so pointing at the old one is trivial; what is not trivial is that both + * versions share ONE data root, outside either dir. A newer orcad migrates that root on load + * — and Orca's persisted state carries no schema version to migrate against, so the older + * build cannot be shown to read the result. Rollback therefore restores the pre-activation + * snapshot, and refuses when restoring it would orphan work (`assessOrcadRollback`). + * + * The order below is the whole safety argument: stop, then restore, then start. Restoring + * under a running orcad would replace the store beneath a process holding it open, and + * starting before restoring would let the old build migrate the new build's state — the + * failure this is meant to avoid, arrived at from the other side. + */ +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { ORCAD_INSTALL_MODEL } from './remote-install-model' +import { computeRemoteInstallDir } from './ssh-relay-versioned-install' +import { writeRelayFile } from './ssh-relay-install-transfers' +import { RELAY_REMOTE_DIR } from './relay-protocol' +import { + ORCAD_STATE_SNAPSHOT_DIR, + serializeOrcadActivationRecord, + withRolledBackVersion, + type OrcadActivationRecord +} from './orcad-activation-record' +import { assessOrcadRollback, type OrcadTerminalCensus } from './orcad-update-plan' +import { evaluateOrcadActivation, type OrcadActivationVerdict } from './orcad-activation-gate' +import { + ORCAD_LOG_FILENAME, + orcadLaunchCommand, + parseOrcadReadinessOutput, + readOrcadReadinessCommand +} from './orcad-remote-launch' +import { + newestStateMtimeCommand, + parseNewestStateMtimeSeconds, + parseOrcadSnapshotRestore, + probeOrcadStateSnapshotCommand, + restoreOrcadStateSnapshotCommand +} from './orcad-state-snapshot' +import { + orcadStopFreedTheHost, + parseOrcadStopOutcome, + stopOrcadCommand +} from './orcad-remote-process-control' +import { orcadActivationPath } from './orcad-activation-record-store' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' + +export type OrcadRollbackOptions = { + conn: SshConnection + host: RemoteHostPlatform + remoteHome: string + record: OrcadActivationRecord + nodePath: string + userDataDir: string + bindHost: string + port: number + census: OrcadTerminalCensus + /** Expected build hash of the rollback target, from the client's copy of those bytes. */ + targetBuildHash: string + readinessTimeoutMs?: number + now?: () => Date + sleep?: (ms: number) => Promise + signal?: AbortSignal +} + +export type OrcadRollbackResult = + | { outcome: 'rolled-back'; target: string; discarded: string[]; verdict: OrcadActivationVerdict } + | { outcome: 'refused'; code: string; reason: string } + | { outcome: 'failed'; code: string; reason: string } + +const DEFAULT_READINESS_TIMEOUT_MS = 90_000 +const READINESS_POLL_MS = 500 +const STOP_WAIT_SECONDS = 20 + +function exec(options: OrcadRollbackOptions, command: string): Promise { + return execCommand(options.conn, command, { + wrapCommand: options.host.commandDialect !== 'powershell', + signal: options.signal + }) +} + +function snapshotDirPath(options: OrcadRollbackOptions, dirName: string): string { + return joinRemotePath( + options.host, + options.remoteHome, + RELAY_REMOTE_DIR, + ORCAD_STATE_SNAPSHOT_DIR, + dirName + ) +} + +/** Has the store been written since activation? `null` when it cannot be established. */ +async function readStateWritesSinceActivation( + options: OrcadRollbackOptions +): Promise { + if (!options.record.activatedAt) { + return null + } + const activatedAtSeconds = Math.floor(Date.parse(options.record.activatedAt) / 1000) + if (!Number.isFinite(activatedAtSeconds)) { + return null + } + const newest = parseNewestStateMtimeSeconds( + await exec(options, newestStateMtimeCommand(options.host, options.userDataDir)).catch(() => '') + ) + return newest === null ? null : newest >= activatedAtSeconds +} + +export async function rollbackOrcad( + options: OrcadRollbackOptions +): Promise { + const now = options.now ?? ((): Date => new Date()) + const snapshotPresent = options.record.snapshot + ? ( + await exec( + options, + probeOrcadStateSnapshotCommand( + options.host, + snapshotDirPath(options, options.record.snapshot.dirName) + ) + ).catch(() => 'ABSENT') + ).trim() === 'PRESENT' + : false + + const safety = assessOrcadRollback({ + record: options.record, + snapshotPresent, + census: options.census, + stateWritesSinceActivation: await readStateWritesSinceActivation(options) + }) + if (safety.safety === 'unsafe') { + return { outcome: 'refused', code: safety.code, reason: safety.reason } + } + + if (options.record.active) { + const outgoingDir = computeRemoteInstallDir( + ORCAD_INSTALL_MODEL, + options.remoteHome, + options.record.active + ) + const stopped = parseOrcadStopOutcome( + await exec( + options, + stopOrcadCommand(options.host, outgoingDir, { waitSeconds: STOP_WAIT_SECONDS }) + ) + ) + if (!orcadStopFreedTheHost(stopped)) { + return { + outcome: 'failed', + code: 'orcad_rollback_stop_incomplete', + reason: + `orcad ${options.record.active} did not exit within ${STOP_WAIT_SECONDS}s of SIGTERM ` + + `(${stopped}). Nothing was restored — the store is untouched and the host is still ` + + 'serving the version you tried to leave.' + } + } + } + + // Why between stop and start: the store must be replaced while no orcad holds it, and + // before the older build gets a chance to migrate the newer build's state. + const restored = parseOrcadSnapshotRestore( + await exec( + options, + restoreOrcadStateSnapshotCommand( + options.host, + options.userDataDir, + // Guarded by `assessOrcadRollback`: `unsafe` covers a missing snapshot. + snapshotDirPath(options, options.record.snapshot?.dirName ?? '') + ) + ).catch(() => 'FAILED') + ) + if (restored !== 'restored') { + return { + outcome: 'failed', + code: 'orcad_rollback_restore_failed', + reason: + `The pre-activation snapshot could not be restored (${restored}). orcad is stopped and ` + + 'the data root may be partially replaced. Do NOT start the older build against it; ' + + `re-deploy ${options.record.active ?? 'the newer version'}, which can read what is there.` + } + } + + const targetDir = computeRemoteInstallDir( + ORCAD_INSTALL_MODEL, + options.remoteHome, + safety.target + ) + await exec( + options, + orcadLaunchCommand(options.host, { + remoteInstallDir: targetDir, + nodePath: options.nodePath, + fullVersion: safety.target, + userDataDir: options.userDataDir, + bindHost: options.bindHost, + port: options.port + }) + ) + const deadline = Date.now() + (options.readinessTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS) + const sleep = options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))) + let parsed = parseOrcadReadinessOutput('') + while (Date.now() < deadline && parsed.state === 'pending') { + options.signal?.throwIfAborted() + parsed = parseOrcadReadinessOutput( + await exec(options, readOrcadReadinessCommand(options.host, targetDir)) + ) + if (parsed.state === 'pending') { + await sleep(READINESS_POLL_MS) + } + } + const verdict = evaluateOrcadActivation(parsed.state === 'ready' ? parsed.readiness : null, { + buildHash: options.targetBuildHash, + fullVersion: safety.target + }) + if (verdict.decision === 'reject') { + return { + outcome: 'failed', + code: verdict.code, + reason: + `The rollback target ${safety.target} did not come up healthy: ${verdict.reason} The ` + + `store has been restored to its pre-activation state. Its stderr is at ` + + `${joinRemotePath(options.host, targetDir, ORCAD_LOG_FILENAME)}.` + } + } + + // Why the record is written last: until the target is proven serving, `active` still names + // the version an operator would need to bring back, and `previous` still names this target. + await writeRelayFile( + options.conn, + options.host, + orcadActivationPath(options.host, options.remoteHome), + serializeOrcadActivationRecord(withRolledBackVersion(options.record, now())), + { signal: options.signal } + ) + return { + outcome: 'rolled-back', + target: safety.target, + discarded: safety.safety === 'lossy' ? safety.discards : [], + verdict + } +} diff --git a/src/main/ssh/orcad-remote-shell-commands.integration.test.ts b/src/main/ssh/orcad-remote-shell-commands.integration.test.ts new file mode 100644 index 00000000000..d1f8ac0048f --- /dev/null +++ b/src/main/ssh/orcad-remote-shell-commands.integration.test.ts @@ -0,0 +1,189 @@ +/** + * Runs the generated POSIX commands through a real `/bin/sh`. + * + * The unit tests assert on command *text*, which is exactly the kind of test that stays + * green while the shell it produces does not work — a quoting slip, a `case` pattern that + * never matches, a `tar` invocation that silently captures nothing. These run the strings. + */ +import { execFileSync, spawn } from 'node:child_process' +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 } from 'vitest' + +import { + orcadLivenessProbeCommand, + ORCAD_PID_FILENAME, + parseOrcadLiveness +} from './orcad-remote-launch' +import { parseOrcadStopOutcome, stopOrcadCommand } from './orcad-remote-process-control' +import { + captureOrcadStateSnapshotCommand, + newestStateMtimeCommand, + parseNewestStateMtimeSeconds, + parseOrcadSnapshotCapture, + parseOrcadSnapshotRestore, + probeOrcadStateSnapshotCommand, + restoreOrcadStateSnapshotCommand +} from './orcad-state-snapshot' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const host = getRemoteHostPlatform('linux-x64') +let root = '' +let dataDir = '' +let snapshotDir = '' +let versionDir = '' + +function sh(command: string): string { + return execFileSync('/bin/sh', ['-c', command], { encoding: 'utf8' }) +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orcad-shell-')) + dataDir = join(root, '.orca') + snapshotDir = join(root, 'snapshots', 'pre-0.2.0+bb01-1000') + versionDir = join(root, '.orca-remote', 'orcad-0.2.0+bb01') + mkdirSync(join(dataDir, 'profiles', 'p1'), { recursive: true }) + mkdirSync(join(dataDir, 'daemon'), { recursive: true }) + mkdirSync(versionDir, { recursive: true }) + writeFileSync(join(dataDir, 'orca-profile-index.json'), '{"v":"before"}') + writeFileSync(join(dataDir, 'profiles', 'p1', 'orca-data.json'), '{"repos":"before"}') + writeFileSync(join(dataDir, 'daemon', 'daemon.sock.token'), 'live-daemon-token') +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +describe('state snapshot commands, run for real', () => { + it('captures, then restores state the newer build overwrote', () => { + expect( + parseOrcadSnapshotCapture(sh(captureOrcadStateSnapshotCommand(host, dataDir, snapshotDir))) + ).toBe('captured') + expect(sh(probeOrcadStateSnapshotCommand(host, snapshotDir)).trim()).toBe('PRESENT') + + // The new version migrates the store and adds a file of its own. + writeFileSync(join(dataDir, 'orca-profile-index.json'), '{"v":"migrated"}') + writeFileSync(join(dataDir, 'profiles', 'p1', 'new-build-only.json'), '{}') + + expect( + parseOrcadSnapshotRestore(sh(restoreOrcadStateSnapshotCommand(host, dataDir, snapshotDir))) + ).toBe('restored') + expect(readFileSync(join(dataDir, 'orca-profile-index.json'), 'utf8')).toBe('{"v":"before"}') + // Removed before extraction, so the older build never sees a file it cannot interpret. + expect(() => readFileSync(join(dataDir, 'profiles', 'p1', 'new-build-only.json'))).toThrow() + }) + + it('leaves the live daemon runtime dir untouched through capture and restore', () => { + sh(captureOrcadStateSnapshotCommand(host, dataDir, snapshotDir)) + // The daemon is running across the rollback and rewrites its token; a restore that + // reached /daemon would break the fence that keeps its terminals adoptable. + writeFileSync(join(dataDir, 'daemon', 'daemon.sock.token'), 'token-after-restart') + sh(restoreOrcadStateSnapshotCommand(host, dataDir, snapshotDir)) + expect(readFileSync(join(dataDir, 'daemon', 'daemon.sock.token'), 'utf8')).toBe( + 'token-after-restart' + ) + }) + + it('reports EMPTY on a data root with nothing to lose, instead of an archive of nothing', () => { + const emptyRoot = join(root, 'fresh') + mkdirSync(emptyRoot) + expect( + parseOrcadSnapshotCapture(sh(captureOrcadStateSnapshotCommand(host, emptyRoot, snapshotDir))) + ).toBe('empty') + expect(sh(probeOrcadStateSnapshotCommand(host, snapshotDir)).trim()).toBe('ABSENT') + }) + + it('reports MISSING rather than claiming a restore it did not perform', () => { + expect( + parseOrcadSnapshotRestore( + sh(restoreOrcadStateSnapshotCommand(host, dataDir, join(root, 'nope'))) + ) + ).toBe('missing') + }) + + it('reads a real mtime for the store', () => { + const seconds = parseNewestStateMtimeSeconds(sh(newestStateMtimeCommand(host, dataDir))) + expect(seconds).toBeGreaterThan(1_600_000_000) + expect(seconds).toBeLessThanOrEqual(Math.floor(Date.now() / 1000) + 5) + }) + + it('survives a data root whose path contains a quote and a space', () => { + const nasty = join(root, `it's a dir`) + mkdirSync(join(nasty, 'profiles'), { recursive: true }) + writeFileSync(join(nasty, 'orca-profile-index.json'), '{"v":"quoted"}') + expect( + parseOrcadSnapshotCapture(sh(captureOrcadStateSnapshotCommand(host, nasty, snapshotDir))) + ).toBe('captured') + writeFileSync(join(nasty, 'orca-profile-index.json'), '{"v":"changed"}') + expect( + parseOrcadSnapshotRestore(sh(restoreOrcadStateSnapshotCommand(host, nasty, snapshotDir))) + ).toBe('restored') + expect(readFileSync(join(nasty, 'orca-profile-index.json'), 'utf8')).toBe('{"v":"quoted"}') + }) +}) + +describe('liveness and stop commands, run for real', () => { + it('reports UNKNOWN with no pid file, and DEAD for a pid that has exited', () => { + expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('UNKNOWN') + writeFileSync(join(versionDir, ORCAD_PID_FILENAME), 'not-a-pid') + expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('UNKNOWN') + // A pid that has certainly exited: our own `sh` child from the line above. + const exited = Number(sh('sh -c "echo $$"').trim()) + writeFileSync(join(versionDir, ORCAD_PID_FILENAME), String(exited)) + expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('DEAD') + }) + + it('reports LIVE for a running process and stops it with SIGTERM', async () => { + const child = spawn('/bin/sh', ['-c', 'sleep 30'], { stdio: 'ignore' }) + try { + writeFileSync(join(versionDir, ORCAD_PID_FILENAME), String(child.pid)) + expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('LIVE') + + const exited = new Promise((resolve) => + child.once('exit', (_code, signal) => resolve(signal)) + ) + expect(parseOrcadStopOutcome(sh(stopOrcadCommand(host, versionDir, { waitSeconds: 10 })))).toBe( + 'stopped' + ) + expect(await exited).toBe('SIGTERM') + expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('DEAD') + } finally { + child.kill('SIGKILL') + } + }) + + // `kill -0` succeeds on a zombie, so a probe built on it alone calls an exited process + // live: the stop loop would time out on a process that is already gone, and GC would keep + // a dead version dir forever. Verified as a real macOS behaviour, not a hypothetical. + it('reports a zombie as DEAD, not as a running process', () => { + const child = spawn('/bin/sh', ['-c', 'exit 0'], { stdio: 'ignore' }) + try { + writeFileSync(join(versionDir, ORCAD_PID_FILENAME), String(child.pid)) + // Block the event loop so Node never reaps it; the process is now a zombie. + sh('sleep 1') + expect(sh(`ps -o stat= -p ${child.pid} || echo GONE`).trim()).toMatch(/^Z/) + expect(sh(`kill -0 ${child.pid} 2>/dev/null && echo LIVE || echo DEAD`).trim()).toBe('LIVE') + expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('DEAD') + expect( + parseOrcadStopOutcome(sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1 }))) + ).toBe('already-exited') + } finally { + child.unref() + } + }) + + it('reports ALREADY_EXITED for a stale pid file rather than signalling a stranger', () => { + const exited = Number(sh('sh -c "echo $$"').trim()) + writeFileSync(join(versionDir, ORCAD_PID_FILENAME), String(exited)) + expect(parseOrcadStopOutcome(sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1 })))).toBe( + 'already-exited' + ) + }) + + it('reports NO_PID when the version dir was never launched', () => { + expect(parseOrcadStopOutcome(sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1 })))).toBe( + 'no-pid' + ) + }) +}) diff --git a/src/main/ssh/orcad-state-snapshot.test.ts b/src/main/ssh/orcad-state-snapshot.test.ts new file mode 100644 index 00000000000..0c50dc55fdb --- /dev/null +++ b/src/main/ssh/orcad-state-snapshot.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' + +import { + ORCAD_SNAPSHOT_EXCLUDED, + ORCAD_SNAPSHOT_MEMBERS, + captureOrcadStateSnapshotCommand, + newestStateMtimeCommand, + orcadSnapshotDirName, + parseNewestStateMtimeSeconds, + parseOrcadSnapshotCapture, + parseOrcadSnapshotRestore, + restoreOrcadStateSnapshotCommand +} from './orcad-state-snapshot' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const posix = getRemoteHostPlatform('linux-x64') +const windows = getRemoteHostPlatform('win32-x64') +const ROOT = '/home/u/.orca' +const SNAP = '/home/u/.orca-remote/orcad-state-snapshots/pre-0.2.0+bb01-1000' + +describe('capturing the pre-activation snapshot', () => { + it('captures the profile state a rollback needs', () => { + const command = captureOrcadStateSnapshotCommand(posix, ROOT, SNAP) + for (const member of ORCAD_SNAPSHOT_MEMBERS) { + expect(command).toContain(`'${member}'`) + } + }) + + // The live daemon owns /daemon and outlives every restart. Restoring a stale copy of + // its socket, PID record and token would break the fence that keeps its terminals adoptable. + it.each(ORCAD_SNAPSHOT_EXCLUDED)('never captures %s', (excluded) => { + expect(captureOrcadStateSnapshotCommand(posix, ROOT, SNAP)).not.toContain(`'${excluded}'`) + }) + + it.each(ORCAD_SNAPSHOT_EXCLUDED)('never removes or restores over %s', (excluded) => { + expect(restoreOrcadStateSnapshotCommand(posix, ROOT, SNAP)).not.toContain(`'${excluded}'`) + }) + + it('writes the archive under a temp name and renames, so a killed deploy leaves no torn tar', () => { + const command = captureOrcadStateSnapshotCommand(posix, ROOT, SNAP) + expect(command).toContain('.partial') + expect(command.indexOf('tar -C')).toBeLessThan(command.indexOf('mv ')) + }) + + it.each([ + ['CAPTURED', 'captured'], + ['EMPTY', 'empty'], + ['tar: broken', 'failed'], + ['', 'failed'] + ])('parses %s as %s', (output, expected) => { + expect(parseOrcadSnapshotCapture(output)).toBe(expected) + }) + + it('keys the snapshot dir on both version and time, so a retry cannot overwrite one', () => { + expect(orcadSnapshotDirName('0.2.0+bb01', 1000)).not.toBe( + orcadSnapshotDirName('0.2.0+bb01', 2000) + ) + }) +}) + +describe('restoring the snapshot', () => { + it('clears the members before extracting, so files the new build added do not survive', () => { + const command = restoreOrcadStateSnapshotCommand(posix, ROOT, SNAP) + expect(command.indexOf('rm -rf')).toBeLessThan(command.indexOf('tar -C')) + }) + + it('reports a missing archive instead of extracting nothing and claiming success', () => { + expect(restoreOrcadStateSnapshotCommand(posix, ROOT, SNAP)).toContain('echo MISSING') + expect(parseOrcadSnapshotRestore('MISSING')).toBe('missing') + expect(parseOrcadSnapshotRestore('RESTORED')).toBe('restored') + expect(parseOrcadSnapshotRestore('FAILED')).toBe('failed') + }) +}) + +describe('detecting writes since activation', () => { + it.each([ + ['1700000000', 1_700_000_000], + ['UNKNOWN', null], + ['', null] + ])('parses %s', (output, expected) => { + expect(parseNewestStateMtimeSeconds(output)).toBe(expected) + }) + + it('looks at the same members the snapshot covers', () => { + const command = newestStateMtimeCommand(posix, ROOT) + for (const member of ORCAD_SNAPSHOT_MEMBERS) { + expect(command).toContain(`'${member}'`) + } + }) +}) + +describe('Windows hosts', () => { + it.each([ + ['capture', () => captureOrcadStateSnapshotCommand(windows, ROOT, SNAP)], + ['restore', () => restoreOrcadStateSnapshotCommand(windows, ROOT, SNAP)], + ['mtime', () => newestStateMtimeCommand(windows, ROOT)] + ])('refuses %s rather than emitting a POSIX command', (_label, build) => { + expect(build).toThrow('orcad to a Windows host is not implemented') + }) +}) diff --git a/src/main/ssh/orcad-state-snapshot.ts b/src/main/ssh/orcad-state-snapshot.ts new file mode 100644 index 00000000000..78ad8b47d2a --- /dev/null +++ b/src/main/ssh/orcad-state-snapshot.ts @@ -0,0 +1,173 @@ +/** + * The pre-activation copy of shared profile state that makes rollback sound. + * + * `docs/design/shipping-orcad.html` §04's state-schema row asks for "backward-readable + * migrations or a pre-activation snapshot". Only the second is available here, and not as a + * preference: Orca's persisted state carries **no schema version**. Migrations are cohort + * and shape heuristics that run on load and rewrite in place, and the load path rebuilds + * `settings` and `ui` from known fields — so a newer build's nested additions are silently + * dropped by an older one rather than rejected. There is nothing to compare and nothing that + * fails loudly, which rules out proving backward-readability and leaves the snapshot. + * + * What is snapshotted is deliberately narrow. `/daemon` is EXCLUDED: it holds the live + * daemon's socket, PID record and auth token, and that daemon outlives every orcad restart + * by design. Restoring a stale copy of it over a running daemon would break the endpoint + * fence that keeps its terminals adoptable — turning a rollback into the exact terminal + * massacre the daemon exists to prevent. + */ +import { shellEscape } from './ssh-connection-utils' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { assertPosixOrcadHost as assertPosixHost } from './orcad-remote-host-support' + +/** + * Root-relative paths a rollback needs restored. Everything else under the data root is + * either regenerable, or owned by a process that survives the rollback. + */ +export const ORCAD_SNAPSHOT_MEMBERS = [ + 'orca-profile-index.json', + // Pre-profiles layout; still read as a migration source. + 'orca-data.json', + 'profiles' +] as const + +/** Never captured and never restored — see the module comment. */ +export const ORCAD_SNAPSHOT_EXCLUDED = ['daemon', 'logs'] as const + +/** + * The member names go into the command unquoted (see `captureOrcadStateSnapshotCommand`), so + * they must be inert. They are compile-time constants; this catches the edit that adds one + * with a space or a metacharacter in it. + */ +function assertPlainMemberName(member: string): string { + if (!/^[A-Za-z0-9._-]+$/.test(member)) { + throw new Error(`Unsafe orcad snapshot member name: ${JSON.stringify(member)}`) + } + return member +} + +export function orcadSnapshotDirName(fullVersion: string, takenAtMs: number): string { + // Why the version and the timestamp: two activations of one version (a re-deploy after a + // rejected activation) must not overwrite each other's snapshot. + return `pre-${fullVersion}-${takenAtMs}` +} + +/** + * Capture the snapshot, or report why there is nothing to capture. + * + * Prints `CAPTURED`, or `EMPTY` when the data root holds none of the members — a first-ever + * deployment, where there is no state to lose and therefore no snapshot to take. `EMPTY` is + * reported rather than fabricating an empty archive, because a rollback that "restored" an + * empty archive would wipe a root that had filled up in between. + */ +export function captureOrcadStateSnapshotCommand( + host: RemoteHostPlatform, + userDataDir: string, + snapshotDir: string +): string { + assertPosixHost(host) + const root = shellEscape(userDataDir) + const dir = shellEscape(snapshotDir) + const archive = shellEscape(joinRemotePath(host, snapshotDir, 'state.tar')) + const memberTests = ORCAD_SNAPSHOT_MEMBERS.map( + // Why the accumulated name is NOT quoted: `$members` is re-split by the shell before it + // reaches tar, so a quoted name arrives as a literal `'profiles'` that tar cannot stat. + // `assertPlainMemberName` is what makes leaving them bare safe. + (member) => + `[ -e ${root}/${shellEscape(member)} ] && members="$members ${assertPlainMemberName(member)}";` + ).join(' ') + return [ + `members=;`, + memberTests, + 'if [ -z "$members" ]; then echo EMPTY; else', + `mkdir -p ${dir} && umask 077 &&`, + // Why a temp name then mv: a deploy killed mid-tar must not leave a truncated archive + // that a later rollback would happily restore. + `tar -C ${root} -cf ${archive}.partial $members && mv ${archive}.partial ${archive} &&`, + 'echo CAPTURED; fi' + ].join(' ') +} + +export type OrcadSnapshotCapture = 'captured' | 'empty' | 'failed' + +export function parseOrcadSnapshotCapture(output: string): OrcadSnapshotCapture { + const value = output.trim().split('\n').pop()?.trim() + if (value === 'CAPTURED') { + return 'captured' + } + return value === 'EMPTY' ? 'empty' : 'failed' +} + +export function probeOrcadStateSnapshotCommand( + host: RemoteHostPlatform, + snapshotDir: string +): string { + assertPosixHost(host) + const archive = shellEscape(joinRemotePath(host, snapshotDir, 'state.tar')) + return `test -f ${archive} && echo PRESENT || echo ABSENT` +} + +/** + * Restore the snapshot over the data root. + * + * Two things make this safe to run: the members are removed before extraction (so a file the + * new version added is gone rather than half-shadowed), and neither the removal nor the + * extraction can reach `/daemon`, because the member list never names it. + * + * The caller must have stopped orcad first. This does not check — it cannot, from a shell — + * so `orcad-remote-deploy.ts` owns that ordering. + */ +export function restoreOrcadStateSnapshotCommand( + host: RemoteHostPlatform, + userDataDir: string, + snapshotDir: string +): string { + assertPosixHost(host) + const root = shellEscape(userDataDir) + const archive = shellEscape(joinRemotePath(host, snapshotDir, 'state.tar')) + const removals = ORCAD_SNAPSHOT_MEMBERS.map( + (member) => `rm -rf ${root}/${shellEscape(member)};` + ).join(' ') + return [ + `test -f ${archive} || { echo MISSING; exit 0; };`, + `test -d ${root} || mkdir -p ${root};`, + removals, + `tar -C ${root} -xf ${archive} && echo RESTORED || echo FAILED` + ].join(' ') +} + +export type OrcadSnapshotRestore = 'restored' | 'missing' | 'failed' + +export function parseOrcadSnapshotRestore(output: string): OrcadSnapshotRestore { + const value = output.trim().split('\n').pop()?.trim() + if (value === 'RESTORED') { + return 'restored' + } + return value === 'MISSING' ? 'missing' : 'failed' +} + +/** + * Has the shared store been written since `activatedAt`? + * + * Prints the newest mtime (epoch seconds) across the snapshot members, or `UNKNOWN`. The + * caller compares; an `UNKNOWN` becomes `null`, which `assessOrcadRollback` treats as "yes, + * assume writes". + */ +export function newestStateMtimeCommand(host: RemoteHostPlatform, userDataDir: string): string { + assertPosixHost(host) + const root = shellEscape(userDataDir) + const paths = ORCAD_SNAPSHOT_MEMBERS.map((member) => `${root}/${shellEscape(member)}`).join(' ') + return [ + `newest=$(find ${paths} -type f -exec stat -c %Y {} + 2>/dev/null ||`, + `find ${paths} -type f -exec stat -f %m {} + 2>/dev/null);`, + 'if [ -z "$newest" ]; then echo UNKNOWN; else', + `echo "$newest" | sort -n | tail -1; fi` + ].join(' ') +} + +export function parseNewestStateMtimeSeconds(output: string): number | null { + const value = output.trim().split('\n').pop()?.trim() + if (!value || !/^\d+$/.test(value)) { + return null + } + return Number.parseInt(value, 10) +} diff --git a/src/main/ssh/orcad-update-plan.test.ts b/src/main/ssh/orcad-update-plan.test.ts new file mode 100644 index 00000000000..5d90c597e3c --- /dev/null +++ b/src/main/ssh/orcad-update-plan.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest' + +import { assessOrcadRollback, planOrcadUpdate } from './orcad-update-plan' +import { + emptyOrcadActivationRecord, + type OrcadActivationRecord, + type OrcadStateSnapshot +} from './orcad-activation-record' + +const SNAPSHOT: OrcadStateSnapshot = { + dirName: 'pre-0.2.0+bb01-1000', + takenBeforeVersion: '0.2.0+bb01', + readableByVersion: '0.1.0+aa01', + takenAt: '2026-01-01T00:00:00.000Z' +} + +function record(overrides: Partial = {}): OrcadActivationRecord { + return { + ...emptyOrcadActivationRecord(), + active: '0.2.0+bb01', + previous: '0.1.0+aa01', + activatedAt: '2026-01-01T00:00:01.000Z', + snapshot: SNAPSHOT, + ...overrides + } +} + +describe('planOrcadUpdate', () => { + it('does nothing when the candidate is already active', () => { + const plan = planOrcadUpdate({ + record: record(), + candidateVersion: '0.2.0+bb01', + census: { liveSessions: 0, startedSinceActivation: 0 } + }) + expect(plan).toMatchObject({ action: 'noop' }) + }) + + it('defers rather than restarting a host with live terminals', () => { + const plan = planOrcadUpdate({ + record: record(), + candidateVersion: '0.3.0+cc01', + census: { liveSessions: 3, startedSinceActivation: 1 } + }) + expect(plan).toMatchObject({ action: 'defer', code: 'orcad_update_terminals_running' }) + expect(plan.action === 'defer' && plan.reason).toContain('would not kill them') + }) + + it('defers when the session count cannot be established', () => { + const plan = planOrcadUpdate({ + record: record(), + candidateVersion: '0.3.0+cc01', + census: { liveSessions: null, startedSinceActivation: null } + }) + expect(plan).toMatchObject({ + action: 'defer', + code: 'orcad_update_terminal_census_unavailable' + }) + }) + + it('plans a forced update with an unknown census as if terminals were live', () => { + const plan = planOrcadUpdate({ + record: record(), + candidateVersion: '0.3.0+cc01', + census: { liveSessions: null, startedSinceActivation: null }, + force: true + }) + expect(plan).toMatchObject({ action: 'proceed', preservesLiveDaemon: true }) + }) + + it('carries the daemon across a forced update with live terminals', () => { + const plan = planOrcadUpdate({ + record: record(), + candidateVersion: '0.3.0+cc01', + census: { liveSessions: 2, startedSinceActivation: 0 }, + force: true + }) + expect(plan).toMatchObject({ action: 'proceed', preservesLiveDaemon: true }) + }) + + it('replaces the daemon only when nothing is running under it', () => { + const plan = planOrcadUpdate({ + record: record(), + candidateVersion: '0.3.0+cc01', + census: { liveSessions: 0, startedSinceActivation: 0 } + }) + expect(plan).toMatchObject({ action: 'proceed', preservesLiveDaemon: false }) + }) +}) + +describe('assessOrcadRollback', () => { + it('is clean when the snapshot is intact and nothing happened since activation', () => { + const safety = assessOrcadRollback({ + record: record(), + snapshotPresent: true, + census: { liveSessions: 0, startedSinceActivation: 0 }, + stateWritesSinceActivation: false + }) + expect(safety).toMatchObject({ safety: 'clean', target: '0.1.0+aa01' }) + }) + + it('is lossy, and names what goes, once the store has been written since activation', () => { + const safety = assessOrcadRollback({ + record: record(), + snapshotPresent: true, + census: { liveSessions: 1, startedSinceActivation: 0 }, + stateWritesSinceActivation: true + }) + expect(safety).toMatchObject({ safety: 'lossy', target: '0.1.0+aa01' }) + expect(safety.safety === 'lossy' && safety.discards[0]).toContain('2026-01-01T00:00:01.000Z') + }) + + it('treats an unreadable store mtime as writes, not as a clean rollback', () => { + const safety = assessOrcadRollback({ + record: record(), + snapshotPresent: true, + census: { liveSessions: 0, startedSinceActivation: 0 }, + stateWritesSinceActivation: null + }) + expect(safety).toMatchObject({ safety: 'lossy' }) + }) + + // The point past which rollback is unsafe: the first terminal created after activation. + it('refuses once a terminal started after activation, because restoring would orphan it', () => { + const safety = assessOrcadRollback({ + record: record(), + snapshotPresent: true, + census: { liveSessions: 4, startedSinceActivation: 1 }, + stateWritesSinceActivation: true + }) + expect(safety).toMatchObject({ + safety: 'unsafe', + code: 'orcad_rollback_orphans_live_terminals' + }) + expect(safety.safety === 'unsafe' && safety.reason).toContain('nothing would be able to') + }) + + it('refuses when the snapshot the record names is gone from the host', () => { + const safety = assessOrcadRollback({ + record: record(), + snapshotPresent: false, + census: { liveSessions: 0, startedSinceActivation: 0 }, + stateWritesSinceActivation: false + }) + expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_snapshot_missing' }) + expect(safety.safety === 'unsafe' && safety.reason).toContain('no schema version') + }) + + it('refuses when no snapshot was ever recorded', () => { + const safety = assessOrcadRollback({ + record: record({ snapshot: null }), + snapshotPresent: true, + census: { liveSessions: 0, startedSinceActivation: 0 }, + stateWritesSinceActivation: false + }) + expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_snapshot_missing' }) + }) + + it('refuses when the post-activation session count is unverifiable', () => { + const safety = assessOrcadRollback({ + record: record(), + snapshotPresent: true, + census: { liveSessions: 2, startedSinceActivation: null }, + stateWritesSinceActivation: false + }) + expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_census_unavailable' }) + }) + + it('refuses when there is no previous version to go back to', () => { + const safety = assessOrcadRollback({ + record: record({ previous: null }), + snapshotPresent: true, + census: { liveSessions: 0, startedSinceActivation: 0 }, + stateWritesSinceActivation: false + }) + expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_no_target' }) + }) +}) diff --git a/src/main/ssh/orcad-update-plan.ts b/src/main/ssh/orcad-update-plan.ts new file mode 100644 index 00000000000..2653956ebe5 --- /dev/null +++ b/src/main/ssh/orcad-update-plan.ts @@ -0,0 +1,234 @@ +/** + * When an update may restart orcad, and when going back is still sound. + * + * Two constraints shape everything here. + * + * **The daemon must outlive the restart.** orcad forks the terminal daemon and deliberately + * does not kill it on stop (`orcad-daemon-supervision.ts` uses `disconnectDaemon`, never + * `shutdownDaemon`). An update that killed it would destroy every terminal on the host — + * the thing the daemon exists to prevent. After an update the surviving daemon was forked + * from the OUTGOING bundle, so `daemon-init` sees an entry-path/version mismatch and takes + * its `shouldPreserveDaemonWithLiveSessions` branch: with live sessions it preserves the old + * daemon; at exactly zero it replaces it. Both are correct, and both mean the outgoing + * version's directory is still load-bearing. + * + * **The state root is shared across versions.** `~/.orca/` (or `$ORCA_USER_DATA`) is outside + * every version dir, and Orca's persisted state carries no schema version — migrations run + * on load and rewrite in place. So "is the old version able to read what the new one wrote" + * has no answer that can be computed. That is why rollback is defined against a + * pre-activation snapshot rather than against a version comparison. + */ +import type { OrcadActivationRecord } from './orcad-activation-record' + +export type OrcadTerminalCensus = { + /** + * Sessions the live daemon owns right now. `null` means the probe could not answer — + * never treated as zero, because loss of contact is not evidence of process death + * (docs/reference/ssh-execution-boundary.md). + */ + liveSessions: number | null + /** + * Of those, how many started at or after `record.activatedAt`. These are the sessions the + * pre-activation snapshot does not describe. + */ + startedSinceActivation: number | null +} + +export type OrcadUpdateDecision = + | { action: 'noop'; reason: string } + | { + action: 'proceed' + /** True when a live daemon will be carried across the restart rather than replaced. */ + preservesLiveDaemon: boolean + notes: string[] + } + | { action: 'defer'; code: OrcadUpdateDeferCode; reason: string } + +export type OrcadUpdateDeferCode = + | 'orcad_update_terminals_running' + | 'orcad_update_terminal_census_unavailable' + +/** + * Decide whether to restart orcad onto `candidateVersion`. + * + * Deferring on live terminals is a deliberate choice, not caution. The restart itself is + * non-destructive, but it leaves the host running a NEW orcad against an OLD daemon until + * every one of those terminals exits — a mixed pair whose duration the operator, not the + * deploy, should decide. `force` is how they decide it. + */ +export function planOrcadUpdate(input: { + record: OrcadActivationRecord + candidateVersion: string + census: OrcadTerminalCensus + force?: boolean +}): OrcadUpdateDecision { + if (input.record.active === input.candidateVersion) { + return { + action: 'noop', + reason: `${input.candidateVersion} is already the active version; nothing to restart.` + } + } + const { liveSessions } = input.census + if (liveSessions === null) { + if (!input.force) { + return { + action: 'defer', + code: 'orcad_update_terminal_census_unavailable', + reason: + 'The terminal daemon did not answer a session count, so this update cannot tell ' + + 'whether work is running on the host. Retry, or force the update knowing terminals ' + + 'may be mid-flight.' + } + } + return { + action: 'proceed', + // Why true: an unverifiable census must be planned for as if sessions exist. Assuming + // the daemon is replaceable is the assumption that destroys terminals. + preservesLiveDaemon: true, + notes: [ + 'Forced with an unverifiable session count. Planning as if terminals are live: the ' + + 'daemon will be preserved across the restart, and the outgoing version directory ' + + 'stays pinned against GC.' + ] + } + } + if (liveSessions > 0 && !input.force) { + return { + action: 'defer', + code: 'orcad_update_terminals_running', + reason: + `${liveSessions} terminal${liveSessions === 1 ? ' is' : 's are'} running on this host. ` + + 'The restart would not kill them — the daemon is preserved — but the host would run ' + + `orcad ${input.candidateVersion} against a daemon forked from ` + + `${input.record.active ?? 'the previous build'} until they all exit. Update when the ` + + 'host is idle, or force it.' + } + } + if (liveSessions > 0) { + return { + action: 'proceed', + preservesLiveDaemon: true, + notes: [ + `Forced with ${liveSessions} live terminal${liveSessions === 1 ? '' : 's'}. They survive ` + + 'the restart on the existing daemon; the outgoing version directory stays pinned ' + + 'against GC because that daemon was forked from it.' + ] + } + } + return { + action: 'proceed', + // Zero live sessions is the one case where daemon-init's freshness branch replaces the + // daemon, so nothing is carried across and nothing is lost. + preservesLiveDaemon: false, + notes: [ + 'No terminals are running, so the daemon is replaced by one forked from the new bundle.' + ] + } +} + +export type OrcadRollbackSafety = + | { safety: 'clean'; target: string; notes: string[] } + | { safety: 'lossy'; target: string; discards: string[] } + | { safety: 'unsafe'; code: OrcadRollbackUnsafeCode; reason: string } + +export type OrcadRollbackUnsafeCode = + | 'orcad_rollback_no_target' + | 'orcad_rollback_snapshot_missing' + | 'orcad_rollback_orphans_live_terminals' + | 'orcad_rollback_census_unavailable' + +/** + * How safe it is to switch back to `record.previous`. + * + * **The point past which rollback is unsafe is the first terminal created after + * activation.** Not the first state write, and not any schema comparison: + * + * - Rolling back means restoring the pre-activation snapshot, because there is no schema + * version to prove the old build can read what the new one wrote. + * - The snapshot predates activation, so it does not describe sessions created since. + * - The daemon survives the binary swap and still owns those sessions. After the restore, + * a live daemon holds PTYs that the restored store has no rows for: work that is running, + * that no client can reattach to, and that the host will report as neither `live` nor + * `exited` for any session anyone can name. + * + * Settings and UI churn written after activation are merely discarded, which is `lossy`. + * Orphaning running work is not something a deploy gets to do quietly, so it is `unsafe`. + */ +export function assessOrcadRollback(input: { + record: OrcadActivationRecord + /** Whether the snapshot named by the record is actually still on the host. */ + snapshotPresent: boolean + census: OrcadTerminalCensus + /** + * Whether the shared store has been written since activation, from its mtime against + * `record.activatedAt`. `null` means unknown, which is treated as "yes" — claiming a + * lossless rollback we cannot demonstrate is the failure mode, not the caution. + */ + stateWritesSinceActivation: boolean | null +}): OrcadRollbackSafety { + const target = input.record.previous + if (!target) { + return { + safety: 'unsafe', + code: 'orcad_rollback_no_target', + reason: + 'This host has no previous orcad version recorded, so there is nothing to roll back ' + + 'to. Deploy a known-good build instead.' + } + } + if (!input.record.snapshot || !input.snapshotPresent) { + return { + safety: 'unsafe', + code: 'orcad_rollback_snapshot_missing', + reason: + `The pre-activation state snapshot for ${input.record.active ?? 'the active version'} ` + + 'is gone, and Orca state carries no schema version that could prove the older build ' + + 'can read what the newer one migrated. Switching the binary back would hand ' + + `${target} a store it may not understand. Deploy forward instead.` + } + } + const { startedSinceActivation } = input.census + if (startedSinceActivation === null) { + return { + safety: 'unsafe', + code: 'orcad_rollback_census_unavailable', + reason: + 'The daemon did not answer how many of its terminals started after this version was ' + + 'activated, so a snapshot restore might orphan running work. Retry when the host is ' + + 'reachable.' + } + } + if (startedSinceActivation > 0) { + return { + safety: 'unsafe', + code: 'orcad_rollback_orphans_live_terminals', + reason: + `${startedSinceActivation} terminal${startedSinceActivation === 1 ? '' : 's'} started ` + + 'after this version was activated. The daemon survives the rollback and would keep ' + + 'owning them, but the restored snapshot predates them, so nothing would be able to ' + + 'reattach. Close them (or let them exit) and roll back then.' + } + } + if (input.stateWritesSinceActivation === false) { + return { + safety: 'clean', + target, + notes: [ + 'The pre-activation snapshot is intact, no terminals started since activation, and ' + + 'the store has not been written since. Restoring it changes nothing.' + ] + } + } + return { + safety: 'lossy', + target, + discards: [ + input.stateWritesSinceActivation === null + ? 'Any profile, settings and UI change written since activation — the store mtime ' + + 'could not be read, so assume there are some.' + : `Every profile, settings and UI change written since ${ + input.record.activatedAt ?? 'activation' + }, when ${input.record.active ?? 'the active version'} was activated.` + ] + } +} diff --git a/src/main/ssh/remote-install-coexistence.test.ts b/src/main/ssh/remote-install-coexistence.test.ts new file mode 100644 index 00000000000..ce844587d62 --- /dev/null +++ b/src/main/ssh/remote-install-coexistence.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' + +import { selectRemoteInstallModel } from './remote-install-coexistence' + +const BOTH_INSTALLED = ['relay-0.1.0+aa01', 'orcad-0.2.0+bb01', 'orcad-0.1.0+aa01'] + +describe('what a client does when it finds both models installed', () => { + it('uses the registered model and leaves the other install alone', () => { + const selection = selectRemoteInstallModel({ + registration: 'orcad-peer', + installedDirNames: BOTH_INSTALLED + }) + expect(selection).toMatchObject({ outcome: 'use', model: 'orcad' }) + expect(selection.outcome === 'use' && selection.coexisting).toEqual(['relay-0.1.0+aa01']) + expect(selection.outcome === 'use' && selection.note).toContain( + 'garbage-collects only its own namespace' + ) + }) + + it('does not switch model just because the other one is also on disk', () => { + const selection = selectRemoteInstallModel({ + registration: 'ssh-target', + installedDirNames: BOTH_INSTALLED + }) + expect(selection).toMatchObject({ outcome: 'use', model: 'relay' }) + expect(selection.outcome === 'use' && selection.coexisting).toEqual([ + 'orcad-0.2.0+bb01', + 'orcad-0.1.0+aa01' + ]) + }) + + it('picks the registered model even when only the other one is installed', () => { + // On-disk presence is diagnostic, never a vote: an orcad-registered host with only relay + // dirs is a first orcad deploy, not a reason to fall back to the relay. + const selection = selectRemoteInstallModel({ + registration: 'orcad-peer', + installedDirNames: ['relay-0.1.0+aa01'] + }) + expect(selection).toMatchObject({ outcome: 'use', model: 'orcad' }) + }) + + it('refuses a machine registered under both models', () => { + const selection = selectRemoteInstallModel({ + registration: 'both', + installedDirNames: BOTH_INSTALLED + }) + expect(selection).toMatchObject({ + outcome: 'refuse', + code: 'remote_host_registered_under_both_models' + }) + // The forbidden thing is two registrations, not two directories — say so, or a user will + // "fix" it by deleting an install that is serving someone. + expect(selection.outcome === 'refuse' && selection.reason).toContain( + 'Leaving both install directories on disk is fine' + ) + }) + + it('refuses to infer a model for an unregistered machine', () => { + const selection = selectRemoteInstallModel({ + registration: 'none', + installedDirNames: BOTH_INSTALLED + }) + expect(selection).toMatchObject({ + outcome: 'refuse', + code: 'remote_host_not_registered' + }) + }) + + it('says nothing when there is nothing coexisting', () => { + const selection = selectRemoteInstallModel({ + registration: 'orcad-peer', + installedDirNames: ['orcad-0.2.0+bb01'] + }) + expect(selection).toMatchObject({ outcome: 'use', model: 'orcad', note: null }) + }) +}) diff --git a/src/main/ssh/remote-install-coexistence.ts b/src/main/ssh/remote-install-coexistence.ts new file mode 100644 index 00000000000..a24ac36cfb1 --- /dev/null +++ b/src/main/ssh/remote-install-coexistence.ts @@ -0,0 +1,83 @@ +/** + * What a client does when it finds both a relay and an orcad installed on one host. + * + * `docs/design/shipping-orcad.html` §06 draws the line the boundary doc actually draws: + * two *directories* on disk are fine and permanent; two *registered targets* for one + * machine are forbidden, because that is what splits a machine's worktrees across two + * identities (`docs/reference/ssh-execution-boundary.md`). + * + * So the model is never inferred from the filesystem. It is decided by how the user + * registered the host, and the on-disk inventory is only ever diagnostic. Inferring it — + * "an orcad dir exists, so prefer orcad" — would let a GC pass, a half-finished install or + * a stale tree silently re-point a live connection at a different execution identity. + */ +import { inventoryRemoteInstallDirs, type RemoteInstallModelId } from './remote-install-model' + +/** + * How this host is registered in the client's own records. + * + * `both` is representable on purpose: it is a state a user can reach by adding an SSH + * target for a machine they have already paired, and the point of this module is to refuse + * it loudly instead of picking one. + */ +export type RemoteHostRegistration = 'ssh-target' | 'orcad-peer' | 'both' | 'none' + +export type RemoteInstallSelection = + | { + outcome: 'use' + model: RemoteInstallModelId + /** Other-model dirs present on this host. Left alone; never GC'd by the chosen model. */ + coexisting: string[] + /** Non-null when there is something an operator should know but nothing to refuse over. */ + note: string | null + } + | { + outcome: 'refuse' + code: 'remote_host_registered_under_both_models' | 'remote_host_not_registered' + reason: string + } + +/** + * Choose the execution model for a connection to a host, given its registration and + * whatever happens to be installed there. + */ +export function selectRemoteInstallModel(input: { + registration: RemoteHostRegistration + /** Raw directory names under `~/.orca-remote/`, as listed on the host. */ + installedDirNames: readonly string[] +}): RemoteInstallSelection { + if (input.registration === 'both') { + return { + outcome: 'refuse', + code: 'remote_host_registered_under_both_models', + reason: + 'This machine is registered both as an SSH target and as a paired orcad peer. One ' + + 'machine must have one execution identity, or its worktrees and terminals split ' + + 'across two owners that cannot see each other. Remove one registration. Leaving ' + + 'both install directories on disk is fine and expected.' + } + } + if (input.registration === 'none') { + return { + outcome: 'refuse', + code: 'remote_host_not_registered', + reason: + 'This machine has no execution model registered. Add it as an SSH target or pair it ' + + 'as an orcad peer; what is already installed on it does not decide which it is.' + } + } + const model: RemoteInstallModelId = input.registration === 'ssh-target' ? 'relay' : 'orcad' + const inventory = inventoryRemoteInstallDirs(input.installedDirNames) + const coexisting = model === 'relay' ? inventory.orcad : inventory.relay + return { + outcome: 'use', + model, + coexisting, + note: + coexisting.length > 0 + ? `This host also has ${coexisting.length} ${model === 'relay' ? 'orcad' : 'relay'} ` + + `install directory(ies) (${coexisting.join(', ')}). They are left untouched: each ` + + 'model garbage-collects only its own namespace.' + : null + } +} diff --git a/src/main/ssh/remote-install-gc.ts b/src/main/ssh/remote-install-gc.ts new file mode 100644 index 00000000000..0d649cd0b03 --- /dev/null +++ b/src/main/ssh/remote-install-gc.ts @@ -0,0 +1,286 @@ +/** + * The version-directory garbage collector, shared by the relay and orcad. + * + * It lives apart from `ssh-relay-versioned-install.ts` because it is the one piece both + * models run, and because the ownership rule below is the whole point of separating them: + * a pass only ever sees, and only ever deletes, directories belonging to `model`. + */ +import type { SshConnection } from './ssh-connection' +import { RELAY_REMOTE_DIR } from './relay-protocol' +import { execCommand } from './ssh-relay-deploy-helpers' +import { probeInstallLockExistsCommand } from './ssh-relay-install-lock-commands' +import { isRelayInstallLockStale, RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock' +import { + RELAY_INSTALL_MODEL, + remoteInstallGcPermits, + remoteInstallVersionDirRegex, + type RemoteInstallModel +} from './remote-install-model' +import { + isRelayGcClaimOwned, + releaseRelayGcClaimWithRetry, + tryAcquireRelayGcClaim +} from './ssh-relay-gc-claim' +import { cleanupRelayGcTombstones } from './ssh-relay-gc-tombstone' +import { + listRemoteInstallBaseDirsCommand, + MAX_RELAY_GC_LISTING_ENTRIES, + moveRemoteTreeCommand, + probeFileExistsCommand, + relayLivenessProbeCommand, + removeRemoteTreeCommand +} from './ssh-remote-commands' +import { + getRemoteHostPlatform, + isWindowsRemoteHost, + joinRemotePath, + remoteBasename, + type RemoteHostPlatform +} from './ssh-remote-platform' +import { windowsRelayPipePathsForSocketName } from './ssh-relay-endpoints' +import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' + +// Legacy relay dirs predate `.install-complete`; they need a liveness-only GC check so they +// eventually drain. There is no orcad equivalent — orcad has never shipped without one. +const LEGACY_RELAY_DIR_REGEX = /^relay-v\d+\.\d+\.\d+$/ +const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') + +function execHostCommand( + conn: SshConnection, + host: RemoteHostPlatform, + command: string +): Promise { + return execCommand(conn, command, { wrapCommand: host.commandDialect !== 'powershell' }) +} + +export type RemoteInstallGcOptions = { + windowsNodePath?: string + windowsSockNames?: string[] + /** + * Model-specific "someone is using this directory" probe. It must answer TRUE when + * inconclusive — an unanswered probe is never evidence a tree is idle. + */ + isDirLive: (dir: string) => Promise + /** + * Directories this pass must never remove even when idle and complete, named by directory + * (not absolute path). orcad passes its active and previous versions: the previous one is + * the rollback target, and GC'ing it turns a recoverable bad update into a re-deploy. + */ + pinnedDirNames?: readonly string[] +} + +/** + * Garbage-collect one model's old version directories. + * + * **GC ownership (design §06 falsifier 1):** a pass only ever sees, and only ever deletes, + * directories belonging to `model`. The remote listing is scoped by prefix, and + * `remoteInstallGcPermits` re-checks every candidate locally, so neither a widened glob nor + * a hand-rolled listing can make one model delete the other's live install. + */ +export async function gcOldRemoteInstallVersions( + conn: SshConnection, + model: RemoteInstallModel, + remoteHome: string, + currentDirAbsPath: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + options: RemoteInstallGcOptions +): Promise { + const baseDir = joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR) + const currentDirName = remoteBasename(currentDirAbsPath, host) + let listing: string + try { + listing = await execHostCommand( + conn, + host, + listRemoteInstallBaseDirsCommand(host, baseDir, model) + ) + } catch { + return + } + const entries = listing + .split('\n') + .map((s) => s.trim()) + .filter(Boolean) + .slice(0, MAX_RELAY_GC_LISTING_ENTRIES) + + await cleanupRelayGcTombstones(conn, baseDir, entries, host) + + const versionDirRegex = remoteInstallVersionDirRegex(model) + const pinned = new Set([currentDirName, ...(options.pinnedDirNames ?? [])]) + const candidates = entries + // Why re-check ownership after a prefix-scoped listing: this is the one line that stands + // between a parameterized GC and deleting the sibling model's live install. + .filter((name) => remoteInstallGcPermits(model, name)) + .filter((name) => versionDirRegex.test(name)) + .filter((name) => !pinned.has(name)) + + if (candidates.length === 0) { + return + } + + const removed: string[] = [] + const kept: string[] = [] + for (const name of candidates) { + const dir = joinRemotePath(host, baseDir, name) + try { + const safe = await isCandidateSafeToRemove(conn, model, dir, name, host, options) + if (!safe) { + kept.push(name) + continue + } + // Why: the claim is a sibling, so it survives moving/deleting the candidate and lets installers back out first. + const gcClaimToken = await tryAcquireRelayGcClaim(conn, dir, host) + if (!gcClaimToken) { + kept.push(name) + continue + } + let preserveGcClaim = false + let gcClaimReleaseNeeded = true + try { + // Recheck under the stable claim; installers probe it before and after creating their lock, closing both orders. + if (!(await isCandidateSafeToRemove(conn, model, dir, name, host, options))) { + kept.push(name) + continue + } + if (!(await isRelayGcClaimOwned(conn, dir, gcClaimToken, host))) { + kept.push(name) + continue + } + const tombstone = `${dir}.gc-tombstone.${process.pid}.${Date.now()}` + const moved = await execHostCommand(conn, host, moveRemoteTreeCommand(host, dir, tombstone)) + if (moved.trim() !== 'MOVED') { + kept.push(name) + continue + } + // Once renamed, a fresh install at the original path is isolated from the tombstone's deletion, so release the claim. + const release = await releaseRelayGcClaimWithRetry(conn, dir, gcClaimToken, host) + gcClaimReleaseNeeded = release === 'unknown' + await execHostCommand(conn, host, removeRemoteTreeCommand(host, tombstone)) + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + preserveGcClaim = true + } + throw err + } finally { + if (!preserveGcClaim && gcClaimReleaseNeeded) { + await releaseRelayGcClaimWithRetry(conn, dir, gcClaimToken, host) + } + } + removed.push(name) + } catch (err) { + console.warn( + `[${model.id}] GC failed for ${dir}: ${err instanceof Error ? err.message : String(err)}` + ) + kept.push(name) + } + } + + if (removed.length > 0) { + const keptSuffix = kept.length > 0 ? ` (kept: ${kept.join(', ')})` : '' + console.log( + `[${model.id}] GC: removed ${removed.length} stale version dir(s): ${removed.join(', ')}${keptSuffix}` + ) + } +} + +async function isCandidateSafeToRemove( + conn: SshConnection, + model: RemoteInstallModel, + dir: string, + name: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + options: RemoteInstallGcOptions +): Promise { + const isLegacy = model.id === 'relay' && LEGACY_RELAY_DIR_REGEX.test(name) + + const lockDir = joinRemotePath(host, dir, RELAY_INSTALL_LOCK_NAME) + let lockProbe: string + try { + lockProbe = await execHostCommand(conn, host, probeInstallLockExistsCommand(host, lockDir)) + } catch { + return false + } + const lockState = lockProbe.trim() + if (lockState !== 'OPEN' && lockState !== 'LOCKED') { + return false + } + const locked = lockState === 'LOCKED' + + if (locked) { + // Why: stale lock = crashed installer; finalize can leave a dir .install-complete yet locked (lock-rm failed), so it's reclaimable. + if (!(await isRelayInstallLockStale(conn, lockDir, host))) { + return false + } + process.stderr.write?.(`[${model.id}] GC: lock at ${lockDir} is stale; treating as recoverable\n`) + } + + // Legacy dirs predate .install-complete; skip the sentinel and rely on the live-socket probe alone. + if (!isLegacy) { + const completePath = joinRemotePath(host, dir, model.installCompleteFilename) + const completeProbe = await execHostCommand( + conn, + host, + probeFileExistsCommand(host, completePath) + ).catch(() => 'PARTIAL') + if (completeProbe.trim() !== 'COMPLETE') { + // Crashed-install partial; leave for the next deploy to recover. + return false + } + } + + return !(await options.isDirLive(dir)) +} + + +/** + * The relay's GC, bound to its own namespace and its own liveness probe (a live unix socket + * or Windows pipe inside the version dir). + */ +export async function gcOldRelayVersions( + conn: SshConnection, + remoteHome: string, + currentDirAbsPath: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + options?: { + windowsNodePath?: string + windowsSockNames?: string[] + } +): Promise { + await gcOldRemoteInstallVersions(conn, RELAY_INSTALL_MODEL, remoteHome, currentDirAbsPath, host, { + ...options, + isDirLive: (dir) => hasLiveRelaySocket(conn, dir, host, options) + }) +} + +async function hasLiveRelaySocket( + conn: SshConnection, + dir: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + options?: { + windowsNodePath?: string + windowsSockNames?: string[] + } +): Promise { + try { + // Why: `test -S` only — a connect-and-close probe would race with a daemon about to idle. + const windowsOptions = + isWindowsRemoteHost(host) && options?.windowsNodePath + ? { + nodePath: options.windowsNodePath, + pipePaths: (options.windowsSockNames ?? []).flatMap((sockName) => + windowsRelayPipePathsForSocketName(host, dir, sockName) + ) + } + : undefined + const out = await execHostCommand( + conn, + host, + relayLivenessProbeCommand(host, dir, windowsOptions) + ) + const state = out.trim() + return state !== 'DEAD' && state !== 'WAITING' + } catch { + // Why: an inconclusive liveness probe must never authorize deletion. + return true + } +} diff --git a/src/main/ssh/remote-install-model.test.ts b/src/main/ssh/remote-install-model.test.ts new file mode 100644 index 00000000000..5a80df8ff4f --- /dev/null +++ b/src/main/ssh/remote-install-model.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' + +import { + inventoryRemoteInstallDirs, + ORCAD_INSTALL_MODEL, + RELAY_INSTALL_MODEL, + remoteInstallDirName, + remoteInstallDirOwner, + remoteInstallGcPermits, + remoteInstallListingRegexSource, + remoteInstallVersionDirRegex +} from './remote-install-model' + +const RELAY_DIRS = ['relay-0.1.0+abcdef123456', 'relay-v0.1.0', 'relay-1.2.3'] +const ORCAD_DIRS = ['orcad-0.1.0+abcdef123456', 'orcad-v0.1.0', 'orcad-1.2.3'] + +describe('remote install namespace', () => { + it('names each model its own version dir', () => { + expect(remoteInstallDirName(RELAY_INSTALL_MODEL, '0.1.0+aa')).toBe('relay-0.1.0+aa') + expect(remoteInstallDirName(ORCAD_INSTALL_MODEL, '0.1.0+aa')).toBe('orcad-0.1.0+aa') + }) + + it('keeps the relay listing pattern byte-identical to the one it shipped with', () => { + // The literal that was hardcoded in `listRelayBaseDirsCommand` before it was + // parameterized. A drift here changes what an existing host's GC can see. + expect(remoteInstallListingRegexSource(RELAY_INSTALL_MODEL)).toBe( + String.raw`^relay-(v?[0-9]+\.[0-9]+\.[0-9]+(\+[0-9a-f]+)?)(\.gc-tombstone\.[0-9]+\.[0-9]+)?$` + ) + }) + + it('refuses a dir prefix that could escape a remote glob or quote', () => { + const injected = { ...RELAY_INSTALL_MODEL, dirPrefix: "relay'; rm -rf ~" } + expect(() => remoteInstallVersionDirRegex(injected)).toThrow('Unsafe remote install dir prefix') + }) +}) + +describe('GC ownership — each model collects only its own namespace', () => { + it.each(ORCAD_DIRS)('the relay never permits GC of %s', (dirName) => { + expect(remoteInstallDirOwner(dirName)).toBe('orcad') + expect(remoteInstallGcPermits(RELAY_INSTALL_MODEL, dirName)).toBe(false) + }) + + it.each(RELAY_DIRS)('orcad never permits GC of %s', (dirName) => { + expect(remoteInstallDirOwner(dirName)).toBe('relay') + expect(remoteInstallGcPermits(ORCAD_INSTALL_MODEL, dirName)).toBe(false) + }) + + it('permits each model its own dirs and its own tombstones', () => { + expect(remoteInstallGcPermits(RELAY_INSTALL_MODEL, 'relay-0.1.0+aa')).toBe(true) + expect(remoteInstallGcPermits(ORCAD_INSTALL_MODEL, 'orcad-0.1.0+aa')).toBe(true) + expect(remoteInstallGcPermits(ORCAD_INSTALL_MODEL, 'orcad-0.1.0+aa.gc-tombstone.12.34')).toBe( + true + ) + }) + + it('claims nothing it did not create', () => { + for (const name of ['.orca-remote', 'orcad', 'relayish-0.1.0', 'orcad-notaversion', 'node']) { + expect(remoteInstallDirOwner(name)).toBeNull() + expect(remoteInstallGcPermits(RELAY_INSTALL_MODEL, name)).toBe(false) + expect(remoteInstallGcPermits(ORCAD_INSTALL_MODEL, name)).toBe(false) + } + }) + + it('groups a mixed listing without losing anything to the wrong owner', () => { + const inventory = inventoryRemoteInstallDirs([...RELAY_DIRS, ...ORCAD_DIRS, 'something-else']) + expect(inventory.relay).toEqual(RELAY_DIRS) + expect(inventory.orcad).toEqual(ORCAD_DIRS) + expect(inventory.unknown).toEqual(['something-else']) + }) +}) diff --git a/src/main/ssh/remote-install-model.ts b/src/main/ssh/remote-install-model.ts new file mode 100644 index 00000000000..54be330a55f --- /dev/null +++ b/src/main/ssh/remote-install-model.ts @@ -0,0 +1,143 @@ +/** + * The two things Orca installs into `~/.orca-remote/`, and the rules that keep them from + * touching each other. + * + * `docs/design/shipping-orcad.html` §06 settles that on-disk coexistence is permanent: the + * relay is the dumb execution host for SSH-target users, orcad is the peer for paired + * environments, and no plan item retires either. So `relay-/` and `orcad-/` + * sit side by side forever, and the namespace has to be a parameter rather than a literal. + * + * GC ownership is the trap that parameterization creates. Each model garbage-collects ONLY + * its own directories — see `remoteInstallDirOwner`. Relay's regex happened to be narrow + * enough already; making the prefix a parameter is exactly what could have widened it into + * deleting a live orcad tree, so the ownership rule is asserted here rather than left to + * whichever regex a caller passes. + */ +import { + relayArtifactFilenames, + RELAY_INSTALL_COMPLETE_FILENAME, + RELAY_VERSION_FILENAME +} from '../../shared/relay-artifacts' +import { + orcadArtifactFilenames, + ORCAD_INSTALL_COMPLETE_FILENAME, + ORCAD_VERSION_FILENAME +} from '../../shared/orcad-artifacts' + +export type RemoteInstallModelId = 'relay' | 'orcad' + +export type RemoteInstallModel = { + readonly id: RemoteInstallModelId + /** Leading segment of every version dir: `-`. */ + readonly dirPrefix: string + /** npm package name written into the remote `package.json` for native deps. */ + readonly nativeDepsPackageName: string + readonly versionFilename: string + readonly installCompleteFilename: string + /** Files whose absence means a torn install, so the probe forces a re-deploy. */ + requiredArtifacts(isWindows: boolean): string[] +} + +export const RELAY_INSTALL_MODEL: RemoteInstallModel = { + id: 'relay', + dirPrefix: 'relay', + nativeDepsPackageName: 'orca-relay', + versionFilename: RELAY_VERSION_FILENAME, + installCompleteFilename: RELAY_INSTALL_COMPLETE_FILENAME, + requiredArtifacts: (isWindows) => relayArtifactFilenames(isWindows) +} + +export const ORCAD_INSTALL_MODEL: RemoteInstallModel = { + id: 'orcad', + dirPrefix: 'orcad', + nativeDepsPackageName: 'orca-orcad', + versionFilename: ORCAD_VERSION_FILENAME, + installCompleteFilename: ORCAD_INSTALL_COMPLETE_FILENAME, + // Why the parameter is ignored: orcad's forked children are the same three .js files on + // every host. The Windows-only console-list agent patch is a relay/node-pty concern. + requiredArtifacts: () => orcadArtifactFilenames() +} + +export const REMOTE_INSTALL_MODELS: readonly RemoteInstallModel[] = [ + RELAY_INSTALL_MODEL, + ORCAD_INSTALL_MODEL +] + +/** + * The version half of a directory name, shared by both models. + * + * Why `[0-9]` and not `\d`: this exact source string is also embedded in an awk ERE and a + * PowerShell `-match` on the remote host. Those three dialects agree on `[0-9]`, `\.` and + * `\+`; only JavaScript understands `\d`. + */ +const VERSION_PATTERN = String.raw`v?[0-9]+\.[0-9]+\.[0-9]+(\+[0-9a-f]+)?` + +/** Suffix GC leaves behind mid-delete; the listing must surface these so they can be swept. */ +const TOMBSTONE_PATTERN = String.raw`\.gc-tombstone\.[0-9]+\.[0-9]+` + +/** + * Why validated and not merely typed: the prefix is interpolated into a remote `find -name` + * glob, an awk regex and a single-quoted PowerShell literal. A quote or a metacharacter + * here would be a remote-shell injection on the client's own connection. + */ +function assertSafeDirPrefix(dirPrefix: string): void { + if (!/^[a-z][a-z0-9-]*$/.test(dirPrefix)) { + throw new Error(`Unsafe remote install dir prefix: ${JSON.stringify(dirPrefix)}`) + } +} + +export function remoteInstallDirName(model: RemoteInstallModel, fullVersion: string): string { + assertSafeDirPrefix(model.dirPrefix) + return `${model.dirPrefix}-${fullVersion}` +} + +/** Matches a live version dir for exactly one model — never a tombstone, never a sibling model. */ +export function remoteInstallVersionDirRegex(model: RemoteInstallModel): RegExp { + assertSafeDirPrefix(model.dirPrefix) + return new RegExp(`^${model.dirPrefix}-(${VERSION_PATTERN})$`) +} + +/** What the remote listing is allowed to return: live dirs plus their tombstones. */ +export function remoteInstallListingRegexSource(model: RemoteInstallModel): string { + assertSafeDirPrefix(model.dirPrefix) + return `^${model.dirPrefix}-(${VERSION_PATTERN})(${TOMBSTONE_PATTERN})?$` +} + +/** + * Which model owns a directory found in `~/.orca-remote/`, or null for anything neither + * model created. + * + * This is the answer to §06 falsifier 1's first half: **the model that created a directory + * owns it, and nothing else may delete it.** A relay GC pass that saw `orcad-0.1.0+abc` + * would be looking at the live install of a peer whose lifecycle it has no view into — the + * SSH-execution-boundary collapse in directory form. + */ +export function remoteInstallDirOwner(dirName: string): RemoteInstallModelId | null { + for (const model of REMOTE_INSTALL_MODELS) { + if (new RegExp(remoteInstallListingRegexSource(model)).test(dirName)) { + return model.id + } + } + return null +} + +/** True when `model` is allowed to garbage-collect `dirName`. */ +export function remoteInstallGcPermits(model: RemoteInstallModel, dirName: string): boolean { + return remoteInstallDirOwner(dirName) === model.id +} + +export type RemoteInstallInventory = Record + +/** Group a raw `~/.orca-remote/` listing by owning model, for diagnostics and the client's choice. */ +export function inventoryRemoteInstallDirs(dirNames: readonly string[]): RemoteInstallInventory { + const inventory: RemoteInstallInventory = { relay: [], orcad: [], unknown: [] } + for (const name of dirNames) { + const owner = remoteInstallDirOwner(name) + if (owner) { + inventory[owner].push(name) + } else { + inventory.unknown.push(name) + } + } + return inventory +} diff --git a/src/main/ssh/ssh-relay-build-toolchain.ts b/src/main/ssh/ssh-relay-build-toolchain.ts index 608fca5c6eb..bcb64d3bdf9 100644 --- a/src/main/ssh/ssh-relay-build-toolchain.ts +++ b/src/main/ssh/ssh-relay-build-toolchain.ts @@ -1,164 +1,24 @@ import { execCommand } from './ssh-relay-deploy-helpers' import type { SshConnection } from './ssh-connection' import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' +import { + buildToolchainProbeCommand, + parseBuildToolchainProbe, + type BuildToolchainStatus +} from './build-toolchain-diagnosis' -// Why: node-pty@1.1.0 ships no Linux prebuild, so the remote `npm install` falls -// back to `node-gyp rebuild` and needs a C/C++ toolchain. A missing toolchain is -// the dominant first-connect failure on Linux relays (#1693); node-gyp surfaces -// it as an opaque `not found: make`. We probe for the tools so we can replace -// that with an actionable "install build-essential" message. -const PROBED_TOOLS = [ - 'make', - 'gcc', - 'g++', - 'cc', - 'c++', - 'clang', - 'clang++', - 'python3', - 'python' -] as const - -// Package managers mapped to the one-liner that installs a C/C++ toolchain on -// the matching distro family. Ordered by detection priority. -const PACKAGE_MANAGER_HINTS: readonly { bin: string; install: string }[] = [ - { bin: 'apt-get', install: 'sudo apt-get install -y build-essential python3' }, - { bin: 'dnf', install: 'sudo dnf install -y make gcc gcc-c++ python3' }, - { bin: 'yum', install: 'sudo yum install -y make gcc gcc-c++ python3' }, - { bin: 'pacman', install: 'sudo pacman -S --needed base-devel python' }, - { bin: 'apk', install: 'sudo apk add build-base python3' }, - { bin: 'zypper', install: 'sudo zypper install -y gcc gcc-c++ make python3' } -] - -export type BuildToolchainStatus = { - present: string[] - packageManager: string | null - // node-gyp needs make, Python, and a C++ compiler. The caller only uses this - // verdict after npm/node-gyp output already points at a native-build failure, - // so custom Python paths do not make unrelated npm failures look toolchainy. - toolchainMissing: boolean -} - -function hasCxxCompiler(present: ReadonlySet): boolean { - return present.has('g++') || present.has('c++') || present.has('clang++') -} - -function hasPython(present: ReadonlySet): boolean { - return present.has('python3') || present.has('python') -} - -// POSIX-sh probe: echo a `HAVE ` line per resolvable build tool and a -// single `PKG ` line for the host's package manager. Runs under -// `/bin/sh -c` (see wrapRemoteCommandForPosixShell), so it stays portable. -export function buildToolchainProbeCommand(): string { - const toolLoop = `for t in ${PROBED_TOOLS.join( - ' ' - )}; do if command -v "$t" >/dev/null 2>&1; then echo "HAVE $t"; fi; done` - const pkgList = PACKAGE_MANAGER_HINTS.map((hint) => hint.bin).join(' ') - const pkgLoop = `for p in ${pkgList}; do if command -v "$p" >/dev/null 2>&1; then echo "PKG $p"; break; fi; done` - return `${toolLoop}; ${pkgLoop}` -} - -export function parseBuildToolchainProbe(output: string): BuildToolchainStatus { - const present = new Set() - let packageManager: string | null = null - for (const line of output.split('\n')) { - const haveMatch = line.trim().match(/^HAVE (\S+)$/) - if (haveMatch) { - present.add(haveMatch[1]) - continue - } - const pkgMatch = line.trim().match(/^PKG (\S+)$/) - if (pkgMatch && !packageManager) { - packageManager = pkgMatch[1] - } - } - return { - present: PROBED_TOOLS.filter((tool) => present.has(tool)), - packageManager, - toolchainMissing: !present.has('make') || !hasCxxCompiler(present) || !hasPython(present) - } -} - -export function shouldProbeBuildToolchainAfterNativeDepsFailure(message: string): boolean { - const lower = message.toLowerCase() - if (!lower.includes('gyp') && !lower.includes('node-gyp')) { - return false - } - return ( - /\bnot found:\s*(make|gmake|gcc|g\+\+|cc|c\+\+|clang|clang\+\+|python|python3)\b/i.test( - message - ) || - /\b(make|gmake|gcc|g\+\+|cc|c\+\+|clang|clang\+\+|python|python3)\b.*\bnot found\b/i.test( - message - ) || - lower.includes('could not find any python installation') || - lower.includes('no xcode or clt version detected') - ) -} - -function missingToolNames(status: BuildToolchainStatus): string[] { - const present = new Set(status.present) - const missing: string[] = [] - if (!present.has('make')) { - missing.push('make') - } - if (!hasCxxCompiler(present)) { - missing.push('a C++ compiler (g++ or clang++)') - } - if (!hasPython(present)) { - missing.push('python3') - } - return missing -} - -/** Install hint for the host's package manager, or the cross-distro list when it is unknown. */ -export function toolchainInstallHintLines(status: BuildToolchainStatus): string[] { - const tailored = status.packageManager - ? PACKAGE_MANAGER_HINTS.find((hint) => hint.bin === status.packageManager)?.install - : null - if (tailored) { - return [` ${tailored}`] - } - return [ - ' Debian/Ubuntu: sudo apt-get install -y build-essential python3', - ' Fedora/RHEL: sudo dnf install -y make gcc gcc-c++ python3', - ' Arch: sudo pacman -S --needed base-devel python', - ' Alpine: sudo apk add build-base python3' - ] -} - -/** One-line summary for the deploy log when node-pty is skipped rather than compiled. */ -export function formatSkippedNodePtyWarning(status: BuildToolchainStatus): string { - // Why: with no package manager detected the hint list is the cross-distro menu, whose first line - // is Debian's — quoting it alone would name the wrong distro, so stay neutral instead. - const hintLines = toolchainInstallHintLines(status) - const hint = - hintLines.length === 1 - ? hintLines[0].trim() - : 'install a C/C++ toolchain (make, a C++ compiler, python3)' - return ( - `missing build tools (${missingToolNames(status).join(', ')}); skipping node-pty so the ` + - `connection still serves files and git. Remote terminals need: ${hint}` - ) -} - -export function formatMissingToolchainError( - status: BuildToolchainStatus, - underlyingError: string -): string { - const lines = [ - `The remote host is missing the C/C++ build tools (${missingToolNames(status).join(', ')}) ` + - `needed to compile Orca's relay native modules (node-pty, @parcel/watcher). node-pty has no ` + - `prebuilt binary for Linux, so they must be compiled on the remote host.`, - '', - 'Install the build tools on the remote host, then reconnect:', - ...toolchainInstallHintLines(status), - '', - `Underlying install error: ${underlyingError}` - ] - return lines.join('\n') -} +// Why re-exported rather than moved outright: `ssh-relay-deploy.ts` and the relay tests +// import the whole diagnosis surface from here, and the split exists for bundle reasons, +// not to redraw the relay's own API. +export { + buildToolchainProbeCommand, + parseBuildToolchainProbe, + shouldProbeBuildToolchainAfterNativeDepsFailure, + toolchainInstallHintLines, + formatSkippedNodePtyWarning, + formatMissingToolchainError +} from './build-toolchain-diagnosis' +export type { BuildToolchainStatus } from './build-toolchain-diagnosis' // Best-effort: returns null on Windows hosts (node-pty ships win32 prebuilds, so // a missing toolchain isn't the failure there) or if the probe itself errors — diff --git a/src/main/ssh/ssh-relay-install-namespace.ts b/src/main/ssh/ssh-relay-install-namespace.ts index a75b8048841..6fd3c2aa89f 100644 --- a/src/main/ssh/ssh-relay-install-namespace.ts +++ b/src/main/ssh/ssh-relay-install-namespace.ts @@ -7,6 +7,11 @@ // See: docs/ssh-relay-sftp-namespace.md import { RELAY_REMOTE_DIR } from './relay-protocol' +import { + RELAY_INSTALL_MODEL, + remoteInstallDirName, + type RemoteInstallModel +} from './remote-install-model' import type { SftpNamespacePathMapping } from './sftp-namespace-resolution' import { shellEscape } from './ssh-connection-utils' import { RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock' @@ -37,7 +42,19 @@ export function relayRemoteDirSegments( fullVersion: string, pathFlavor: RemotePathFlavor ): string[] { - const segments = [RELAY_REMOTE_DIR, `relay-${fullVersion}`] + return remoteInstallDirSegments(RELAY_INSTALL_MODEL, fullVersion, pathFlavor) +} + +/** + * The model-parameterized form. `relay-` and `orcad-` are permanent siblings under + * one `.orca-remote/` (see remote-install-model.ts), so the prefix is an argument. + */ +export function remoteInstallDirSegments( + model: RemoteInstallModel, + fullVersion: string, + pathFlavor: RemotePathFlavor +): string[] { + const segments = [RELAY_REMOTE_DIR, remoteInstallDirName(model, fullVersion)] for (const segment of segments) { assertSafeRemotePathSegment(segment, pathFlavor) // Why: the version reaches logs and diagnostics, where an embedded CR/LF can forge lines. @@ -49,7 +66,14 @@ export function relayRemoteDirSegments( } export function relayHomeRelativeDir(fullVersion: string): string { - return relayRemoteDirSegments(fullVersion, 'posix').join('/') + return remoteInstallHomeRelativeDir(RELAY_INSTALL_MODEL, fullVersion) +} + +export function remoteInstallHomeRelativeDir( + model: RemoteInstallModel, + fullVersion: string +): string { + return remoteInstallDirSegments(model, fullVersion, 'posix').join('/') } export function createRelayInstallNamespace(homeRelativeRelayDir: string): RelayInstallNamespace { diff --git a/src/main/ssh/ssh-relay-versioned-install.ts b/src/main/ssh/ssh-relay-versioned-install.ts index e4380c319c3..d2ddc62e64b 100644 --- a/src/main/ssh/ssh-relay-versioned-install.ts +++ b/src/main/ssh/ssh-relay-versioned-install.ts @@ -7,24 +7,12 @@ import { join } from 'node:path' import { existsSync, readFileSync } from 'node:fs' import type { SshConnection } from './ssh-connection' -import { RELAY_REMOTE_DIR } from './relay-protocol' import { execCommand } from './ssh-relay-deploy-helpers' -import { probeInstallLockExistsCommand } from './ssh-relay-install-lock-commands' -import { isRelayInstallLockStale, RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock' -import { relayRemoteDirSegments } from './ssh-relay-install-namespace' +import { RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock' +import { remoteInstallDirSegments } from './ssh-relay-install-namespace' +import { RELAY_INSTALL_MODEL, type RemoteInstallModel } from './remote-install-model' import { - isRelayGcClaimOwned, - releaseRelayGcClaimWithRetry, - tryAcquireRelayGcClaim -} from './ssh-relay-gc-claim' -import { cleanupRelayGcTombstones } from './ssh-relay-gc-tombstone' -import { - listRelayBaseDirsCommand, - MAX_RELAY_GC_LISTING_ENTRIES, - moveRemoteTreeCommand, - probeFileExistsCommand, - probeRelayInstalledCommand, - relayLivenessProbeCommand, + probeRemoteInstallCompleteCommand, removeRemoteTreeCommand, writeRemoteEmptyFileCommand } from './ssh-remote-commands' @@ -32,20 +20,11 @@ import { getRemoteHostPlatform, isWindowsRemoteHost, joinRemotePath, - remoteBasename, type RemoteHostPlatform, type RemotePathFlavor } from './ssh-remote-platform' -import { windowsRelayPipePathsForSocketName } from './ssh-relay-endpoints' -import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' import { isSshSessionLimitError } from './ssh-session-limit-error' -// Single source of truth for GC and the version-dir parser; matches both the new and legacy relay-dir layouts. -const RELAY_VERSION_DIR_REGEX = /^relay-(v?\d+\.\d+\.\d+(\+[0-9a-f]+)?)$/ - -// Legacy dirs predate `.install-complete`; they need a liveness-only GC check so they eventually drain. -const LEGACY_RELAY_DIR_REGEX = /^relay-v\d+\.\d+\.\d+$/ - const INSTALL_COMPLETE_NAME = '.install-complete' const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') @@ -97,13 +76,23 @@ export function computeRemoteRelayDir( remoteHome: string, fullVersion: string, pathFlavor: RemotePathFlavor = 'posix' +): string { + // Why: shell and SFTP-relative builders must derive the same validated segments or the namespaces diverge. + return computeRemoteInstallDir(RELAY_INSTALL_MODEL, remoteHome, fullVersion, pathFlavor) +} + +/** The model-parameterized form of `computeRemoteRelayDir`. */ +export function computeRemoteInstallDir( + model: RemoteInstallModel, + remoteHome: string, + fullVersion: string, + pathFlavor: RemotePathFlavor = 'posix' ): string { const host = pathFlavor === 'windows' ? getRemoteHostPlatform('win32-x64') : getRemoteHostPlatform('linux-x64') - // Why: shell and SFTP-relative builders must derive the same validated segments or the namespaces diverge. - return joinRemotePath(host, remoteHome, ...relayRemoteDirSegments(fullVersion, pathFlavor)) + return joinRemotePath(host, remoteHome, ...remoteInstallDirSegments(model, fullVersion, pathFlavor)) } /** @@ -116,11 +105,26 @@ export async function isRelayAlreadyInstalled( host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, options?: RelayInstalledProbeOptions ): Promise { + return isRemoteInstallComplete(conn, RELAY_INSTALL_MODEL, remoteRelayDir, host, options) +} + +/** The model-parameterized form: each model probes for its own artifact list. */ +export async function isRemoteInstallComplete( + conn: SshConnection, + model: RemoteInstallModel, + remoteInstallDir: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + options?: RelayInstalledProbeOptions +): Promise { + const remoteRelayDir = remoteInstallDir try { const probe = await execHostCommand( conn, host, - probeRelayInstalledCommand(host, remoteRelayDir), + probeRemoteInstallCompleteCommand(host, remoteRelayDir, [ + ...model.requiredArtifacts(isWindowsRemoteHost(host)), + model.installCompleteFilename + ]), { signal: options?.signal } ) return probe.trim() === 'OK' @@ -175,188 +179,10 @@ export async function abandonInstall( * unlocked sibling version dir (never the current one). Best-effort — errors * are swallowed so GC never blocks the user from connecting. */ -export async function gcOldRelayVersions( - conn: SshConnection, - remoteHome: string, - currentDirAbsPath: string, - host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, - options?: { - windowsNodePath?: string - windowsSockNames?: string[] - } -): Promise { - const baseDir = joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR) - const currentDirName = remoteBasename(currentDirAbsPath, host) - let listing: string - try { - listing = await execHostCommand(conn, host, listRelayBaseDirsCommand(host, baseDir)) - } catch { - return - } - const entries = listing - .split('\n') - .map((s) => s.trim()) - .filter(Boolean) - .slice(0, MAX_RELAY_GC_LISTING_ENTRIES) - - await cleanupRelayGcTombstones(conn, baseDir, entries, host) - - const candidates = entries - .filter((name) => RELAY_VERSION_DIR_REGEX.test(name)) - .filter((name) => name !== currentDirName) - - if (candidates.length === 0) { - return - } - - const removed: string[] = [] - const kept: string[] = [] - for (const name of candidates) { - const dir = joinRemotePath(host, baseDir, name) - try { - const safe = await isCandidateSafeToRemove(conn, dir, name, host, options) - if (!safe) { - kept.push(name) - continue - } - // Why: the claim is a sibling, so it survives moving/deleting the candidate and lets installers back out first. - const gcClaimToken = await tryAcquireRelayGcClaim(conn, dir, host) - if (!gcClaimToken) { - kept.push(name) - continue - } - let preserveGcClaim = false - let gcClaimReleaseNeeded = true - try { - // Recheck under the stable claim; installers probe it before and after creating their lock, closing both orders. - if (!(await isCandidateSafeToRemove(conn, dir, name, host, options))) { - kept.push(name) - continue - } - if (!(await isRelayGcClaimOwned(conn, dir, gcClaimToken, host))) { - kept.push(name) - continue - } - const tombstone = `${dir}.gc-tombstone.${process.pid}.${Date.now()}` - const moved = await execHostCommand(conn, host, moveRemoteTreeCommand(host, dir, tombstone)) - if (moved.trim() !== 'MOVED') { - kept.push(name) - continue - } - // Once renamed, a fresh install at the original path is isolated from the tombstone's deletion, so release the claim. - const release = await releaseRelayGcClaimWithRetry(conn, dir, gcClaimToken, host) - gcClaimReleaseNeeded = release === 'unknown' - await execHostCommand(conn, host, removeRemoteTreeCommand(host, tombstone)) - } catch (err) { - if (isUnconfirmedSshCommandTermination(err)) { - preserveGcClaim = true - } - throw err - } finally { - if (!preserveGcClaim && gcClaimReleaseNeeded) { - await releaseRelayGcClaimWithRetry(conn, dir, gcClaimToken, host) - } - } - removed.push(name) - } catch (err) { - console.warn( - `[ssh-relay] GC failed for ${dir}: ${err instanceof Error ? err.message : String(err)}` - ) - kept.push(name) - } - } - - if (removed.length > 0) { - const keptSuffix = kept.length > 0 ? ` (kept: ${kept.join(', ')})` : '' - console.log( - `[ssh-relay] GC: removed ${removed.length} stale version dir(s): ${removed.join(', ')}${keptSuffix}` - ) - } -} - -async function isCandidateSafeToRemove( - conn: SshConnection, - dir: string, - name: string, - host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, - options?: { - windowsNodePath?: string - windowsSockNames?: string[] - } -): Promise { - const isLegacy = LEGACY_RELAY_DIR_REGEX.test(name) - - const lockDir = joinRemotePath(host, dir, RELAY_INSTALL_LOCK_NAME) - let lockProbe: string - try { - lockProbe = await execHostCommand(conn, host, probeInstallLockExistsCommand(host, lockDir)) - } catch { - return false - } - const lockState = lockProbe.trim() - if (lockState !== 'OPEN' && lockState !== 'LOCKED') { - return false - } - const locked = lockState === 'LOCKED' - - if (locked) { - // Why: stale lock = crashed installer; finalize can leave a dir .install-complete yet locked (lock-rm failed), so it's reclaimable. - if (!(await isRelayInstallLockStale(conn, lockDir, host))) { - return false - } - process.stderr.write?.(`[ssh-relay] GC: lock at ${lockDir} is stale; treating as recoverable\n`) - } - - // Legacy dirs predate .install-complete; skip the sentinel and rely on the live-socket probe alone. - if (!isLegacy) { - const completePath = joinRemotePath(host, dir, INSTALL_COMPLETE_NAME) - const completeProbe = await execHostCommand( - conn, - host, - probeFileExistsCommand(host, completePath) - ).catch(() => 'PARTIAL') - if (completeProbe.trim() !== 'COMPLETE') { - // Crashed-install partial; leave for the next deploy to recover. - return false - } - } - - const sockAlive = await hasLiveRelaySocket(conn, dir, host, options) - if (sockAlive) { - return false - } - return true -} - -async function hasLiveRelaySocket( - conn: SshConnection, - dir: string, - host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, - options?: { - windowsNodePath?: string - windowsSockNames?: string[] - } -): Promise { - try { - // Why: `test -S` only — a connect-and-close probe would race with a daemon about to idle. - const windowsOptions = - isWindowsRemoteHost(host) && options?.windowsNodePath - ? { - nodePath: options.windowsNodePath, - pipePaths: (options.windowsSockNames ?? []).flatMap((sockName) => - windowsRelayPipePathsForSocketName(host, dir, sockName) - ) - } - : undefined - const out = await execHostCommand( - conn, - host, - relayLivenessProbeCommand(host, dir, windowsOptions) - ) - const state = out.trim() - return state !== 'DEAD' && state !== 'WAITING' - } catch { - // Why: an inconclusive liveness probe must never authorize deletion. - return true - } -} +// Why re-exported rather than moved outright: deploy and the relay tests import the whole +// versioned-install surface from here, and the split exists for file size, not to redraw an API. +export { + gcOldRelayVersions, + gcOldRemoteInstallVersions, + type RemoteInstallGcOptions +} from './remote-install-gc' diff --git a/src/main/ssh/ssh-remote-commands.ts b/src/main/ssh/ssh-remote-commands.ts index 4fa1445969d..fc3e965193e 100644 --- a/src/main/ssh/ssh-remote-commands.ts +++ b/src/main/ssh/ssh-remote-commands.ts @@ -2,6 +2,11 @@ import { RELAY_INSTALL_COMPLETE_FILENAME, relayArtifactFilenames } from '../../shared/relay-artifacts' +import { + RELAY_INSTALL_MODEL, + remoteInstallListingRegexSource, + type RemoteInstallModel +} from './remote-install-model' import type { RemoteHostPlatform } from './ssh-remote-platform' import { isWindowsRemoteHost, joinRemotePath, remoteDirname } from './ssh-remote-platform' import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' @@ -99,10 +104,28 @@ export function probeRelayInstalledCommand( host: RemoteHostPlatform, remoteRelayDir: string ): string { - const required = [ + return probeRemoteInstallCompleteCommand(host, remoteRelayDir, [ ...relayArtifactFilenames(isWindowsRemoteHost(host)), RELAY_INSTALL_COMPLETE_FILENAME - ].map((filename) => joinRemotePath(host, remoteRelayDir, filename)) + ]) +} + +/** + * The model-agnostic form: any install is complete when its directory exists and every + * named artifact is a regular file inside it. + * + * Why the caller passes the list: orcad and the relay ship different artifacts, and a probe + * that checked a shared subset would call a torn install complete. + */ +export function probeRemoteInstallCompleteCommand( + host: RemoteHostPlatform, + remoteInstallDir: string, + requiredFilenames: readonly string[] +): string { + const remoteRelayDir = remoteInstallDir + const required = requiredFilenames.map((filename) => + joinRemotePath(host, remoteRelayDir, filename) + ) if (!isWindowsRemoteHost(host)) { const fileTests = required.map((path) => `&& test -f ${shellEscape(path)} `).join('') return `test -d ${shellEscape(remoteRelayDir)} ${fileTests}&& echo OK || echo MISSING` @@ -121,12 +144,28 @@ export function probeRelayInstalledCommand( export const MAX_RELAY_GC_LISTING_ENTRIES = 64 export function listRelayBaseDirsCommand(host: RemoteHostPlatform, baseDir: string): string { + return listRemoteInstallBaseDirsCommand(host, baseDir, RELAY_INSTALL_MODEL) +} + +/** + * List one model's version dirs (and its own tombstones) under `~/.orca-remote/`. + * + * The model scopes BOTH the `find`/`Get-ChildItem` glob and the validating regex. That + * double filter is the on-the-wire half of the GC ownership rule: an orcad GC pass never + * even receives a relay directory name, so it cannot delete one through a later bug. + */ +export function listRemoteInstallBaseDirsCommand( + host: RemoteHostPlatform, + baseDir: string, + model: RemoteInstallModel +): string { + const namePattern = remoteInstallListingRegexSource(model) if (!isWindowsRemoteHost(host)) { const statusPrefix = '__ORCA_RELAY_GC_FIND_STATUS__' return [ `base=${shellEscape(baseDir)}; [ -d "$base" ] || exit 0;`, - `{ find "$base" -mindepth 1 -maxdepth 1 -type d -name 'relay-*' -print; status=$?; printf '\n${statusPrefix}%s\n' "$status"; } |`, - String.raw`awk 'BEGIN { count=0; status=-1 } /^${statusPrefix}[0-9]+$/ { status=substr($0, ${statusPrefix.length + 1}); next } { name=$0; sub(/^.*\//, "", name); if (name ~ /^relay-(v?[0-9]+\.[0-9]+\.[0-9]+(\+[0-9a-f]+)?)(\.gc-tombstone\.[0-9]+\.[0-9]+)?$/ && count < ${MAX_RELAY_GC_LISTING_ENTRIES}) { entries[count++]=name } } END { if (status != 0) exit 1; for (i=0; i !artifact.optional).map( + (artifact) => artifact.filename + ) +} diff --git a/src/shared/runtime-capability-degradation.ts b/src/shared/runtime-capability-degradation.ts new file mode 100644 index 00000000000..05620ac9942 --- /dev/null +++ b/src/shared/runtime-capability-degradation.ts @@ -0,0 +1,64 @@ +export const TERMINAL_UNAVAILABLE_ERROR_CODE = 'terminal_unavailable' as const + +export const TERMINAL_PTY_DEGRADATION_CAPABILITY = 'terminal.pty.v1' as const + +export type RuntimeBrowserUnavailableReason = + | 'unconfigured' + | 'driver_missing' + | 'executable_not_found' + | 'executable_not_executable' + | 'electron_start_failed' + | 'chromium_start_failed' + | 'provider_unhealthy' + | 'desktop_window_unavailable' + | 'unknown' + +/** + * Why this host cannot spawn PTYs. Members are opaque to clients: render `message`, + * never switch exhaustively. Dynamic-loader failures are proved out of process because + * an incompatible native binary can terminate the host before JavaScript can catch it. + */ +export type RuntimeTerminalUnavailableReason = + | 'dependency_missing' + | 'libc_floor' + | 'abi_mismatch' + | 'load_failed' + | 'load_crashed' + | 'spawn_helper_missing' + | 'unknown' + +export type RuntimeDegradation = { + /** + * Open vocabulary. New codes ship without a protocol bump, so clients must render + * `message` and must not switch exhaustively on this or the `reason` field. + */ + code: 'browser_unavailable' | typeof TERMINAL_UNAVAILABLE_ERROR_CODE + capability: 'browser.headless.v1' | typeof TERMINAL_PTY_DEGRADATION_CAPABILITY + message: string + reason?: RuntimeBrowserUnavailableReason | RuntimeTerminalUnavailableReason + /** Underlying error text when the host has one. Diagnostic only; never load-bearing. */ + detail?: string +} + +const TERMINAL_UNAVAILABLE_MESSAGES: Record = { + dependency_missing: + 'Terminals are unavailable on this host: node-pty has no native binary for this platform. Install or rebuild it, or deploy a build that ships a prebuilt binary for this platform.', + libc_floor: + "This host's node-pty binary was built against a newer C library than the host provides, so the dynamic loader refuses it. Rebuild node-pty on this host, or deploy a build whose prebuilt binary matches this platform's libc.", + abi_mismatch: + "This host's node-pty binary was built for a different Node ABI than the running Node, so it cannot be loaded. Rebuild node-pty against this Node version.", + load_failed: 'Terminals are unavailable on this host: node-pty failed to load.', + load_crashed: + 'Terminals are unavailable on this host: loading node-pty terminated the probe process, which means the binary is incompatible with this host rather than merely missing.', + spawn_helper_missing: + 'node-pty loaded, but its spawn-helper executable is missing or not executable, so every terminal spawn would fail. Reinstall node-pty on this host.', + unknown: 'Terminals are unavailable on this host, and the cause could not be determined.' +} + +export function terminalUnavailableMessage( + reason: RuntimeTerminalUnavailableReason, + detail?: string +): string { + const base = TERMINAL_UNAVAILABLE_MESSAGES[reason] + return detail ? `${base} (${detail})` : base +} diff --git a/src/shared/runtime-session-contracts.ts b/src/shared/runtime-session-contracts.ts index 9e96bf445bc..9d94980fcae 100644 --- a/src/shared/runtime-session-contracts.ts +++ b/src/shared/runtime-session-contracts.ts @@ -4,6 +4,10 @@ import type { RemoteServerUpdateSupport } from './remote-server-update' import type { RemoteRuntimeSharedConnectionDiagnostics } from './remote-runtime-shared-control-types' import type { RuntimeBrowserPlacement } from './runtime-browser-placement' import type { RuntimeCapability } from './protocol-version' +import type { + RuntimeBrowserUnavailableReason, + RuntimeDegradation +} from './runtime-capability-degradation' import type { TabGroupLayoutNode } from './tab-types' import type { TerminalColorOverrides } from './terminal-color-overrides' import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from './terminal-tab-types' @@ -26,22 +30,6 @@ export type RuntimeBrowserDriverState = RuntimeTerminalDriverState export const BROWSER_UNAVAILABLE_ERROR_CODE = 'browser_unavailable' as const -/** - * Why a host declined browser automation. Members are opaque to clients: new ones - * ship without a protocol bump, so render `message` and never switch exhaustively - * (same contract as RuntimeTerminalWaitBlockedReason). - */ -export type RuntimeBrowserUnavailableReason = - | 'unconfigured' - | 'driver_missing' - | 'executable_not_found' - | 'executable_not_executable' - | 'electron_start_failed' - | 'chromium_start_failed' - | 'provider_unhealthy' - | 'desktop_window_unavailable' - | 'unknown' - // Why: one sentence per cause, each naming the thing the operator can change. The host // renders these so an older client still shows an accurate reason it cannot decode. const BROWSER_UNAVAILABLE_MESSAGES: Record = { @@ -69,19 +57,6 @@ export function browserUnavailableMessage( return detail ? `${base} (${detail})` : base } -export type RuntimeDegradation = { - code: typeof BROWSER_UNAVAILABLE_ERROR_CODE - capability: 'browser.headless.v1' - message: string - /** - * Machine-readable cause. Optional for mixed-version peers: absence means the host - * predates structured causes, NOT that the cause is 'unconfigured'. - */ - reason?: RuntimeBrowserUnavailableReason - /** Underlying error text when the host has one. Diagnostic only; never load-bearing. */ - detail?: string -} - export type RuntimeStatus = { runtimeId: string /** Authenticated requester identity. Missing for in-process callers and older hosts. */ diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 762f4274e29..6ca9dd9bf58 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -97,13 +97,21 @@ export { HEADLESS_RUNTIME_WINDOW_ID, UNPUBLISHED_WORKTREE_PUBLICATION_EPOCH } from './runtime-session-contracts' +export { + TERMINAL_PTY_DEGRADATION_CAPABILITY, + TERMINAL_UNAVAILABLE_ERROR_CODE, + terminalUnavailableMessage +} from './runtime-capability-degradation' +export type { + RuntimeBrowserUnavailableReason, + RuntimeDegradation, + RuntimeTerminalUnavailableReason +} from './runtime-capability-degradation' export type { CliRuntimeState, CliStatusResult, DeviceScope, RuntimeBrowserDriverState, - RuntimeBrowserUnavailableReason, - RuntimeDegradation, RuntimeDesktopWindowStatus, RuntimeGraphStatus, RuntimeMobileSessionBrowserTab, From f352e3e27dc887fb96a6bd3cfe77a82aae7cc38a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:25:31 -0700 Subject: [PATCH 051/152] fix(cursor): emit Cursor-contract JSON from managed hooks Merge rebased conflict repair after exact-head tests, typecheck, lint, format, and all required GitHub checks passed. --- .github/workflows/pr.yml | 1 + config/tsconfig.cli.json | 2 + src/main/agent-hooks/installer-utils.test.ts | 11 ++ src/main/agent-hooks/installer-utils.ts | 10 +- .../remote-hook-service-installers.test.ts | 29 +-- src/main/cursor/hook-events.ts | 29 +++ src/main/cursor/hook-script.ts | 87 +++++++++ src/main/cursor/hook-service.test.ts | 168 ++++++++++++++++-- src/main/cursor/hook-service.ts | 41 ++--- 9 files changed, 323 insertions(+), 55 deletions(-) create mode 100644 src/main/cursor/hook-events.ts create mode 100644 src/main/cursor/hook-script.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 21d43d3bee5..0862ac31fe7 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -712,6 +712,7 @@ jobs: src/main/wsl/wsl-w1-w3-contract.test.ts src/shared/source-scan/source-tree-scan.test.ts src/main/cli/wsl-cli-powershell-boundary.test.ts + src/main/cursor/hook-service.test.ts src/main/orca-profiles/profile-index-store.test.ts src/main/runtime/repo-worktree-admin-fingerprint.test.ts src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index eb278257159..f4555ae4a48 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -82,6 +82,8 @@ "../src/main/copilot/copilot-managed-script.ts", "../src/main/copilot/copilot-remote-hook-install.ts", "../src/main/copilot/hook-service.ts", + "../src/main/cursor/hook-events.ts", + "../src/main/cursor/hook-script.ts", "../src/main/cursor/hook-service.ts", "../src/main/droid/hook-service.ts", "../src/main/gemini/hook-service.ts", diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index 8515f415fd2..88c73550da2 100644 --- a/src/main/agent-hooks/installer-utils.test.ts +++ b/src/main/agent-hooks/installer-utils.test.ts @@ -639,6 +639,17 @@ describe('wrapWindowsHookCommand', () => { ) }) + it('emits fallback stdout when the managed script is missing', () => { + const command = wrapWindowsHookCommand( + 'C:\\hooks\\cursor-hook.cmd', + {}, + { fallbackStdout: '{"permission":"allow"}' } + ) + expect(decodeWindowsHookCommand(command)).toContain( + 'Write-Output \'{"permission":"allow"}\'; exit 0' + ) + }) + // Why: a user profile path like `C:\Users\Jane Doe` is the regression from // #6078 — the raw path used to be split at the space. The wrapper must keep // the whole path inside the encoded command so shells do not split it. diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index b5a545e1048..8667c418492 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -118,14 +118,20 @@ export { export function wrapWindowsHookCommand( scriptPath: string, - env: Record = {} + env: Record = {}, + // Why: POSIX wrap already answers missing-script with stdout; Windows must match so gate events cannot drift (#15462). + options: { fallbackStdout?: string } = {} ): string { // Why: the encoded launcher protects paths across Windows shells and drains stdin when the config points at a missing script. const quoted = quotePowerShellString(scriptPath) const envPrefix = Object.entries(env) .map(([key, value]) => `$env:${key} = ${quotePowerShellString(value)}; `) .join('') - const command = `${envPrefix}if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0` + const fallback = + options.fallbackStdout === undefined + ? '' + : `Write-Output ${quotePowerShellString(options.fallbackStdout)}; ` + const command = `${envPrefix}if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; ${fallback}exit 0` return wrapWindowsPowerShellEncodedCommand(command) } diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 051b8bc48d1..bcb446b10ee 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -11,6 +11,7 @@ vi.mock('electron', () => ({ import { CodexHookService, codexHookService } from '../codex/hook-service' import { DroidHookService, droidHookService } from '../droid/hook-service' import { CursorHookService, cursorHookService } from '../cursor/hook-service' +import { CURSOR_EVENTS, type CursorEvent } from '../cursor/hook-events' import { CommandCodeHookService, commandCodeHookService } from '../command-code/hook-service' import { GeminiHookService, geminiHookService } from '../gemini/hook-service' import { AntigravityHookService, antigravityHookService } from '../antigravity/hook-service' @@ -35,6 +36,17 @@ type FakeFs = { failRenameTo: Set } +const EXPECTED_CURSOR_HOOK_RESPONSES = { + beforeSubmitPrompt: '{"continue":true}', + stop: '{}', + preToolUse: '{"permission":"allow"}', + postToolUse: '{}', + postToolUseFailure: '{}', + beforeShellExecution: '{"permission":"allow"}', + beforeMCPExecution: '{"permission":"allow"}', + afterAgentResponse: '{}' +} satisfies Record + function createFakeSftp(initialFiles: Record = {}): { sftp: SFTPWrapper fs: FakeFs @@ -366,19 +378,14 @@ describe('remote hook service installers', () => { hooks: Record } expect(cursorConfig.version).toBe(1) - for (const eventName of [ - 'beforeSubmitPrompt', - 'stop', - 'preToolUse', - 'postToolUse', - 'postToolUseFailure', - 'beforeShellExecution', - 'beforeMCPExecution', - 'afterAgentResponse' - ]) { + for (const eventName of CURSOR_EVENTS) { const definition = cursorConfig.hooks[eventName]?.[0] - expect(definition?.command).toContain('/home/dev/.orca/agent-hooks/cursor-hook.sh') + const command = definition?.command + expect(command).toContain('/home/dev/.orca/agent-hooks/cursor-hook.sh') expect(definition?.hooks).toBeUndefined() + const response = EXPECTED_CURSOR_HOOK_RESPONSES[eventName] + expect(command).toContain(`ORCA_CURSOR_HOOK_RESPONSE='${response}'`) + expect(command).toContain(`printf '%s\\n' '${response}'`) } const commandCodeConfig = JSON.parse( diff --git a/src/main/cursor/hook-events.ts b/src/main/cursor/hook-events.ts new file mode 100644 index 00000000000..fe840a41b72 --- /dev/null +++ b/src/main/cursor/hook-events.ts @@ -0,0 +1,29 @@ +// Subscribe only to Cursor hooks needed for spinner and turn detection. +// Exclude process-boundary session hooks, which can reset the submitted-turn prompt cache. +export const CURSOR_EVENTS = [ + 'beforeSubmitPrompt', + 'stop', + 'preToolUse', + 'postToolUse', + 'postToolUseFailure', + 'beforeShellExecution', + 'beforeMCPExecution', + 'afterAgentResponse' +] as const + +export type CursorEvent = (typeof CURSOR_EVENTS)[number] + +const CURSOR_HOOK_RESPONSES = { + beforeSubmitPrompt: '{"continue":true}', + stop: '{}', + preToolUse: '{"permission":"allow"}', + postToolUse: '{}', + postToolUseFailure: '{}', + beforeShellExecution: '{"permission":"allow"}', + beforeMCPExecution: '{"permission":"allow"}', + afterAgentResponse: '{}' +} satisfies Record + +export function getCursorHookResponse(eventName: CursorEvent): string { + return CURSOR_HOOK_RESPONSES[eventName] +} diff --git a/src/main/cursor/hook-script.ts b/src/main/cursor/hook-script.ts new file mode 100644 index 00000000000..94563337131 --- /dev/null +++ b/src/main/cursor/hook-script.ts @@ -0,0 +1,87 @@ +import { + buildWindowsAgentHookPostCommand, + wrapPosixHookCommand, + wrapWindowsHookCommand +} from '../agent-hooks/installer-utils' +import { + buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, + buildWindowsHookEnvironmentGuardLines, + buildWindowsHookStdinDrainEpilogue +} from '../agent-hooks/hook-stdin-contract' +import { getCursorHookResponse, type CursorEvent } from './hook-events' + +const CURSOR_HOOK_RESPONSE_ENV = 'ORCA_CURSOR_HOOK_RESPONSE' + +export function getPosixManagedCommand(scriptPath: string, eventName: CursorEvent): string { + const response = getCursorHookResponse(eventName) + return wrapPosixHookCommand( + scriptPath, + { [CURSOR_HOOK_RESPONSE_ENV]: response }, + { fallbackStdout: response } + ) +} + +export function getManagedCommand(scriptPath: string, eventName: CursorEvent): string { + const response = getCursorHookResponse(eventName) + return process.platform === 'win32' + ? wrapWindowsHookCommand( + scriptPath, + { [CURSOR_HOOK_RESPONSE_ENV]: response }, + { fallbackStdout: response } + ) + : getPosixManagedCommand(scriptPath, eventName) +} + +export function getManagedScript(target: 'local' | 'posix' = 'local'): string { + if (target === 'local' && process.platform === 'win32') { + return [ + '@echo off', + 'setlocal', + // Why: Cursor permission hooks fail closed on empty/invalid stdout (#15462). + `if defined ${CURSOR_HOOK_RESPONSE_ENV} (echo %${CURSOR_HOOK_RESPONSE_ENV}%) else (echo {})`, + // Why: source current endpoint coordinates for PTYs surviving an Orca restart. + 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + ...buildWindowsHookEnvironmentGuardLines(), + buildWindowsAgentHookPostCommand('cursor'), + 'exit /b 0', + ...buildWindowsHookStdinDrainEpilogue(), + '' + ].join('\r\n') + } + + return [ + '#!/bin/sh', + // Why: Cursor permission hooks fail closed on empty/invalid stdout (#15462). + `if [ -n "$${CURSOR_HOOK_RESPONSE_ENV}" ]; then`, + ` printf '%s\\n' "$${CURSOR_HOOK_RESPONSE_ENV}"`, + 'else', + ' printf "{}\\n"', + 'fi', + ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('cursor'), + // Why: refresh endpoint coordinates so surviving PTYs keep reporting. + 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', + ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', + 'fi', + 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', + ' exit 0', + 'fi', + // Why: post form fields because path-bearing worktree IDs are unsafe in hand-built JSON. + // Why: pipe payload to curl stdin to keep large output off the command line. + 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/cursor" \\', + ' --connect-timeout 0.5 --max-time 1.5 \\', + ' -H "Content-Type: application/x-www-form-urlencoded" \\', + ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', + ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', + ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', + ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', + ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', + ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', + ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', + 'exit 0', + '' + ].join('\n') +} diff --git a/src/main/cursor/hook-service.test.ts b/src/main/cursor/hook-service.test.ts index a260b74938f..36f6aa94b48 100644 --- a/src/main/cursor/hook-service.test.ts +++ b/src/main/cursor/hook-service.test.ts @@ -1,7 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { findGitBash } from '../agent-hooks/windows-git-bash-path.test-fixture' const { homedirMock } = vi.hoisted(() => ({ homedirMock: vi.fn<() => string>() @@ -17,22 +19,66 @@ vi.mock('os', async () => { import { CursorHookService } from './hook-service' import { POSIX_HOOK_STDIN_READER } from '../agent-hooks/hook-stdin-contract' - -const CURSOR_EVENTS = [ - 'beforeSubmitPrompt', - 'stop', - 'preToolUse', - 'postToolUse', - 'postToolUseFailure', - 'beforeShellExecution', - 'beforeMCPExecution', - 'afterAgentResponse' -] +import { CURSOR_EVENTS, type CursorEvent } from './hook-events' const CURSOR_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'cursor-hook.cmd' : 'cursor-hook.sh' const WINDOWS_POWERSHELL_LAUNCHER = /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ +type InstalledCursorHooks = { + hooks: Record +} + +const EXPECTED_CURSOR_HOOK_STDOUT = { + beforeSubmitPrompt: { continue: true }, + stop: {}, + preToolUse: { permission: 'allow' }, + postToolUse: {}, + postToolUseFailure: {}, + beforeShellExecution: { permission: 'allow' }, + beforeMCPExecution: { permission: 'allow' }, + afterAgentResponse: {} +} satisfies Record> + +function readInstalledCursorHooks(homeDir: string): InstalledCursorHooks { + return JSON.parse( + readFileSync(join(homeDir, '.cursor', 'hooks.json'), 'utf8') + ) as InstalledCursorHooks +} + +function requireRegisteredCommand(config: InstalledCursorHooks, eventName: string): string { + const command = config.hooks[eventName]?.[0]?.command + expect(command, eventName).toEqual(expect.any(String)) + if (typeof command !== 'string') { + throw new Error(`missing Cursor hook command for ${eventName}`) + } + return command +} + +function runRegisteredCursorHook( + command: string, + input: string, + extraEnv: NodeJS.ProcessEnv = {} +): { stdout: string; stderr: string; status: number | null } { + const executable = process.platform === 'win32' ? 'cmd.exe' : '/bin/sh' + const args = process.platform === 'win32' ? ['/d', '/s', '/c', command] : ['-c', command] + const result = spawnSync(executable, args, { + encoding: 'utf8', + input, + timeout: 15_000, + env: { + ...process.env, + ORCA_AGENT_HOOK_ENDPOINT: '', + ORCA_AGENT_HOOK_PORT: '', + ORCA_AGENT_HOOK_TOKEN: '', + ORCA_PANE_KEY: '', + ...extraEnv + } + }) + expect(result.error, result.stderr).toBeUndefined() + return { stdout: result.stdout, stderr: result.stderr, status: result.status } +} + describe('CursorHookService', () => { let homeDir: string @@ -156,4 +202,102 @@ describe('CursorHookService', () => { '/usr/local/bin/retired-user-hook' ]) }) + + // Why: installer-intent assertions missed empty stdout, which Cursor treats as + // invalid JSON and fails closed (#15462). This runs the registered command. + it('emits protocol-valid JSON on stdout for every managed event, including empty stdin (#15462)', () => { + expect(new CursorHookService().install().state).toBe('installed') + const config = readInstalledCursorHooks(homeDir) + const payloads = [ + (eventName: string) => JSON.stringify({ hook_event_name: eventName, tool_name: 'Write' }), + () => '' + ] + + for (const eventName of CURSOR_EVENTS) { + const command = requireRegisteredCommand(config, eventName) + for (const payloadFor of payloads) { + const result = runRegisteredCursorHook(command, payloadFor(eventName)) + expect(result.status, `${eventName} exit`).toBe(0) + expect(result.stderr, `${eventName} stderr`).toBe('') + expect(JSON.parse(result.stdout), `${eventName} stdout`).toEqual( + EXPECTED_CURSOR_HOOK_STDOUT[eventName] + ) + } + } + }) + + it('emits protocol-valid JSON when the managed Cursor script is missing (#15462)', () => { + expect(new CursorHookService().install().state).toBe('installed') + const config = readInstalledCursorHooks(homeDir) + unlinkSync(join(homeDir, '.orca', 'agent-hooks', CURSOR_SCRIPT_FILE_NAME)) + + for (const eventName of CURSOR_EVENTS) { + const command = requireRegisteredCommand(config, eventName) + const result = runRegisteredCursorHook(command, '') + expect(result.status, `${eventName} missing-script exit`).toBe(0) + expect(result.stderr, `${eventName} missing-script stderr`).toBe('') + expect(JSON.parse(result.stdout), `${eventName} missing-script stdout`).toEqual( + EXPECTED_CURSOR_HOOK_STDOUT[eventName] + ) + } + }) + + it('keeps curl failure off stdout when the listener is unreachable (#15462)', () => { + expect(new CursorHookService().install().state).toBe('installed') + const config = readInstalledCursorHooks(homeDir) + + for (const eventName of ['beforeSubmitPrompt', 'preToolUse', 'stop'] as const) { + const command = requireRegisteredCommand(config, eventName) + const result = runRegisteredCursorHook( + command, + JSON.stringify({ hook_event_name: eventName, tool_name: 'Write' }), + { + ORCA_AGENT_HOOK_PORT: '59999', + ORCA_AGENT_HOOK_TOKEN: 'token', + ORCA_PANE_KEY: 'tab:leaf' + } + ) + expect(result.status, `${eventName} dead-listener exit`).toBe(0) + expect(JSON.parse(result.stdout), `${eventName} dead-listener stdout`).toEqual( + EXPECTED_CURSOR_HOOK_STDOUT[eventName] + ) + } + }) + + it.skipIf(process.platform !== 'win32')( + 'emits parseable JSON through cmd.exe and Git Bash (#14825/#15462)', + () => { + expect(new CursorHookService().install().state).toBe('installed') + const config = readInstalledCursorHooks(homeDir) + const gitBash = findGitBash() + const shells = [ + { name: 'cmd.exe', executable: 'cmd.exe', args: ['/d', '/c'] }, + { name: 'Git Bash', executable: gitBash, args: ['-c'] } + ] + for (const eventName of ['beforeSubmitPrompt', 'preToolUse'] as const) { + const command = requireRegisteredCommand(config, eventName) + for (const shell of shells) { + const result = spawnSync(shell.executable, [...shell.args, command], { + encoding: 'utf8', + input: JSON.stringify({ hook_event_name: eventName, tool_name: 'Write' }), + timeout: 15_000, + env: { + ...process.env, + ORCA_AGENT_HOOK_ENDPOINT: '', + ORCA_AGENT_HOOK_PORT: '', + ORCA_AGENT_HOOK_TOKEN: '', + ORCA_PANE_KEY: '', + USERPROFILE: homeDir + } + }) + expect(result.error, `${eventName} ${shell.name}`).toBeUndefined() + expect(result.status, `${eventName} ${shell.name} exit`).toBe(0) + expect(result.stderr, `${eventName} ${shell.name} stderr`).toBe('') + expect(JSON.parse(result.stdout), `${eventName} ${shell.name} stdout`).toEqual( + EXPECTED_CURSOR_HOOK_STDOUT[eventName] + ) + } + } + } + ) }) diff --git a/src/main/cursor/hook-service.ts b/src/main/cursor/hook-service.ts index 310afad939c..b07639f108a 100644 --- a/src/main/cursor/hook-service.ts +++ b/src/main/cursor/hook-service.ts @@ -5,12 +5,9 @@ import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared import { buildManagedCommandDefinition, createManagedCommandMatcher, - buildWindowsAgentHookPostCommand, getSharedManagedScriptPath, readHooksJson, removeManagedCommands, - wrapPosixHookCommand, - wrapWindowsHookCommand, writeHooksJson, writeManagedScript, type HookDefinition @@ -21,25 +18,8 @@ import { writeHooksJsonRemote, writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' -import { - buildPosixHookPayloadCapture, - buildPosixHookSpoolLines, - buildWindowsHookEnvironmentGuardLines, - buildWindowsHookStdinDrainEpilogue -} from '../agent-hooks/hook-stdin-contract' - -// Subscribe only to Cursor hooks needed for spinner and turn detection. -// Exclude process-boundary session hooks, which can reset the submitted-turn prompt cache. -const CURSOR_EVENTS = [ - 'beforeSubmitPrompt', - 'stop', - 'preToolUse', - 'postToolUse', - 'postToolUseFailure', - 'beforeShellExecution', - 'beforeMCPExecution', - 'afterAgentResponse' -] as const +import { CURSOR_EVENTS } from './hook-events' +import { getManagedCommand, getManagedScript, getPosixManagedCommand } from './hook-script' function getConfigPath(): string { return join(homedir(), '.cursor', 'hooks.json') @@ -53,6 +33,7 @@ function getManagedScriptPath(): string { return getSharedManagedScriptPath(getManagedScriptFileName()) } +/* function getManagedCommand(scriptPath: string): string { return process.platform === 'win32' ? wrapWindowsHookCommand(scriptPath) @@ -103,7 +84,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { '' ].join('\n') } - +*/ export class CursorHookService { async refreshManagedScripts(): Promise { await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) @@ -123,10 +104,10 @@ export class CursorHookService { } } - const command = getManagedCommand(scriptPath) const missing: string[] = [] let presentCount = 0 for (const eventName of CURSOR_EVENTS) { + const command = getManagedCommand(scriptPath, eventName) const definitions = Array.isArray(config.hooks?.[eventName]) ? config.hooks![eventName]! : [] // Why: Cursor puts command directly on the definition (Claude nests under `hooks`); match both shapes. const hasCommand = definitions.some( @@ -170,7 +151,6 @@ export class CursorHookService { } } - const command = getManagedCommand(scriptPath) // Why: config.hooks is undefined on a fresh file with no prior hook install. const nextHooks = { ...config.hooks } const managedEvents = new Set(CURSOR_EVENTS) @@ -189,7 +169,7 @@ export class CursorHookService { const cleaned = removeManagedCommands(definitions, isManagedCommand) // Also strip entries with the command at the top level (Cursor schema). const strippedCursorShape = cleaned.filter( - (definition) => !isManagedCommand(definition.command as string | undefined) + (definition) => !isManagedCommand(definition.command) ) if (strippedCursorShape.length === 0) { delete nextHooks[eventName] @@ -199,10 +179,11 @@ export class CursorHookService { } for (const eventName of CURSOR_EVENTS) { + const command = getManagedCommand(scriptPath, eventName) const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] // Sweep Claude- and Cursor-shaped variants so installs converge on one entry. const cleaned = removeManagedCommands(current, isManagedCommand).filter( - (definition) => !isManagedCommand(definition.command as string | undefined) + (definition) => !isManagedCommand(definition.command) ) // Why: Cursor's schema puts `command` directly on the definition (not under `hooks`); emit that shape. const definition: HookDefinition = buildManagedCommandDefinition(command) @@ -235,15 +216,15 @@ export class CursorHookService { } } - const command = wrapPosixHookCommand(remoteScriptPath) const nextHooks = { ...config.hooks } const isManagedCommand = createManagedCommandMatcher('cursor-hook.sh') for (const eventName of CURSOR_EVENTS) { + const command = getPosixManagedCommand(remoteScriptPath, eventName) const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] // Why: dual-shape sweep so repeated installs converge on a single managed entry. const cleaned = removeManagedCommands(current, isManagedCommand).filter( - (definition) => !isManagedCommand(definition.command as string | undefined) + (definition) => !isManagedCommand(definition.command) ) const definition: HookDefinition = buildManagedCommandDefinition(command) nextHooks[eventName] = [...cleaned, definition] @@ -297,7 +278,7 @@ export class CursorHookService { continue } const cleaned = removeManagedCommands(definitions, isManagedCommand).filter( - (definition) => !isManagedCommand(definition.command as string | undefined) + (definition) => !isManagedCommand(definition.command) ) if (cleaned.length === 0) { delete nextHooks[eventName] From 026389a3bc03da03ca2d65295e805493712b0774 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:26:55 -0700 Subject: [PATCH 052/152] Suppress default quit on window-all-closed in DNS probe (#16683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Suppress default quit on window-all-closed in DNS probe Destroying the probe window awaits stopLogging, which yields to the event loop long enough for the default window-all-closed exit (non-macOS) to trigger before the result can be written. Preventing this default behavior allows the result to complete and exit cleanly. * Preserve Japanese Skills UI labels; fix test flakiness and deps - Add skipKeyPrefixes filter to Japanese phrase fixes to prevent automatic translation of UI labels (e.g., keep エージェント in Skills components) - Wrap timing-dependent tests in vi.waitFor to eliminate race conditions during reconciliation and scheduler boundary ticks - Complete useEffect dependency arrays to resolve React hook warnings * Fix updater startup scheduling test flakiness Set last update check to 23h ago instead of null to make timing deterministic. The startup check arms its own 24h timer; by pre-setting the last check time, only the result handler's re-arm can produce the expected check 24h later, eliminating race conditions. --- config/scripts/locale-ja-phrase-fixes.mjs | 8 +++++++- ...nslation-policy.ja-relocalization.test.mjs | 10 ++++++++++ config/scripts/locale-translation-policy.mjs | 11 +++++++---- ...rowser-route-dns-prefetch-electron-main.ts | 4 ++++ .../ipc/worktree-git-common-watch.test.ts | 15 ++++++++++++--- src/main/updater.startup-scheduling.test.ts | 13 +++++++++++-- src/renderer/src/components/TaskPage.tsx | 19 ++++++++++++++++--- 7 files changed, 67 insertions(+), 13 deletions(-) diff --git a/config/scripts/locale-ja-phrase-fixes.mjs b/config/scripts/locale-ja-phrase-fixes.mjs index 504c5d25889..8c80544eba2 100644 --- a/config/scripts/locale-ja-phrase-fixes.mjs +++ b/config/scripts/locale-ja-phrase-fixes.mjs @@ -37,7 +37,13 @@ const RELOCALIZED_GENERIC_TERMS = [ export const JA_PHRASE_FIXES = [ ...RELOCALIZED_GENERIC_TERMS, - { pattern: /エージェント/g, replacement: 'Agent', whenEnIncludes: 'agent' }, + { + pattern: /エージェント/g, + replacement: 'Agent', + whenEnIncludes: 'agent', + // Skills filters and metadata are Japanese UI labels, not agent product prose. + skipKeyPrefixes: ['auto.components.skills.'] + }, { pattern: /解雇/g, replacement: '閉じる', whenEnIncludes: 'Dismiss' }, { pattern: /却下/g, replacement: '閉じる', whenEnIncludes: 'Dismiss' }, { pattern: /代理人/g, replacement: 'Agent', whenEnIncludes: 'agent' }, diff --git a/config/scripts/locale-translation-policy.ja-relocalization.test.mjs b/config/scripts/locale-translation-policy.ja-relocalization.test.mjs index c91909ad69d..26ff48c878c 100644 --- a/config/scripts/locale-translation-policy.ja-relocalization.test.mjs +++ b/config/scripts/locale-translation-policy.ja-relocalization.test.mjs @@ -79,6 +79,16 @@ describe('locale-translation-policy ja relocalization', () => { expect(ja('Open the browser', 'ブラウザーを開く')).toBe('ブラウザを開く') }) + it('preserves translated Japanese Skills labels', () => { + expect( + ja( + 'Filter by agent', + 'エージェントで絞り込み', + 'auto.components.skills.SkillsPage.filterProvider' + ) + ).toBe('エージェントで絞り込み') + }) + it('leaves ranges and token samples out of the ellipsis rule', () => { expect(ja('Compare main...HEAD', 'main...HEAD を比較')).toBe('main...HEAD を比較') }) diff --git a/config/scripts/locale-translation-policy.mjs b/config/scripts/locale-translation-policy.mjs index eb1a7ea0b92..9fd4350ee45 100644 --- a/config/scripts/locale-translation-policy.mjs +++ b/config/scripts/locale-translation-policy.mjs @@ -351,12 +351,15 @@ function phraseFixMatchesEnglish(enValue, fix) { return enValue.toLowerCase().includes(fix.whenEnIncludes.toLowerCase()) } -function applyPhraseFixes(enValue, localeValue, locale) { +function applyPhraseFixes(enValue, localeValue, locale, key = '') { let result = localeValue for (const fix of LOCALE_PHRASE_FIXES[locale] ?? []) { if (!phraseFixMatchesEnglish(enValue, fix)) { continue } + if (fix.skipKeyPrefixes?.some((prefix) => key.startsWith(prefix))) { + continue + } result = result.replace(fix.pattern, fix.replacement) } return result @@ -367,7 +370,7 @@ export function repairTranslatedValue({ key, enValue, localeValue, locale }) { if (keyOverride) { // Why: exact key overrides can still carry stale MT output, so glossary repairs remain the final gate. let result = applyBrandMistranslationFixes(enValue, keyOverride, locale, key) - result = applyPhraseFixes(enValue, result, locale) + result = applyPhraseFixes(enValue, result, locale, key) if (['zh', 'ja', 'ko'].includes(locale)) { result = applyCjkLatinTermSpacing(result, locale) } @@ -377,7 +380,7 @@ export function repairTranslatedValue({ key, enValue, localeValue, locale }) { const valueOverride = LOCALE_VALUE_OVERRIDES[locale]?.[enValue] if (valueOverride) { let result = applyBrandMistranslationFixes(enValue, valueOverride, locale, key) - result = applyPhraseFixes(enValue, result, locale) + result = applyPhraseFixes(enValue, result, locale, key) if (['zh', 'ja', 'ko'].includes(locale)) { result = applyCjkLatinTermSpacing(result, locale) } @@ -398,7 +401,7 @@ export function repairTranslatedValue({ key, enValue, localeValue, locale }) { } result = applyBrandMistranslationFixes(enValue, result, locale, key) - result = applyPhraseFixes(enValue, result, locale) + result = applyPhraseFixes(enValue, result, locale, key) if (['zh', 'ja', 'ko'].includes(locale)) { result = applyCjkLatinTermSpacing(result, locale) } diff --git a/src/main/browser/browser-route-dns-prefetch-electron-main.ts b/src/main/browser/browser-route-dns-prefetch-electron-main.ts index 5d98d5e3816..d53bb721f34 100644 --- a/src/main/browser/browser-route-dns-prefetch-electron-main.ts +++ b/src/main/browser/browser-route-dns-prefetch-electron-main.ts @@ -43,6 +43,10 @@ async function probe() { return { resolvedProxy } } +// Why: destroying the probe window awaits stopLogging, which yields long enough for the default +// window-all-closed quit (non-macOS) to exit 0 before the result is written. +app.on('window-all-closed', () => {}) + async function run() { const timeout = setTimeout(() => app.exit(2), 25000) await app.whenReady() diff --git a/src/main/ipc/worktree-git-common-watch.test.ts b/src/main/ipc/worktree-git-common-watch.test.ts index cc893b8604a..619133263de 100644 --- a/src/main/ipc/worktree-git-common-watch.test.ts +++ b/src/main/ipc/worktree-git-common-watch.test.ts @@ -752,9 +752,18 @@ describe('worktree git-common narrow watch (local native platforms)', () => { const stalePendingSubscription = narrowSubscriptions()[1] await replaceWorktreesRoot(commonDir, worktreesDir, retainedEntry) await vi.advanceTimersByTimeAsync(POLL_MS * RECONCILIATION_TICKS * 4) - expect( - received.flat().filter((event) => event.type === 'create' && event.path === worktreesDir) - ).toHaveLength(2) + // Reconciliation's snapshot is real filesystem work, so advancing the fake + // clock schedules the second replacement's tick but does not settle it. + await vi.waitFor( + () => { + expect( + received + .flat() + .filter((event) => event.type === 'create' && event.path === worktreesDir) + ).toHaveLength(2) + }, + { timeout: 2_000 } + ) const beforeStaleInterruption = received.length stalePendingSubscription.hooks.onInterruption?.() diff --git a/src/main/updater.startup-scheduling.test.ts b/src/main/updater.startup-scheduling.test.ts index 369cf85d1dd..6cf7cf5f00d 100644 --- a/src/main/updater.startup-scheduling.test.ts +++ b/src/main/updater.startup-scheduling.test.ts @@ -216,11 +216,17 @@ describe('updater', () => { const { setupAutoUpdater } = await import('./updater') + // Why: a startup check also arms its own 24h timer, which would fire at the same boundary as the + // reschedule under test; entering 23h in makes the startup timer fire the check itself, so only + // the result handler's re-arm can produce a check 24h later. setupAutoUpdater(mainWindow as never, { - getLastUpdateCheckAt: () => null, + getLastUpdateCheckAt: () => Date.now() - 23 * 60 * 60 * 1000, setLastUpdateCheckAt }) + expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(60 * 60 * 1000) await vi.waitFor(() => { expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) }) @@ -237,8 +243,11 @@ describe('updater', () => { expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) await vi.advanceTimersByTimeAsync(60 * 1000) + // Why: the boundary tick sweeps the updater's other timers (30-minute nudge poll, 45-second + // stall guard) too, so pin the reschedule itself — nothing before 24h, a check once it elapses — + // rather than an exact process-wide call total. await vi.waitFor(() => { - expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2) + expect(autoUpdaterMock.checkForUpdates.mock.calls.length).toBeGreaterThanOrEqual(2) }) }) diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 901fcdf3f6e..4ef3cc430f6 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -2179,7 +2179,7 @@ export default function TaskPage(): React.JSX.Element { setAppliedLinearProjectSearch(linearProjectSearchInput) }, TASK_SEARCH_DEBOUNCE_MS) return () => window.clearTimeout(timeout) - }, [linearProjectSearchInput, taskResumeApplied]) + }, [linearProjectSearchInput, setAppliedLinearProjectSearch, taskResumeApplied]) useEffect(() => { if (!taskResumeApplied || taskSource !== 'linear' || linearMode !== 'projects') { @@ -2274,6 +2274,12 @@ export default function TaskPage(): React.JSX.Element { fetchLinearProject, linearRefreshNonce, selectedLinearProject, + setLinearProjectDetailError, + setLinearProjectDetailLoading, + setLinearProjectParentView, + setLinearProjectsError, + setSelectedLinearProject, + setSelectedLinearProjectDetail, setTaskResumeState, linearTaskSourceContext ]) @@ -2315,7 +2321,10 @@ export default function TaskPage(): React.JSX.Element { linearRefreshNonce, listLinearProjectIssues, linearTaskSourceContext, - selectedLinearProject + selectedLinearProject, + setLinearProjectIssuesError, + setLinearProjectIssuesLoading, + setLinearProjectIssuesResult ]) useEffect(() => { @@ -2433,7 +2442,11 @@ export default function TaskPage(): React.JSX.Element { listLinearCustomViewIssues, listLinearCustomViewProjects, linearTaskSourceContext, - selectedLinearCustomView + selectedLinearCustomView, + setLinearCustomViewContentsError, + setLinearCustomViewContentsLoading, + setLinearCustomViewIssuesResult, + setLinearCustomViewProjectsResult ]) useEffect(() => { From 07b7e9e68d304d24af068ed8aa6b4bfec0ee718b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:49:02 -0700 Subject: [PATCH 053/152] Open target=_blank links and unnamed popups in new Orca tabs (#16720) * feat(browser): open target=_blank links and unnamed popups in new Orca t - Treat target=_blank as a new-tab request matching browser behavior - Route unnamed, featureless window.open() calls to Orca tabs instead of native popups - Add rate limiting to prevent page-initiated tab loops - Inherit session profiles when opening links to maintain isolation boundaries * fix(browser): deny new-tab window.open when renderer is destroyed Move deny action outside conditional to ensure new-tab intents are safely rejected even if renderer vanishes mid-open, preventing native popup fallthrough. Add test coverage and simplify comments. * Share page-initiated tab budget across opener popup tree Prevent pages from bypassing the new-tab rate limit by chaining popup windows. The page-initiated tab quota is now shared by all popups in an opener tree (root + named children), so child windows inherit their root's budget instead of each getting a fresh allocation. --- .../browser-clicked-link-routing.test.ts | 28 +- .../browser/browser-clicked-link-routing.ts | 26 +- .../browser-manager-popup-child.test.ts | 253 ++++++++++++ .../browser-manager-popup-routing.test.ts | 381 +++++++++--------- src/main/browser/browser-manager.ts | 51 ++- .../browser-page-initiated-tab-budget.test.ts | 34 ++ .../browser-page-initiated-tab-budget.ts | 26 ++ .../browser/browser-popup-new-tab-intent.ts | 17 + .../ipc-events/browser-state-ipc-bridge.ts | 15 +- .../browser-state-open-link-profile.test.ts | 104 +++++ tests/e2e/browser-tab.spec.ts | 23 +- 11 files changed, 708 insertions(+), 250 deletions(-) create mode 100644 src/main/browser/browser-manager-popup-child.test.ts create mode 100644 src/main/browser/browser-page-initiated-tab-budget.test.ts create mode 100644 src/main/browser/browser-page-initiated-tab-budget.ts create mode 100644 src/main/browser/browser-popup-new-tab-intent.ts create mode 100644 src/renderer/src/hooks/ipc-events/browser-state-open-link-profile.test.ts diff --git a/src/main/browser/browser-clicked-link-routing.test.ts b/src/main/browser/browser-clicked-link-routing.test.ts index eee5eea35f1..24c26471c3a 100644 --- a/src/main/browser/browser-clicked-link-routing.test.ts +++ b/src/main/browser/browser-clicked-link-routing.test.ts @@ -64,7 +64,7 @@ describe('browser clicked-link routing', () => { vi.restoreAllMocks() }) - it('routes plain target=_blank links back into the current Orca tab', () => { + it('routes plain target=_blank links into a new Orca tab', () => { const link = document.createElement('a') link.href = 'https://docs.example.com/guide' link.target = '_blank' @@ -73,9 +73,8 @@ describe('browser clicked-link routing', () => { const { event, open } = clickLink(link) - expect(event.defaultPrevented).toBe(false) - expect(open).not.toHaveBeenCalled() - expect(link.getAttribute('target')).toBe('_self') + expect(event.defaultPrevented).toBe(true) + expect(open).toHaveBeenCalledWith('https://docs.example.com/guide', FOREGROUND_FRAME_NAME) }) it('routes the host-platform modifier without trusting an emulated guest user agent', () => { @@ -127,9 +126,11 @@ describe('browser clicked-link routing', () => { expect(clickLink(cancelled).open).not.toHaveBeenCalled() expect(cancelled.getAttribute('target')).toBe('_blank') - expect(clickLink(rewritten).open).not.toHaveBeenCalled() - expect(rewritten.href).toBe('https://example.com/rewritten') - expect(rewritten.getAttribute('target')).toBe('_self') + // The page's own handler ran first, so routing must follow the rewritten href. + expect(clickLink(rewritten).open).toHaveBeenCalledWith( + 'https://example.com/rewritten', + FOREGROUND_FRAME_NAME + ) }) it('routes SVG links but leaves download links and links without href alone', () => { @@ -145,8 +146,10 @@ describe('browser clicked-link routing', () => { document.body.append(svgLink, areaDownload, noHref) installRouting() - expect(clickLink(svgLink).open).not.toHaveBeenCalled() - expect(svgLink.getAttribute('target')).toBe('_self') + expect(clickLink(svgLink).open).toHaveBeenCalledWith( + 'https://example.com/svg', + FOREGROUND_FRAME_NAME + ) expect(clickLink(areaDownload).open).not.toHaveBeenCalled() expect(clickLink(noHref).open).not.toHaveBeenCalled() }) @@ -195,7 +198,7 @@ describe('browser clicked-link routing', () => { expect(script).not.toContain('BrowserClickedLinkRoutingState') }) - it('routes plain iframe target=_blank links into the top-level guest', () => { + it('routes plain iframe target=_blank links into a new Orca tab', () => { const link = document.createElement('a') link.href = 'https://example.com/from-frame' link.target = '_blank' @@ -204,9 +207,8 @@ describe('browser clicked-link routing', () => { const { event, open } = clickLink(link) - expect(event.defaultPrevented).toBe(false) - expect(open).not.toHaveBeenCalled() - expect(link.getAttribute('target')).toBe('_top') + expect(event.defaultPrevented).toBe(true) + expect(open).toHaveBeenCalledWith('https://example.com/from-frame', FOREGROUND_FRAME_NAME) }) it('routes explicit iframe new-tab gestures through one-use frame names', () => { diff --git a/src/main/browser/browser-clicked-link-routing.ts b/src/main/browser/browser-clicked-link-routing.ts index 42a8bbf8232..ee6f2fc43d5 100644 --- a/src/main/browser/browser-clicked-link-routing.ts +++ b/src/main/browser/browser-clicked-link-routing.ts @@ -75,8 +75,9 @@ export function installBrowserClickedLinkRouting( const baseTarget = document.querySelector('base[target]')?.getAttribute('target') ?? '' const ownTarget = link.getAttribute('target') const effectiveTarget = (ownTarget === null ? baseTarget : ownTarget).trim().toLowerCase() - const opensNewContext = middleClick || modifierClick - if (!opensNewContext && effectiveTarget !== '_blank') { + // target=_blank is a new-tab request, exactly as it is in every other + // browser; the modifiers are the other two ways to ask for one. + if (!(middleClick || modifierClick || effectiveTarget === '_blank')) { return } @@ -96,13 +97,6 @@ export function installBrowserClickedLinkRouting( return } - if (!opensNewContext) { - // Why: changing only the browsing context keeps Chromium's native anchor - // navigation, including referrer policy, attribution, and history. - link.setAttribute('target', '_self') - return - } - // Why: Electron reports direct link clicks and featureless window.open() // with the same disposition. The private frame name preserves that one // distinction without weakening OAuth popups that need window.opener. @@ -118,8 +112,8 @@ export function installBrowserClickedLinkRouting( } /** - * Keeps plain target=_blank clicks inside the top-level guest when Electron's - * isolated-world API cannot target a child frame. + * Same routing for links inside child frames, which Electron's isolated-world + * API cannot reach, so this runs in the page world against a one-use token. */ export function installBrowserIframeClickedLinkRouting( frameName: string, @@ -161,8 +155,7 @@ export function installBrowserIframeClickedLinkRouting( const baseTarget = document.querySelector('base[target]')?.getAttribute('target') ?? '' const ownTarget = link.getAttribute('target') const effectiveTarget = (ownTarget === null ? baseTarget : ownTarget).trim().toLowerCase() - const opensNewContext = middleClick || modifierClick - if (!opensNewContext && effectiveTarget !== '_blank') { + if (!(middleClick || modifierClick || effectiveTarget === '_blank')) { return } @@ -182,13 +175,6 @@ export function installBrowserIframeClickedLinkRouting( return } - if (!opensNewContext) { - // Why: WebContents isolated worlds only cover the main frame. Rewriting - // to `_top` preserves native anchor semantics without opening a popup. - link.setAttribute('target', '_top') - return - } - // Why: child-frame code runs in the page world, so each token is one-use. // A page that observes a real click cannot replay it to create more tabs. event.preventDefault() diff --git a/src/main/browser/browser-manager-popup-child.test.ts b/src/main/browser/browser-manager-popup-child.test.ts new file mode 100644 index 00000000000..bf4caf2b0ba --- /dev/null +++ b/src/main/browser/browser-manager-popup-child.test.ts @@ -0,0 +1,253 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const browserMocks = vi.hoisted(() => ({ + appGetPathMock: vi.fn(() => '/downloads'), + menuBuildFromTemplateMock: vi.fn(), + guestOffMock: vi.fn(), + guestOnMock: vi.fn(), + guestSetBackgroundThrottlingMock: vi.fn(), + guestSetWindowOpenHandlerMock: vi.fn(), + guestOpenDevToolsMock: vi.fn(), + webContentsFromIdMock: vi.fn(), + browserWindowFromWebContentsMock: vi.fn(), + screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })), + shellOpenExternalMock: vi.fn(), + openPopupWithOriginBarMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: browserMocks.appGetPathMock }, + BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock }, + clipboard: { writeText: vi.fn() }, + shell: { openExternal: browserMocks.shellOpenExternalMock }, + Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock }, + screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock }, + webContents: { fromId: browserMocks.webContentsFromIdMock } +})) + +vi.mock('./popup-origin-bar-window', () => ({ + openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock +})) + +import { browserManager } from './browser-manager' +import { + createDownloadItem, + getDownloadItemEventHandler, + rendererWebContentsId, + resetBrowserManagerMocks, + resetBrowserManagerState +} from './browser-manager-test-harness' + +const { + guestOffMock, + guestOnMock, + guestSetBackgroundThrottlingMock, + guestSetWindowOpenHandlerMock, + guestOpenDevToolsMock, + webContentsFromIdMock, + shellOpenExternalMock +} = browserMocks + +describe('browserManager popup child policies', () => { + beforeEach(() => { + resetBrowserManagerMocks(browserMocks) + resetBrowserManagerState() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('attaches guest policies to created popup child windows', () => { + const rendererSendMock = vi.fn() + const childSetBackgroundThrottlingMock = vi.fn() + const childSetWindowOpenHandlerMock = vi.fn() + const childOnMock = vi.fn() + const childOffMock = vi.fn() + const childOpenDevToolsMock = vi.fn() + const childGuest = { + id: 4040, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: childSetBackgroundThrottlingMock, + setWindowOpenHandler: childSetWindowOpenHandlerMock, + on: childOnMock, + off: childOffMock, + openDevTools: childOpenDevToolsMock + } + const guest = { + id: 404, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockImplementation((id: number) => { + if (id === guest.id) { + return guest + } + if (id === rendererWebContentsId) { + return { isDestroyed: vi.fn(() => false), send: rendererSendMock } + } + return null + }) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-1', + webContentsId: guest.id, + rendererWebContentsId + }) + + const didCreateWindowHandler = guestOnMock.mock.calls.find( + ([event]) => event === 'did-create-window' + )?.[1] as ((window: { webContents: typeof childGuest }) => void) | undefined + expect(didCreateWindowHandler).toBeTypeOf('function') + + didCreateWindowHandler?.({ webContents: childGuest }) + + expect(childSetBackgroundThrottlingMock).toHaveBeenCalledWith(false) + expect(childSetWindowOpenHandlerMock).toHaveBeenCalledTimes(1) + expect(childOnMock.mock.calls.filter(([event]) => event === 'did-create-window')).toHaveLength( + 1 + ) + expect(childOnMock.mock.calls.filter(([event]) => event === 'will-navigate')).toHaveLength(1) + expect(childOnMock.mock.calls.filter(([event]) => event === 'will-redirect')).toHaveLength(1) + + const childWindowOpenHandler = childSetWindowOpenHandlerMock.mock.calls[0][0] as (details: { + url: string + }) => { action: 'allow' | 'deny' } + expect(childWindowOpenHandler({ url: 'https://identity.example.com/login' })).toMatchObject({ + action: 'allow' + }) + expect(childWindowOpenHandler({ url: 'file:///etc/passwd' })).toEqual({ action: 'deny' }) + expect(rendererSendMock).toHaveBeenCalledWith('browser:popup', { + browserPageId: 'browser-1', + origin: 'null', + action: 'blocked' + }) + browserManager.notifyPermissionDenied({ + guestWebContentsId: childGuest.id, + permission: 'notifications', + rawUrl: 'https://identity.example.com/login' + }) + expect(rendererSendMock).toHaveBeenCalledWith('browser:permission-denied', { + browserPageId: 'browser-1', + permission: 'notifications', + origin: 'https://identity.example.com' + }) + + const childDidFailLoadHandler = childOnMock.mock.calls.find( + ([event]) => event === 'did-fail-load' + )?.[1] as + | (( + event: Electron.Event, + errorCode: number, + errorDescription: string, + validatedURL: string, + isMainFrame: boolean + ) => void) + | undefined + childDidFailLoadHandler?.( + {} as Electron.Event, + -105, + 'Name not resolved', + 'https://identity.example.com/unavailable', + true + ) + expect(rendererSendMock).not.toHaveBeenCalledWith( + 'browser:guest-load-failed', + expect.anything() + ) + + const childDownloadItem = createDownloadItem() + browserManager.handleGuestWillDownload({ + guestWebContentsId: childGuest.id, + item: childDownloadItem + }) + expect(rendererSendMock).toHaveBeenCalledWith( + 'browser:download-requested', + expect.objectContaining({ browserPageId: 'browser-1' }) + ) + const childDownloadDoneHandler = getDownloadItemEventHandler(childDownloadItem, 'once', 'done') + childDownloadDoneHandler?.({} as Electron.Event, 'completed') + + const managerState = browserManager as unknown as { + popupOwnerContextByGuestId: Map + } + expect(managerState.popupOwnerContextByGuestId.has(childGuest.id)).toBe(true) + + const cleanupChildOnMock = vi.fn() + const cleanupChildGuest = { + ...childGuest, + id: 4041, + on: cleanupChildOnMock, + off: vi.fn(), + setBackgroundThrottling: vi.fn(), + setWindowOpenHandler: vi.fn() + } + const childDidCreateWindowHandler = childOnMock.mock.calls.find( + ([event]) => event === 'did-create-window' + )?.[1] as ((window: { webContents: typeof cleanupChildGuest }) => void) | undefined + childDidCreateWindowHandler?.({ webContents: cleanupChildGuest }) + expect(managerState.popupOwnerContextByGuestId.has(cleanupChildGuest.id)).toBe(true) + const cleanupChildWindowOpenHandler = cleanupChildGuest.setWindowOpenHandler.mock + .calls[0][0] as (details: { url: string }) => { action: 'allow' | 'deny' } + expect( + cleanupChildWindowOpenHandler({ url: 'https://identity.example.com/continue' }) + ).toMatchObject({ action: 'allow' }) + const cleanupChildDestroyedHandler = cleanupChildOnMock.mock.calls.find( + ([event]) => event === 'destroyed' + )?.[1] as (() => void) | undefined + cleanupChildDestroyedHandler?.() + expect(managerState.popupOwnerContextByGuestId.has(cleanupChildGuest.id)).toBe(false) + + const replacementGuest = { + ...guest, + id: 405, + on: vi.fn(), + off: vi.fn(), + setBackgroundThrottling: vi.fn(), + setWindowOpenHandler: vi.fn() + } + webContentsFromIdMock.mockImplementation((id: number) => { + if (id === guest.id) { + return guest + } + if (id === replacementGuest.id) { + return replacementGuest + } + if (id === rendererWebContentsId) { + return { isDestroyed: vi.fn(() => false), send: rendererSendMock } + } + return null + }) + browserManager.attachGuestPolicies(replacementGuest as never) + browserManager.registerGuest({ + browserPageId: 'browser-1', + webContentsId: replacementGuest.id, + rendererWebContentsId + }) + + expect(childWindowOpenHandler({ url: 'https://identity.example.com/next' })).toEqual({ + action: 'deny' + }) + expect(shellOpenExternalMock).toHaveBeenCalledWith('https://identity.example.com/next') + expect(managerState.popupOwnerContextByGuestId.has(childGuest.id)).toBe(false) + + const childDestroyedHandler = childOnMock.mock.calls.find( + ([event]) => event === 'destroyed' + )?.[1] as (() => void) | undefined + childDestroyedHandler?.() + expect(managerState.popupOwnerContextByGuestId.has(childGuest.id)).toBe(false) + + browserManager.unregisterAll() + + expect(childOffMock).toHaveBeenCalledWith('did-create-window', expect.any(Function)) + expect(childOffMock).toHaveBeenCalledWith('will-navigate', expect.any(Function)) + expect(childOffMock).toHaveBeenCalledWith('will-redirect', expect.any(Function)) + }) +}) diff --git a/src/main/browser/browser-manager-popup-routing.test.ts b/src/main/browser/browser-manager-popup-routing.test.ts index 89b4d2fe2a8..a3083005016 100644 --- a/src/main/browser/browser-manager-popup-routing.test.ts +++ b/src/main/browser/browser-manager-popup-routing.test.ts @@ -40,9 +40,8 @@ vi.mock('./popup-origin-bar-window', () => ({ })) import { browserManager } from './browser-manager' +import { MAX_PAGE_INITIATED_TABS_PER_WINDOW } from './browser-page-initiated-tab-budget' import { - createDownloadItem, - getDownloadItemEventHandler, rendererWebContentsId, resetBrowserManagerMocks, resetBrowserManagerState @@ -169,11 +168,12 @@ describe('browserManager', () => { expect(rendererSendMock).not.toHaveBeenCalled() }) - it('keeps featureless window.open popups in-app for every disposition', () => { + it('keeps opener-dependent window.open popups in-app for every disposition', () => { // Regression guard for the reverted #8332: gating the allow on // disposition === 'new-window' silently broke featureless window.open() - // OAuth flows (disposition 'foreground-tab'), whose returned handle must - // stay live. Disposition is a UX hint, not a trust signal. + // OAuth flows, whose returned handle must stay live. A named target, a + // features string, and a blank URL each mark such a flow, so all three keep + // a real child window no matter which disposition Chromium reports. const guest = { id: 140, isDestroyed: vi.fn(() => false), @@ -199,14 +199,186 @@ describe('browserManager', () => { features: string disposition: string }) => { action: 'allow' | 'deny' } + const openerDependentOpens = [ + { url: 'https://sso.example.com/auth', frameName: 'ssoWindow', features: '' }, + { url: 'https://sso.example.com/auth', frameName: '', features: 'width=500,height=600' }, + { url: 'about:blank', frameName: '', features: '' } + ] for (const disposition of ['foreground-tab', 'background-tab', 'new-window']) { - expect( - handler({ url: 'https://sso.example.com/auth', frameName: '', features: '', disposition }) - ).toMatchObject({ action: 'allow' }) + for (const open of openerDependentOpens) { + expect(handler({ ...open, disposition })).toMatchObject({ action: 'allow' }) + } } expect(shellOpenExternalMock).not.toHaveBeenCalled() }) + it('routes unnamed featureless window.open safely, including after renderer destruction', () => { + const rendererSendMock = vi.fn() + let rendererDestroyed = false + const guest = { + id: 142, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockImplementation((id: number) => { + if (id === guest.id) { + return guest + } + if (id === rendererWebContentsId) { + return { isDestroyed: vi.fn(() => rendererDestroyed), send: rendererSendMock } + } + return null + }) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-1', + webContentsId: guest.id, + rendererWebContentsId + }) + + const handler = guestSetWindowOpenHandlerMock.mock.calls[0][0] as (details: { + url: string + frameName: string + features: string + disposition: string + }) => { action: 'allow' | 'deny' } + for (const disposition of ['foreground-tab', 'background-tab']) { + expect( + handler({ + url: 'https://docs.example.com/guide', + frameName: '', + features: '', + disposition + }) + ).toEqual({ action: 'deny' }) + } + + expect(rendererSendMock).toHaveBeenCalledWith('browser:open-link-in-orca-tab', { + browserPageId: 'browser-1', + url: 'https://docs.example.com/guide' + }) + expect(rendererSendMock).toHaveBeenCalledWith('browser:popup', { + browserPageId: 'browser-1', + origin: 'https://docs.example.com', + action: 'opened-in-orca' + }) + expect(openPopupWithOriginBarMock).not.toHaveBeenCalled() + expect(shellOpenExternalMock).not.toHaveBeenCalled() + rendererDestroyed = true + expect( + handler({ + url: 'https://docs.example.com/guide', + frameName: '', + features: '', + disposition: 'foreground-tab' + }) + ).toEqual({ action: 'deny' }) + expect(openPopupWithOriginBarMock).not.toHaveBeenCalled() + expect(shellOpenExternalMock).not.toHaveBeenCalled() + }) + + it('shares the page-initiated tab budget across the whole opener popup tree', () => { + const rendererSendMock = vi.fn() + const guest = { + id: 150, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + once: vi.fn(), + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + const guestsById = new Map([[guest.id, guest]]) + webContentsFromIdMock.mockImplementation((id: number) => { + if (id === rendererWebContentsId) { + return { isDestroyed: vi.fn(() => false), send: rendererSendMock } + } + return guestsById.get(id) ?? null + }) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-1', + webContentsId: guest.id, + rendererWebContentsId + }) + + type WindowOpenHandler = (details: { + url: string + frameName: string + features: string + disposition: string + }) => { + action: 'allow' | 'deny' + createWindow?: (options: Record) => unknown + } + const handler = guestSetWindowOpenHandlerMock.mock.calls[0][0] as WindowOpenHandler + + // A named child popup keeps a real child window; each one used to start with a fresh budget. + const openNamedChild = (index: number): WindowOpenHandler => { + const childSetWindowOpenHandlerMock = vi.fn() + const child = { + id: 1500 + index, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: vi.fn(), + setWindowOpenHandler: childSetWindowOpenHandlerMock, + on: vi.fn(), + once: vi.fn(), + off: vi.fn(), + openDevTools: vi.fn() + } + guestsById.set(child.id, child) + openPopupWithOriginBarMock.mockReturnValueOnce({ + contentWebContents: child, + close: vi.fn(), + onClosed: vi.fn() + }) + const result = handler({ + url: `https://sso.example.com/child-${index}`, + frameName: `child-${index}`, + features: '', + disposition: 'new-window' + }) + expect(result).toMatchObject({ action: 'allow' }) + result.createWindow?.({}) + return childSetWindowOpenHandlerMock.mock.calls[0][0] as WindowOpenHandler + } + + const childHandlers = [openNamedChild(0), openNamedChild(1), openNamedChild(2)] + for (const [childIndex, childHandler] of childHandlers.entries()) { + for (let openIndex = 0; openIndex < MAX_PAGE_INITIATED_TABS_PER_WINDOW; openIndex++) { + expect( + childHandler({ + url: `https://docs.example.com/${childIndex}-${openIndex}`, + frameName: '', + features: '', + disposition: 'foreground-tab' + }) + ).toEqual({ action: 'deny' }) + } + } + + const routedTabs = rendererSendMock.mock.calls.filter( + ([channel]) => channel === 'browser:open-link-in-orca-tab' + ) + expect(routedTabs).toHaveLength(MAX_PAGE_INITIATED_TABS_PER_WINDOW) + expect(rendererSendMock).toHaveBeenCalledWith('browser:popup', { + browserPageId: 'browser-1', + origin: 'https://docs.example.com', + action: 'blocked' + }) + expect(openPopupWithOriginBarMock).toHaveBeenCalledTimes(childHandlers.length) + }) + it('keeps plain links current and routes explicit new-tab gestures to Orca tabs', async () => { const rendererSendMock = vi.fn() const executeJavaScriptInIsolatedWorldMock = vi.fn().mockResolvedValue(undefined) @@ -589,197 +761,4 @@ describe('browserManager', () => { }) ) }) - - it('attaches guest policies to created popup child windows', () => { - const rendererSendMock = vi.fn() - const childSetBackgroundThrottlingMock = vi.fn() - const childSetWindowOpenHandlerMock = vi.fn() - const childOnMock = vi.fn() - const childOffMock = vi.fn() - const childOpenDevToolsMock = vi.fn() - const childGuest = { - id: 4040, - isDestroyed: vi.fn(() => false), - getType: vi.fn(() => 'webview'), - setBackgroundThrottling: childSetBackgroundThrottlingMock, - setWindowOpenHandler: childSetWindowOpenHandlerMock, - on: childOnMock, - off: childOffMock, - openDevTools: childOpenDevToolsMock - } - const guest = { - id: 404, - isDestroyed: vi.fn(() => false), - getType: vi.fn(() => 'webview'), - setBackgroundThrottling: guestSetBackgroundThrottlingMock, - setWindowOpenHandler: guestSetWindowOpenHandlerMock, - on: guestOnMock, - off: guestOffMock, - openDevTools: guestOpenDevToolsMock - } - webContentsFromIdMock.mockImplementation((id: number) => { - if (id === guest.id) { - return guest - } - if (id === rendererWebContentsId) { - return { isDestroyed: vi.fn(() => false), send: rendererSendMock } - } - return null - }) - - browserManager.attachGuestPolicies(guest as never) - browserManager.registerGuest({ - browserPageId: 'browser-1', - webContentsId: guest.id, - rendererWebContentsId - }) - - const didCreateWindowHandler = guestOnMock.mock.calls.find( - ([event]) => event === 'did-create-window' - )?.[1] as ((window: { webContents: typeof childGuest }) => void) | undefined - expect(didCreateWindowHandler).toBeTypeOf('function') - - didCreateWindowHandler?.({ webContents: childGuest }) - - expect(childSetBackgroundThrottlingMock).toHaveBeenCalledWith(false) - expect(childSetWindowOpenHandlerMock).toHaveBeenCalledTimes(1) - expect(childOnMock.mock.calls.filter(([event]) => event === 'did-create-window')).toHaveLength( - 1 - ) - expect(childOnMock.mock.calls.filter(([event]) => event === 'will-navigate')).toHaveLength(1) - expect(childOnMock.mock.calls.filter(([event]) => event === 'will-redirect')).toHaveLength(1) - - const childWindowOpenHandler = childSetWindowOpenHandlerMock.mock.calls[0][0] as (details: { - url: string - }) => { action: 'allow' | 'deny' } - expect(childWindowOpenHandler({ url: 'https://identity.example.com/login' })).toMatchObject({ - action: 'allow' - }) - expect(childWindowOpenHandler({ url: 'file:///etc/passwd' })).toEqual({ action: 'deny' }) - expect(rendererSendMock).toHaveBeenCalledWith('browser:popup', { - browserPageId: 'browser-1', - origin: 'null', - action: 'blocked' - }) - browserManager.notifyPermissionDenied({ - guestWebContentsId: childGuest.id, - permission: 'notifications', - rawUrl: 'https://identity.example.com/login' - }) - expect(rendererSendMock).toHaveBeenCalledWith('browser:permission-denied', { - browserPageId: 'browser-1', - permission: 'notifications', - origin: 'https://identity.example.com' - }) - - const childDidFailLoadHandler = childOnMock.mock.calls.find( - ([event]) => event === 'did-fail-load' - )?.[1] as - | (( - event: Electron.Event, - errorCode: number, - errorDescription: string, - validatedURL: string, - isMainFrame: boolean - ) => void) - | undefined - childDidFailLoadHandler?.( - {} as Electron.Event, - -105, - 'Name not resolved', - 'https://identity.example.com/unavailable', - true - ) - expect(rendererSendMock).not.toHaveBeenCalledWith( - 'browser:guest-load-failed', - expect.anything() - ) - - const childDownloadItem = createDownloadItem() - browserManager.handleGuestWillDownload({ - guestWebContentsId: childGuest.id, - item: childDownloadItem - }) - expect(rendererSendMock).toHaveBeenCalledWith( - 'browser:download-requested', - expect.objectContaining({ browserPageId: 'browser-1' }) - ) - const childDownloadDoneHandler = getDownloadItemEventHandler(childDownloadItem, 'once', 'done') - childDownloadDoneHandler?.({} as Electron.Event, 'completed') - - const managerState = browserManager as unknown as { - popupOwnerContextByGuestId: Map - } - expect(managerState.popupOwnerContextByGuestId.has(childGuest.id)).toBe(true) - - const cleanupChildOnMock = vi.fn() - const cleanupChildGuest = { - ...childGuest, - id: 4041, - on: cleanupChildOnMock, - off: vi.fn(), - setBackgroundThrottling: vi.fn(), - setWindowOpenHandler: vi.fn() - } - const childDidCreateWindowHandler = childOnMock.mock.calls.find( - ([event]) => event === 'did-create-window' - )?.[1] as ((window: { webContents: typeof cleanupChildGuest }) => void) | undefined - childDidCreateWindowHandler?.({ webContents: cleanupChildGuest }) - expect(managerState.popupOwnerContextByGuestId.has(cleanupChildGuest.id)).toBe(true) - const cleanupChildWindowOpenHandler = cleanupChildGuest.setWindowOpenHandler.mock - .calls[0][0] as (details: { url: string }) => { action: 'allow' | 'deny' } - expect( - cleanupChildWindowOpenHandler({ url: 'https://identity.example.com/continue' }) - ).toMatchObject({ action: 'allow' }) - const cleanupChildDestroyedHandler = cleanupChildOnMock.mock.calls.find( - ([event]) => event === 'destroyed' - )?.[1] as (() => void) | undefined - cleanupChildDestroyedHandler?.() - expect(managerState.popupOwnerContextByGuestId.has(cleanupChildGuest.id)).toBe(false) - - const replacementGuest = { - ...guest, - id: 405, - on: vi.fn(), - off: vi.fn(), - setBackgroundThrottling: vi.fn(), - setWindowOpenHandler: vi.fn() - } - webContentsFromIdMock.mockImplementation((id: number) => { - if (id === guest.id) { - return guest - } - if (id === replacementGuest.id) { - return replacementGuest - } - if (id === rendererWebContentsId) { - return { isDestroyed: vi.fn(() => false), send: rendererSendMock } - } - return null - }) - browserManager.attachGuestPolicies(replacementGuest as never) - browserManager.registerGuest({ - browserPageId: 'browser-1', - webContentsId: replacementGuest.id, - rendererWebContentsId - }) - - expect(childWindowOpenHandler({ url: 'https://identity.example.com/next' })).toEqual({ - action: 'deny' - }) - expect(shellOpenExternalMock).toHaveBeenCalledWith('https://identity.example.com/next') - expect(managerState.popupOwnerContextByGuestId.has(childGuest.id)).toBe(false) - - const childDestroyedHandler = childOnMock.mock.calls.find( - ([event]) => event === 'destroyed' - )?.[1] as (() => void) | undefined - childDestroyedHandler?.() - expect(managerState.popupOwnerContextByGuestId.has(childGuest.id)).toBe(false) - - browserManager.unregisterAll() - - expect(childOffMock).toHaveBeenCalledWith('did-create-window', expect.any(Function)) - expect(childOffMock).toHaveBeenCalledWith('will-navigate', expect.any(Function)) - expect(childOffMock).toHaveBeenCalledWith('will-redirect', expect.any(Function)) - }) }) diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 1e04cbdf0cc..7ac64b485b9 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -43,6 +43,11 @@ import { buildBrowserClickedLinkRoutingScript, buildBrowserIframeClickedLinkRoutingScript } from './browser-clicked-link-routing' +import { + createPageInitiatedTabBudget, + type PageInitiatedTabBudget +} from './browser-page-initiated-tab-budget' +import { isNewBrowserTabPopupIntent } from './browser-popup-new-tab-intent' import { cleanElectronUserAgent } from './browser-session-ua' import { getBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua' @@ -233,6 +238,8 @@ export class BrowserManager { // Why: reverse map gives O(1) guest→tab lookups on every mouse/load/permission/popup event. private readonly tabIdByWebContentsId = new Map() private readonly popupOwnerContextByGuestId = new Map() + // Why: keyed by the opener tree's root so named child popups can't each mint a fresh tab quota. + private readonly pageInitiatedTabBudgetByRootGuestId = new Map() // Why: guests are keyed by page id but renderer visibility by workspace id; bridge the mismatch to activate the right tab before capture. private readonly workspaceIdByPageId = new Map() private readonly sessionProfileIdByPageId = new Map() @@ -394,6 +401,16 @@ export class BrowserManager { return null } + /** Shared across the whole opener tree, so a chain of popups draws from one budget. */ + private tryConsumePageInitiatedTab(rootGuestWebContentsId: number): boolean { + let budget = this.pageInitiatedTabBudgetByRootGuestId.get(rootGuestWebContentsId) + if (!budget) { + budget = createPageInitiatedTabBudget() + this.pageInitiatedTabBudgetByRootGuestId.set(rootGuestWebContentsId, budget) + } + return budget.tryConsume(Date.now()) + } + private resolveRendererForBrowserTab(browserTabId: string): Electron.WebContents | null { const rendererWebContentsId = this.rendererWebContentsIdByTabId.get(browserTabId) if (!rendererWebContentsId) { @@ -758,8 +775,9 @@ export class BrowserManager { this.attachGuestPolicies(window.webContents, this.resolvePopupOwnerContext(guest.id)) } guest.on('did-create-window', handleDidCreateWindow) - guest.setWindowOpenHandler(({ url, frameName }) => { - const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guest.id) + guest.setWindowOpenHandler(({ url, frameName, disposition, features }) => { + const ownerContext = this.resolvePopupOwnerContext(guest.id) + const browserTabId = ownerContext?.browserTabId ?? null const browserUrl = normalizeBrowserNavigationUrl(url) const externalUrl = normalizeExternalBrowserUrl(url) const expectedClickedLinkFrameName = this.clickedLinkFrameNameByGuestId.get(guest.id) @@ -784,6 +802,33 @@ export class BrowserManager { return { action: 'deny' } } + // Why: an unnamed, featureless window.open() is Chromium's own new-tab shape, so an Orca tab is + // the honest presentation; a floating origin-bar window is not. Opener-dependent shapes are + // excluded by isNewBrowserTabPopupIntent and still get a real child window below. + if ( + ownerContext && + externalUrl && + isNewBrowserTabPopupIntent({ frameName, disposition, features }) + ) { + // Why: one activation lets a page loop window.open, and each routed tab persists into + // workspace session state, so it survives the quit that used to clear popup windows. + if (!this.tryConsumePageInitiatedTab(ownerContext.rootGuestWebContentsId)) { + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(externalUrl), + action: 'blocked' + }) + return { action: 'deny' } + } + if (this.openLinkInOrcaTab(ownerContext.browserTabId, externalUrl)) { + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(externalUrl), + action: 'opened-in-orca' + }) + } + // Why: a recognized new-tab intent must never fall through to a native popup if its renderer vanished mid-open. + return { action: 'deny' } + } + // Why: file URLs are fine for in-pane previews, but must not spawn native child windows targeting local paths. const canOpenAsChild = Boolean(externalUrl || browserUrl === ORCA_BROWSER_BLANK_URL) if (browserTabId && canOpenAsChild) { @@ -1243,6 +1288,7 @@ export class BrowserManager { this.clickedLinkFrameNameByGuestId.delete(guestWebContentsId) this.offscreenGuestIds.delete(guestWebContentsId) this.popupOwnerContextByGuestId.delete(guestWebContentsId) + this.pageInitiatedTabBudgetByRootGuestId.delete(guestWebContentsId) this.authUserAgentOverrideStateByGuestId.delete(guestWebContentsId) this.pendingNavigationByGuestId.delete(guestWebContentsId) // Why: a popup must stop inheriting authorization the moment its owner retires, before Chromium destroys the child. @@ -1444,6 +1490,7 @@ export class BrowserManager { this.clickedLinkFrameNameByGuestId.clear() this.tabIdByWebContentsId.clear() this.popupOwnerContextByGuestId.clear() + this.pageInitiatedTabBudgetByRootGuestId.clear() this.worktreeIdByTabId.clear() this.sessionProfileIdByPageId.clear() this.userAgentModeByPageId.clear() diff --git a/src/main/browser/browser-page-initiated-tab-budget.test.ts b/src/main/browser/browser-page-initiated-tab-budget.test.ts new file mode 100644 index 00000000000..c2dc7618a7f --- /dev/null +++ b/src/main/browser/browser-page-initiated-tab-budget.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { + createPageInitiatedTabBudget, + MAX_PAGE_INITIATED_TABS_PER_WINDOW, + PAGE_INITIATED_TAB_WINDOW_MS +} from './browser-page-initiated-tab-budget' + +describe('page-initiated tab budget', () => { + it('absorbs a window.open loop fired from a single activation', () => { + const budget = createPageInitiatedTabBudget() + const granted = Array.from({ length: 12 }, () => budget.tryConsume(1_000)).filter(Boolean) + + expect(granted).toHaveLength(MAX_PAGE_INITIATED_TABS_PER_WINDOW) + }) + + it('refills once the rolling window has passed, so real browsing is unaffected', () => { + const budget = createPageInitiatedTabBudget() + for (let i = 0; i < MAX_PAGE_INITIATED_TABS_PER_WINDOW; i++) { + expect(budget.tryConsume(1_000)).toBe(true) + } + expect(budget.tryConsume(1_000 + PAGE_INITIATED_TAB_WINDOW_MS - 1)).toBe(false) + expect(budget.tryConsume(1_000 + PAGE_INITIATED_TAB_WINDOW_MS)).toBe(true) + }) + + it('slides rather than resetting, so a paced flood cannot outrun the cap', () => { + const budget = createPageInitiatedTabBudget(2, 1_000) + + expect(budget.tryConsume(0)).toBe(true) + expect(budget.tryConsume(900)).toBe(true) + // The first grant has aged out by 1_000 but the second has not. + expect(budget.tryConsume(1_000)).toBe(true) + expect(budget.tryConsume(1_100)).toBe(false) + }) +}) diff --git a/src/main/browser/browser-page-initiated-tab-budget.ts b/src/main/browser/browser-page-initiated-tab-budget.ts new file mode 100644 index 00000000000..d15a5c9ba4c --- /dev/null +++ b/src/main/browser/browser-page-initiated-tab-budget.ts @@ -0,0 +1,26 @@ +/** Bounded so one hostile click cannot plant an unbounded number of tabs. */ +export const MAX_PAGE_INITIATED_TABS_PER_WINDOW = 4 +export const PAGE_INITIATED_TAB_WINDOW_MS = 2_000 + +export type PageInitiatedTabBudget = { + /** Records the grant when it returns true; call only when the tab is actually being opened. */ + tryConsume: (now: number) => boolean +} + +/** Rolling window, so it absorbs a same-tick `window.open` loop without capping real browsing. */ +export function createPageInitiatedTabBudget( + maxPerWindow = MAX_PAGE_INITIATED_TABS_PER_WINDOW, + windowMs = PAGE_INITIATED_TAB_WINDOW_MS +): PageInitiatedTabBudget { + let grants: number[] = [] + return { + tryConsume: (now) => { + grants = grants.filter((grantedAt) => now - grantedAt < windowMs) + if (grants.length >= maxPerWindow) { + return false + } + grants.push(now) + return true + } + } +} diff --git a/src/main/browser/browser-popup-new-tab-intent.ts b/src/main/browser/browser-popup-new-tab-intent.ts new file mode 100644 index 00000000000..316614b4aab --- /dev/null +++ b/src/main/browser/browser-popup-new-tab-intent.ts @@ -0,0 +1,17 @@ +/** + * Whether a `window.open()` asks for a new tab rather than a popup window. + * + * Orca answers a tab by denying, which hands the page `null`, so named and featured opens — whose + * flow may use that handle — stay popups. + */ +export function isNewBrowserTabPopupIntent(details: { + frameName: string + disposition: string + features: string +}): boolean { + return ( + details.frameName === '' && + details.features.trim() === '' && + (details.disposition === 'foreground-tab' || details.disposition === 'background-tab') + ) +} diff --git a/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts index 15a632a25e2..4f3450dae85 100644 --- a/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts @@ -89,7 +89,20 @@ export function registerBrowserStateIpcBridge( if (!sourcePage || getRuntimeEnvironmentIdForWorktree(store, sourcePage.worktreeId)) { return } - store.createBrowserTab(sourcePage.worktreeId, url, { title: url }) + // Why: the link inherits the opener's cookie jar. Falling back to the default profile would let + // a page in an isolated session hand its links to the default one, silently crossing profiles. + const sourceTab = (store.browserTabsByWorktree[sourcePage.worktreeId] ?? []).find( + (tab) => tab.id === sourcePage.workspaceId + ) + store.createBrowserTab(sourcePage.worktreeId, url, { + title: url, + ...(sourceTab + ? { + sessionProfileId: sourceTab.sessionProfileId, + sessionPartition: sourceTab.sessionPartition + } + : {}) + }) }) ) } diff --git a/src/renderer/src/hooks/ipc-events/browser-state-open-link-profile.test.ts b/src/renderer/src/hooks/ipc-events/browser-state-open-link-profile.test.ts new file mode 100644 index 00000000000..b15a5be158c --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/browser-state-open-link-profile.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { createBrowserTabMock, storeState } = vi.hoisted(() => ({ + createBrowserTabMock: vi.fn(), + storeState: { + value: {} as Record + } +})) + +vi.mock('../../store', () => ({ + useAppStore: { getState: () => storeState.value } +})) +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: () => null +})) +vi.mock('@/components/browser-pane/describe-page/live-browser-url-registry', () => ({ + rememberLiveBrowserUrl: vi.fn() +})) +vi.mock('./browser-automation-bootstrap-lease', () => ({ + acquireBrowserAutomationBootstrapLease: vi.fn() +})) + +import { registerBrowserStateIpcBridge } from './browser-state-ipc-bridge' + +const noopUnsubscribe = (): void => {} + +function captureOpenLinkHandler(): (event: { browserPageId: string; url: string }) => void { + let handler: ((event: { browserPageId: string; url: string }) => void) | null = null + const browserApi = new Proxy( + { + onOpenLinkInOrcaTab: (callback: (event: { browserPageId: string; url: string }) => void) => { + handler = callback + return noopUnsubscribe + } + } as Record, + { + get: (target, property) => + property in target ? target[property as string] : () => noopUnsubscribe + } + ) + const api = new Proxy({ browser: browserApi } as Record, { + get: (target, property) => + property in target + ? target[property as string] + : new Proxy({}, { get: () => () => noopUnsubscribe }) + }) + ;(globalThis as { window?: unknown }).window = { api } + + registerBrowserStateIpcBridge([], () => false) + if (!handler) { + throw new Error('Expected the bridge to subscribe to browser:open-link-in-orca-tab') + } + return handler +} + +describe('link-opened Orca tabs', () => { + beforeEach(() => { + createBrowserTabMock.mockReset() + }) + + it('inherits the opener tab session so an isolated profile cannot leak into the default one', () => { + storeState.value = { + browserPagesByWorkspace: { + 'workspace-1': [{ id: 'page-1', workspaceId: 'workspace-1', worktreeId: 'worktree-1' }] + }, + browserTabsByWorktree: { + 'worktree-1': [ + { + id: 'workspace-1', + sessionProfileId: 'profile-client-a', + sessionPartition: 'persist:orca-browser-session-client-a' + } + ] + }, + createBrowserTab: createBrowserTabMock + } + + captureOpenLinkHandler()({ browserPageId: 'page-1', url: 'https://docs.example.com/guide' }) + + expect(createBrowserTabMock).toHaveBeenCalledWith( + 'worktree-1', + 'https://docs.example.com/guide', + expect.objectContaining({ + sessionProfileId: 'profile-client-a', + sessionPartition: 'persist:orca-browser-session-client-a' + }) + ) + }) + + it('leaves the profile unset when the opener tab is gone, so the user default still applies', () => { + storeState.value = { + browserPagesByWorkspace: { + 'workspace-1': [{ id: 'page-1', workspaceId: 'missing', worktreeId: 'worktree-1' }] + }, + browserTabsByWorktree: {}, + createBrowserTab: createBrowserTabMock + } + + captureOpenLinkHandler()({ browserPageId: 'page-1', url: 'https://docs.example.com/guide' }) + + const options = createBrowserTabMock.mock.calls[0][2] as Record + expect('sessionProfileId' in options).toBe(false) + }) +}) diff --git a/tests/e2e/browser-tab.spec.ts b/tests/e2e/browser-tab.spec.ts index 4988b76ccc2..00075b4af19 100644 --- a/tests/e2e/browser-tab.spec.ts +++ b/tests/e2e/browser-tab.spec.ts @@ -680,7 +680,7 @@ test.describe('Browser Tab', () => { } }) - test('plain links stay current while explicit new-tab gestures activate Orca tabs', async ({ + test('every new-tab link gesture activates an Orca tab and never a native window', async ({ electronApp, orcaPage }) => { @@ -698,23 +698,20 @@ test.describe('Browser Tab', () => { const baseWindowCount = await electronApp.evaluate( ({ BaseWindow }) => BaseWindow.getAllWindows().length ) - const baseTabCount = await orcaPage.locator('[data-tab-id]').count() - await clickBrowserLink(orcaPage, sourceTab!.id, '#external-link') - + // A plain target=_blank click is a new-tab request, in the main frame and in an iframe; + // the source tab must stay put rather than navigate away under it. const sourceTabLocator = orcaPage.locator(`[data-tab-id="${sourceTab!.id}"]`) - await expect(sourceTabLocator).toContainText('Linked destination', { timeout: 10_000 }) - await expect(orcaPage.locator('[data-tab-id]')).toHaveCount(baseTabCount) + await clickBrowserLink(orcaPage, sourceTab!.id, '#external-link') + await expectBrowserTabActive(orcaPage, 'Linked destination') + await expect(sourceTabLocator).toContainText('Source page') + await switchToBrowserTab(orcaPage, worktreeId, sourceTab!.id) - await clickBrowserLink(orcaPage, sourceTab!.id, '#return-link') - await expect(sourceTabLocator).toContainText('Source page', { timeout: 10_000 }) await clickBrowserLink(orcaPage, sourceTab!.id, '#frame-link', { frameSelector: '#link-frame' }) - await expect(sourceTabLocator).toContainText('Frame destination', { timeout: 10_000 }) - await expect(orcaPage.locator('[data-tab-id]')).toHaveCount(baseTabCount) - - await clickBrowserLink(orcaPage, sourceTab!.id, '#return-link') - await expect(sourceTabLocator).toContainText('Source page', { timeout: 10_000 }) + await expectBrowserTabActive(orcaPage, 'Frame destination') + await expect(sourceTabLocator).toContainText('Source page') + await switchToBrowserTab(orcaPage, worktreeId, sourceTab!.id) await clickBrowserLink(orcaPage, sourceTab!.id, '#frame-modifier-link', { frameSelector: '#link-frame', From 92536346bd414d37afeadb5b69c2171b1179cbd3 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:08:07 -0700 Subject: [PATCH 054/152] Add terminal unavailability exports to parity test (#16810) New runtime exports for handling terminal unavailability: RuntimeTerminalUnavailableReason type and related error codes and messaging constants. --- src/shared/runtime-client-export-parity.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/shared/runtime-client-export-parity.test.ts b/src/shared/runtime-client-export-parity.test.ts index ad3d9708eec..2b86d2e6e99 100644 --- a/src/shared/runtime-client-export-parity.test.ts +++ b/src/shared/runtime-client-export-parity.test.ts @@ -158,6 +158,7 @@ type RuntimeTypeInventory = [ Runtime.RuntimeTerminalSplit, Runtime.RuntimeTerminalState, Runtime.RuntimeTerminalSummary, + Runtime.RuntimeTerminalUnavailableReason, Runtime.RuntimeTerminalVisualGroupNode, Runtime.RuntimeTerminalVisualLayout, Runtime.RuntimeTerminalVisualLayoutNode, @@ -225,8 +226,11 @@ describe('runtime client public export parity', () => { 'BROWSER_UNAVAILABLE_ERROR_CODE', 'COMPUTER_ERROR_CODES', 'HEADLESS_RUNTIME_WINDOW_ID', + 'TERMINAL_PTY_DEGRADATION_CAPABILITY', + 'TERMINAL_UNAVAILABLE_ERROR_CODE', 'UNPUBLISHED_WORKTREE_PUBLICATION_EPOCH', - 'browserUnavailableMessage' + 'browserUnavailableMessage', + 'terminalUnavailableMessage' ]) expect(Object.keys(RemoteClient).sort()).toEqual([ 'RemoteRuntimeClientError', From 642607bfa711d46fe14f3b376583fba54ea9135c Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:45:56 -0700 Subject: [PATCH 055/152] feat(diagnostics): name the code driving a React commit cascade (#16730) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(diagnostics): name the code driving a React commit cascade React #185 reports blame whichever component dispatched after the root-global counter tripped. react-update-depth-attribution already tells the report that boundary_id names a bystander; nothing recorded what the real driver was. Count commits through react-dom's devtools commit hook — the only per-commit seam that survives minification. Profiler's onRender is compiled out of the production bundle, and a dependency-less root layout effect fires per render of its own component, not per commit (measured: a root effect saw 1 of 11 commits a leaf drove). Mirror React's own reset rule rather than a time window: a commit that leaves no sync lanes pending ends the cascade, and a different root restarts it. The steady-state cost is a mask, a compare and an increment, with no clock read and no allocation. Stack sampling arms only once a cascade is already deep, so ordinary work never pays for it. * fix(diagnostics): remove the install-order trap and guard the write path Adversarial and perf review of the cascade diagnostic: The install-order ratchet guarded the wrong thing. The observer self-installs at the bottom of its own module, so it only ran after its transitive graph evaluated — one new import reaching react-dom would have killed the diagnostic in production with every test green. The entries now import the import-free shim instead, which only has to make the global exist; wrapping the callback is timing-independent because react-dom re-reads it per commit. The store write probe called the sampler unguarded, so a throw there dropped the write on the app's universal write path. Guarded; the try/catch measured free at +0.005ns. Report the frames that name the driver instead of capturing eight and reporting one, arm the self-check on the paths where install fails, bind the sample cap to the write count rather than a V8-only API, and stop defining the devtools global for every test file to serve one. The cascadeRoot comment claimed a strong reference cannot retain; a WeakRef probe disproved it. It is still not a leak — the next non-cascading commit clears the slot — so the comment now says that instead. * test(diagnostics): close the ratchet holes guarding the cascade hook Adversarial review loop 2: The install-order ratchet only saw imports whose `from` shared a line with the keyword, so a multi-line `import { createRoot } from 'react-dom/client'` in the shim passed it — and that is the one edit that kills the diagnostic in production. 43% of files in this directory use the multi-line form. Scan the shim source directly as well as walking the graph. The 4000-char budget for the driver frames is bought by the key ending in `stack`, but the only test asserting that emitted its own literal key, so renaming the real one truncated the frames with the suite green. Assert the name the renderer actually emits. Also correct the comment on the `installed` placement: the self-check never reads that flag, it arms because it sits outside the try. * test(diagnostics): stop the shim ratchet firing on prose Adversarial review loop 3 caught two flaws in the guards added last commit. The source-scan regex used an unbounded `[\s\S]*?` after an anchor that also matched the shim's own `export type`, so it degenerated to "does the word `from` appear later in the file" — rewriting a doc comment to say "reads the hook from the global" failed the ratchet. A guard that fails on prose is a guard someone deletes, and this one is what stands between a reshuffled import and a silently dead diagnostic. Require a quote after `from`, tolerate comment obfuscation, and catch `await import(...)`, which makes the shim async so react-dom evaluates before the hook is installed. The 4000-char budget assertion matched `/stack$/i` against the raw key, but the real rule camel-splits first — so `driverstack` would pass while shipping truncated frames. Assert through sanitizeCrashReportDetails, resolving the key from the payload rather than hard-coding it. --- ...ash-reporting-renderer-breadcrumbs.test.ts | 75 ++++ .../crash-reporting-renderer-breadcrumbs.ts | 9 + .../terminal-park-verdict-flip-telemetry.ts | 3 +- ...react-commit-cascade-install-order.test.ts | 137 +++++++ ...-commit-cascade-observer.react185.test.tsx | 169 +++++++++ .../lib/react-commit-cascade-observer.test.ts | 307 ++++++++++++++++ .../src/lib/react-commit-cascade-observer.ts | 101 ++++++ ...commit-cascade-store-write-samples.test.ts | 144 ++++++++ ...eact-commit-cascade-store-write-samples.ts | 157 ++++++++ .../react-commit-cascade-telemetry.test.ts | 339 ++++++++++++++++++ .../src/lib/react-commit-cascade-telemetry.ts | 205 +++++++++++ .../lib/react-devtools-commit-hook-shim.ts | 70 ++++ src/renderer/src/main.tsx | 6 + src/renderer/src/popout.tsx | 8 + src/renderer/src/store/index.ts | 101 +++--- .../react-commit-cascade-write-probe.test.ts | 73 ++++ .../store/react-commit-cascade-write-probe.ts | 37 ++ src/shared/react-update-depth-attribution.ts | 7 + 18 files changed, 1897 insertions(+), 51 deletions(-) create mode 100644 src/renderer/src/lib/react-commit-cascade-install-order.test.ts create mode 100644 src/renderer/src/lib/react-commit-cascade-observer.react185.test.tsx create mode 100644 src/renderer/src/lib/react-commit-cascade-observer.test.ts create mode 100644 src/renderer/src/lib/react-commit-cascade-observer.ts create mode 100644 src/renderer/src/lib/react-commit-cascade-store-write-samples.test.ts create mode 100644 src/renderer/src/lib/react-commit-cascade-store-write-samples.ts create mode 100644 src/renderer/src/lib/react-commit-cascade-telemetry.test.ts create mode 100644 src/renderer/src/lib/react-commit-cascade-telemetry.ts create mode 100644 src/renderer/src/lib/react-devtools-commit-hook-shim.ts create mode 100644 src/renderer/src/store/react-commit-cascade-write-probe.test.ts create mode 100644 src/renderer/src/store/react-commit-cascade-write-probe.ts diff --git a/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts b/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts index 41c108a5cbc..5568030c17a 100644 --- a/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts +++ b/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts @@ -393,6 +393,81 @@ describe('renderer breadcrumb IPC routing', () => { ]) }) + // Why: a #185 crash loop emits a cascade notice per cascade, back to back, + // and uncoalesced they would flush the 30-entry ring holding the trail. + it('coalesces react commit cascade notices from one driver into one slot', () => { + emitRendererBreadcrumb({ + name: 'react_commit_cascade', + data: { commits: 40, rendererSurface: 'main', driverFrame: 'tabs.ts:12:3 bump' } + }) + emitRendererBreadcrumb({ + name: 'react_commit_cascade', + data: { commits: 41, rendererSurface: 'main', driverFrame: 'tabs.ts:12:3 bump' } + }) + + expect(recordCrashBreadcrumbMock).not.toHaveBeenCalled() + expect( + recordCoalescedCrashBreadcrumbMock.mock.calls.map( + (call) => (call[0] as { coalesceKey: string }).coalesceKey + ) + ).toEqual([ + 'react_commit_cascade:main:tabs.ts:12:3 bump', + 'react_commit_cascade:main:tabs.ts:12:3 bump' + ]) + }) + + // Why keyed and not name-only: coalescing keeps only the newest payload, so + // one surface or driver would erase the other's evidence. + it('keeps different cascade surfaces and drivers in separate slots', () => { + emitRendererBreadcrumb({ + name: 'react_commit_cascade', + data: { commits: 40, rendererSurface: 'main', driverFrame: 'tabs.ts:12:3 bump' } + }) + emitRendererBreadcrumb({ + name: 'react_commit_cascade', + data: { commits: 40, rendererSurface: 'dashboard-popout', driverFrame: 'tabs.ts:12:3 bump' } + }) + emitRendererBreadcrumb({ + name: 'react_commit_cascade', + data: { commits: 40, rendererSurface: 'main', storeWrites: 0 } + }) + + expect( + new Set( + recordCoalescedCrashBreadcrumbMock.mock.calls.map( + (call) => (call[0] as { coalesceKey: string }).coalesceKey + ) + ).size + ).toBe(3) + }) + + // Why asserted here: the key ends in `stack`, which is what buys the driver + // frames the 4000-char budget instead of the 240-char one details get. + it('keeps the full driver stack on a react commit cascade notice', () => { + const driverStack = `tabs.ts:12:3 bump\n${'tabs.ts:99:1 other\n'.repeat(60)}` + emitRendererBreadcrumb({ + name: 'react_commit_cascade', + data: { commits: 40, rendererSurface: 'main', driverFrame: 'tabs.ts:12:3 bump', driverStack } + }) + + const recorded = recordCoalescedCrashBreadcrumbMock.mock.calls[0]?.[0] as + | { data: { driverStack: string } } + | undefined + expect(recorded?.data.driverStack.length).toBeGreaterThan(240) + }) + + // Why uncoalesced: it fires at most once per renderer, and it is the signal + // that the diagnostic itself never wired up. + it('records the uninstalled self-check notice directly', () => { + emitRendererBreadcrumb({ name: 'react_commit_cascade_uninstalled' }) + + expect(recordCrashBreadcrumbMock).toHaveBeenCalledWith( + 'react_commit_cascade_uninstalled', + undefined + ) + expect(recordCoalescedCrashBreadcrumbMock).not.toHaveBeenCalled() + }) + it('records non-error renderer breadcrumbs without coalescing', () => { emitRendererBreadcrumb({ name: 'renderer_bootstrap_started', data: { dev: true } }) diff --git a/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts b/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts index 2b2129436fb..160cb93dd87 100644 --- a/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts +++ b/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts @@ -48,12 +48,14 @@ function recordRendererBreadcrumbTrace( // suppressed count instead. const DUPLICATE_TAB_OWNER_BREADCRUMB = 'terminal_tab_id_owned_by_multiple_worktrees' const PARK_VERDICT_CHURN_BREADCRUMB = 'terminal_park_verdict_churn' +const REACT_COMMIT_CASCADE_BREADCRUMB = 'react_commit_cascade' const COALESCED_RENDERER_BREADCRUMB_NAMES = new Set([ 'renderer_error', 'renderer_unhandled_rejection', 'terminal_safe_fit_retry_exhausted', DUPLICATE_TAB_OWNER_BREADCRUMB, PARK_VERDICT_CHURN_BREADCRUMB, + REACT_COMMIT_CASCADE_BREADCRUMB, TERMINAL_WEBGL_DIAGNOSTIC_BREADCRUMB ]) const RENDERER_BREADCRUMB_COALESCE_MS = 30_000 @@ -82,6 +84,13 @@ function rendererBreadcrumbCoalesceKey( if (name === PARK_VERDICT_CHURN_BREADCRUMB) { return `${name}:${String(data?.trigger ?? '')}` } + // Why keyed on surface and driver frame: the popout and the main window can + // cascade independently, and two different driving writes are two different + // bugs that last-write coalescing would collapse into one. A driverless crumb + // (storeWrites 0, a useState loop) keys separately for the same reason. + if (name === REACT_COMMIT_CASCADE_BREADCRUMB) { + return `${name}:${String(data?.rendererSurface ?? '')}:${String(data?.driverFrame ?? '')}` + } // Preserve distinct GPU failures and atlas-reset triggers while coalescing each storm. if (name === TERMINAL_WEBGL_DIAGNOSTIC_BREADCRUMB) { const kind = String(data?.kind ?? '') diff --git a/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.ts b/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.ts index 17b53d53785..13642519029 100644 --- a/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.ts +++ b/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.ts @@ -10,13 +10,12 @@ * one tab as "damping did not reach the oscillating input". */ import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' +import { REACT_NESTED_UPDATE_LIMIT } from '../../../../shared/react-update-depth-attribution' export const TERMINAL_TAB_PARK_FLIP_WINDOW_MS = 60_000 /** Flips per window that no sane park policy should reach. Breadcrumb only. */ export const TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT = 12 -/** react-dom 19.2.x nested commit limit. */ -const REACT_NESTED_UPDATE_LIMIT = 50 /** Measured upper bound after the passive-effect pin engages. */ const PARK_PIN_SETTLE_COMMITS = 6 /** Worst-case commits from pane, watcher, and store work per verdict flip. */ diff --git a/src/renderer/src/lib/react-commit-cascade-install-order.test.ts b/src/renderer/src/lib/react-commit-cascade-install-order.test.ts new file mode 100644 index 00000000000..2f5a1227e89 --- /dev/null +++ b/src/renderer/src/lib/react-commit-cascade-install-order.test.ts @@ -0,0 +1,137 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join, resolve, dirname } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * react-dom reads __REACT_DEVTOOLS_GLOBAL_HOOK__ once, at its own module + * evaluation, and enters the graph through several component modules rather + * than just the entry's import of react-renderer-root. ES evaluation is + * depth-first in source order, so only the FIRST import statement is guaranteed + * to beat it — and only if that module's own graph cannot reach react-dom + * first, which is why the shim has no imports at all. + * + * Nothing else fails when either property stops holding: the diagnostic simply + * records nothing. These tests are the failure. + */ +const RENDERER_ROOT = resolve(__dirname, '..') +const SHIM_MODULE = 'react-devtools-commit-hook-shim' +const SHIM_IMPORT = `import './lib/${SHIM_MODULE}'` +const OBSERVER_IMPORT = "import './lib/react-commit-cascade-observer'" + +/** Entries whose crash reports reach the breadcrumb pipe. */ +const INSTRUMENTED_ENTRIES = ['main.tsx', 'popout.tsx'] +/** + * The web preload stubs crashReports.recordBreadcrumb to a no-op + * (src/renderer/src/web/preload-api/web-diagnostics-api.ts), so instrumenting + * the web entry would cost commits and record nothing. + */ +const UNINSTRUMENTED_ENTRIES = [join('web', 'main.tsx')] + +function firstImportStatement(source: string): string | undefined { + return source + .split('\n') + .map((line) => line.trim()) + .find((line) => line.startsWith('import ')) +} + +function importedSpecifiers(source: string): string[] { + return Array.from(source.matchAll(/(?:^|\n)\s*(?:import|export)[^\n]*?from\s*'([^']+)'/g)) + .map((match) => match[1] as string) + .concat( + Array.from(source.matchAll(/(?:^|\n)\s*import\s*'([^']+)'/g)).map( + (match) => match[1] as string + ) + ) +} + +/** Mirrors the alias in config/vitest.config.ts and electron.vite.config.ts. */ +function resolveLocalModule(specifier: string, fromFile: string): string | undefined { + const base = specifier.startsWith('@/') + ? join(RENDERER_ROOT, specifier.slice(2)) + : specifier.startsWith('.') + ? join(dirname(fromFile), specifier) + : undefined + if (base === undefined) { + return undefined + } + // Only TS: a .css or asset import cannot pull react-dom into the graph. + return [`${base}.ts`, `${base}.tsx`, join(base, 'index.ts')].find((candidate) => + existsSync(candidate) + ) +} + +/** Bare specifiers stay as-is; local ones are followed so a hop cannot hide react-dom. */ +function transitiveSpecifiers(entryFile: string): Set { + const seen = new Set() + const specifiers = new Set() + const queue = [entryFile] + while (queue.length > 0) { + const file = queue.pop() as string + if (seen.has(file)) { + continue + } + seen.add(file) + for (const specifier of importedSpecifiers(readFileSync(file, 'utf8'))) { + specifiers.add(specifier) + const local = resolveLocalModule(specifier, file) + if (local) { + queue.push(local) + } + } + } + return specifiers +} + +describe('react commit cascade shim install order', () => { + for (const entry of INSTRUMENTED_ENTRIES) { + it(`keeps the import-free shim as the first import in ${entry}`, () => { + const source = readFileSync(join(RENDERER_ROOT, entry), 'utf8') + + expect(source).toContain(SHIM_IMPORT) + expect(firstImportStatement(source)).toBe(SHIM_IMPORT) + }) + + // Why separate: the observer wraps a property react-dom re-reads per commit, + // so it only has to be in the graph, not first in it. + it(`still installs the observer in ${entry}`, () => { + expect(readFileSync(join(RENDERER_ROOT, entry), 'utf8')).toContain(OBSERVER_IMPORT) + }) + } + + for (const entry of UNINSTRUMENTED_ENTRIES) { + it(`leaves ${entry} uninstrumented, where breadcrumbs are a no-op`, () => { + const source = readFileSync(join(RENDERER_ROOT, entry), 'utf8') + + expect(source).not.toContain('react-commit-cascade-observer') + }) + } + + // Why this is the real ratchet: one import here that transitively reaches + // react-dom would let react-dom evaluate first and read no hook, killing the + // diagnostic in production with every other test still green. + it('keeps the shim free of imports, react-dom above all', () => { + const shimFile = join(RENDERER_ROOT, 'lib', `${SHIM_MODULE}.ts`) + + const graph = transitiveSpecifiers(shimFile) + expect(Array.from(graph).filter((specifier) => specifier.includes('react-dom'))).toEqual([]) + expect(Array.from(graph)).toEqual([]) + // Why a source scan too: the specifier regex only sees an import whose `from` + // shares a line with the keyword, and a multi-line import is the common form + // here — so the graph check alone would miss the one edit that matters. + // Why `from` must be followed by a quote: bare `\bfrom\b` also matched the + // word in a comment, and a ratchet that fails on prose gets deleted. + // Why the `import(` arm: a top-level `await import(...)` makes this module + // async, so react-dom evaluates before the hook is installed. + expect(readFileSync(shimFile, 'utf8')).not.toMatch( + /^\s*(?:import|export)\b[\s\S]*?\bfrom\s*(?:\/\*[\s\S]*?\*\/\s*)*['"]|^\s*import\s*['"]|\bimport\s*\(/m + ) + }) + + it('follows local hops when checking a graph for react-dom', () => { + const observerFile = join(RENDERER_ROOT, 'lib', 'react-commit-cascade-observer.ts') + + // The observer's own graph is allowed to be wide; this only proves the walker + // above sees past the first hop, so an empty shim graph means something. + expect(transitiveSpecifiers(observerFile).size).toBeGreaterThan(3) + }) +}) diff --git a/src/renderer/src/lib/react-commit-cascade-observer.react185.test.tsx b/src/renderer/src/lib/react-commit-cascade-observer.react185.test.tsx new file mode 100644 index 00000000000..a8c4e7c4c58 --- /dev/null +++ b/src/renderer/src/lib/react-commit-cascade-observer.react185.test.tsx @@ -0,0 +1,169 @@ +/** @vitest-environment happy-dom */ +// Why first: react-dom below reads __REACT_DEVTOOLS_GLOBAL_HOOK__ at module +// evaluation. The shim has no imports of its own, so unlike the observer it +// cannot drag the real breadcrumb recorder in ahead of the vi.mock below. +import './react-devtools-commit-hook-shim' +import { act, useLayoutEffect } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { create } from 'zustand' +import { withReactCommitCascadeWriteProbe } from '../store/react-commit-cascade-write-probe' +import { + REACT_CASCADING_LANES, + REACT_COMMIT_CASCADE_BREADCRUMB, + REACT_COMMIT_CASCADE_NOTICE_LIMIT, + resetReactCommitCascadeTelemetryForTests +} from './react-commit-cascade-telemetry' +import { + installReactCommitCascadeObserver, + resetReactCommitCascadeObserverForTests +} from './react-commit-cascade-observer' +import type { ReactDevtoolsCommitHook } from './react-devtools-commit-hook-shim' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const recordBreadcrumb = vi.fn() +vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: (name: string, data?: unknown) => recordBreadcrumb(name, data) +})) + +/** + * A single-driver cascade measures 438 bytes here, where every frame carries this + * file's long basename; a production bundle name is shorter. Wide enough to sit + * still, narrow enough that a raw stack or an uncapped key list breaks it. + */ +const MAX_CASCADE_CRUMB_BYTES = 768 + +/** Enough to pass the notice limit while staying under React's own 50-commit bail. */ +const CASCADE_TICKS = REACT_COMMIT_CASCADE_NOTICE_LIMIT + 5 + +type CascadeState = { ticks: number; bump: () => void } + +const useCascadeStore = create()( + withReactCommitCascadeWriteProbe((set) => ({ + ticks: 0, + bump: () => { + set({ ticks: useCascadeStore.getState().ticks + 1 }) + } + })) +) + +/** + * Layout effect, not passive: only the synchronous counter throws #185. A + * useEffect loop leaves `pendingLanes: 0` at commit time (measured) because + * passive effects flush after the callback, and React only console.errors it. + */ +function RunawayLayoutEffectPane(): React.JSX.Element { + const ticks = useCascadeStore((state) => state.ticks) + useLayoutEffect(() => { + if (ticks < CASCADE_TICKS) { + useCascadeStore.getState().bump() + } + }) + return
{ticks}
+} + +let host: HTMLDivElement +let root: Root + +const commitHook = (globalThis as { __REACT_DEVTOOLS_GLOBAL_HOOK__?: ReactDevtoolsCommitHook }) + .__REACT_DEVTOOLS_GLOBAL_HOOK__ + +beforeEach(() => { + recordBreadcrumb.mockReset() + resetReactCommitCascadeTelemetryForTests() + resetReactCommitCascadeObserverForTests() + useCascadeStore.setState({ ticks: 0 }) + // Why cleared rather than deleting the global: react-dom captured this hook + // OBJECT at its own module evaluation, so a replacement object would never be + // called — and reinstalling over the last test's wrapper double-counts commits. + if (commitHook) { + commitHook.onCommitFiberRoot = undefined + } + installReactCommitCascadeObserver() + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) +}) + +afterEach(() => { + act(() => { + root.unmount() + }) + host.remove() +}) + +describe('react commit cascade observer', () => { + it('counts a genuine synchronous cascade and names the store write driving it', () => { + act(() => { + root.render() + }) + + const cascadeCalls = recordBreadcrumb.mock.calls.filter( + ([name]) => name === REACT_COMMIT_CASCADE_BREADCRUMB + ) + expect(cascadeCalls).toHaveLength(1) + + const payload = (cascadeCalls[0]?.[1] ?? {}) as Record + expect(payload.commits).toBe(REACT_COMMIT_CASCADE_NOTICE_LIMIT) + expect(payload.pendingLanes).toBeGreaterThan(0) + expect(payload.storeWrites).toBeGreaterThan(0) + // The middleware boundary is elided, so this is the code that called `set`. + expect(String(payload.driverFrame)).toContain('bump') + expect(payload.changedKeys).toBe('ticks') + expect(payload.rendererSurface).toBe('main') + }) + + // Why counted from outside our own wrapper: a bundler change that installs the + // observer twice counts every commit twice and fires the crumb at half the real + // depth, which no other assertion here can tell from a correct run. + it('counts exactly one commit per hook invocation', () => { + let cascadingInvocations = 0 + const observed = commitHook?.onCommitFiberRoot + if (commitHook) { + // The mount commit holds no cascading lanes, so only these are countable. + commitHook.onCommitFiberRoot = (rendererId, fiberRoot, priorityLevel, didError) => { + const lanes = (fiberRoot as { pendingLanes?: number } | null)?.pendingLanes ?? 0 + if ((lanes & REACT_CASCADING_LANES) !== 0) { + cascadingInvocations += 1 + } + observed?.call(commitHook, rendererId, fiberRoot, priorityLevel, didError) + } + } + let invocationsAtReport: number | undefined + recordBreadcrumb.mockImplementation((name: string) => { + if (name === REACT_COMMIT_CASCADE_BREADCRUMB && invocationsAtReport === undefined) { + invocationsAtReport = cascadingInvocations + } + }) + + act(() => { + root.render() + }) + + expect(invocationsAtReport).toBe(REACT_COMMIT_CASCADE_NOTICE_LIMIT) + }) + + // Why a byte budget: driverStack's key ends in `stack`, which buys 4000 chars, + // so a raw stack or an uncapped key list could be added here unnoticed. + it('keeps the crumb inside a flat byte budget', () => { + act(() => { + root.render() + }) + + const payload = recordBreadcrumb.mock.calls.find( + ([name]) => name === REACT_COMMIT_CASCADE_BREADCRUMB + )?.[1] + expect(JSON.stringify(payload).length).toBeLessThan(MAX_CASCADE_CRUMB_BYTES) + }) + + it('stays silent for a render that settles well below the notice limit', () => { + useCascadeStore.setState({ ticks: CASCADE_TICKS }) + + act(() => { + root.render() + }) + + expect(recordBreadcrumb).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/react-commit-cascade-observer.test.ts b/src/renderer/src/lib/react-commit-cascade-observer.test.ts new file mode 100644 index 00000000000..2928afdcebc --- /dev/null +++ b/src/renderer/src/lib/react-commit-cascade-observer.test.ts @@ -0,0 +1,307 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + REACT_COMMIT_CASCADE_BREADCRUMB, + REACT_COMMIT_CASCADE_NOTICE_LIMIT, + resetReactCommitCascadeTelemetryForTests +} from './react-commit-cascade-telemetry' +import { + REACT_COMMIT_CASCADE_INSTALL_CHECK_MS, + REACT_COMMIT_CASCADE_UNINSTALLED_BREADCRUMB, + installReactCommitCascadeObserver, + resetReactCommitCascadeObserverForTests +} from './react-commit-cascade-observer' +import { + ensureReactDevtoolsCommitHook, + type ReactDevtoolsCommitHook +} from './react-devtools-commit-hook-shim' + +const recordBreadcrumb = vi.fn() +vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: (name: string, data?: unknown) => recordBreadcrumb(name, data) +})) + +const CASCADING_ROOT = { pendingLanes: 2 } + +function readHook(): ReactDevtoolsCommitHook { + return (globalThis as { __REACT_DEVTOOLS_GLOBAL_HOOK__?: ReactDevtoolsCommitHook }) + .__REACT_DEVTOOLS_GLOBAL_HOOK__ as ReactDevtoolsCommitHook +} + +beforeEach(() => { + recordBreadcrumb.mockClear() + resetReactCommitCascadeTelemetryForTests() + resetReactCommitCascadeObserverForTests() + // Why the delete: install wraps whatever is there, so a hook left over from + // the previous test would stack a second counter onto the same callback. + delete (globalThis as { __REACT_DEVTOOLS_GLOBAL_HOOK__?: unknown }).__REACT_DEVTOOLS_GLOBAL_HOOK__ + installReactCommitCascadeObserver() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('react devtools commit hook shim', () => { + // Why asserted: without a pre-existing hook react-dom injects nothing, so a + // packaged build only gets commit callbacks if the shim installs its own. + it('exposes what react-dom requires to inject', () => { + const hook = readHook() + + expect(hook.supportsFiber).toBe(true) + expect(hook.isDisabled).toBe(false) + expect(hook.inject?.({})).toBeGreaterThan(0) + expect(typeof hook.onCommitFiberRoot).toBe('function') + }) + + // Why asserted: react-dom calls this once per DELETED FIBER, guarded only by + // a typeof check. Defining it would put our shim on every unmount path. + it('leaves the per-deleted-fiber callbacks undefined', () => { + const hook = readHook() as Record + + expect(hook.onCommitFiberUnmount).toBeUndefined() + expect(hook.onPostCommitFiberRoot).toBeUndefined() + expect(hook.setStrictMode).toBeUndefined() + }) + + // Why asserted: react-refresh captures this property and calls + // oldOnCommitFiberRoot.apply(this, arguments) with no typeof guard, unlike the + // fallback it gives onScheduleFiberRoot. + it('ships a callable onCommitFiberRoot before anything wraps it', () => { + delete (globalThis as { __REACT_DEVTOOLS_GLOBAL_HOOK__?: unknown }) + .__REACT_DEVTOOLS_GLOBAL_HOOK__ + const hook = ensureReactDevtoolsCommitHook() + + expect(typeof hook?.onCommitFiberRoot).toBe('function') + expect(() => hook?.onCommitFiberRoot?.(1, null, undefined, false)).not.toThrow() + }) +}) + +describe('installReactCommitCascadeObserver', () => { + it('breadcrumbs once the commit callback reaches the notice limit', () => { + const hook = readHook() + for (let commit = 0; commit < REACT_COMMIT_CASCADE_NOTICE_LIMIT; commit += 1) { + hook.onCommitFiberRoot?.(1, CASCADING_ROOT, undefined, false) + } + + expect(recordBreadcrumb).toHaveBeenCalledTimes(1) + expect(recordBreadcrumb.mock.calls[0]?.[0]).toBe(REACT_COMMIT_CASCADE_BREADCRUMB) + }) + + it('ignores a commit whose root reports no cascading lanes', () => { + const hook = readHook() + for (let commit = 0; commit < REACT_COMMIT_CASCADE_NOTICE_LIMIT * 2; commit += 1) { + hook.onCommitFiberRoot?.(1, { pendingLanes: 0 }, undefined, false) + } + + expect(recordBreadcrumb).not.toHaveBeenCalled() + }) + + // Why asserted: a future React that stops exposing pendingLanes must make the + // diagnostic go quiet, not fire on a depth it can no longer evaluate. + it('ends the cascade when pendingLanes is not readable', () => { + const hook = readHook() + for (let commit = 0; commit < REACT_COMMIT_CASCADE_NOTICE_LIMIT - 1; commit += 1) { + hook.onCommitFiberRoot?.(1, CASCADING_ROOT, undefined, false) + } + + hook.onCommitFiberRoot?.(1, { renamedLanes: 2 }, undefined, false) + for (let commit = 0; commit < REACT_COMMIT_CASCADE_NOTICE_LIMIT - 1; commit += 1) { + hook.onCommitFiberRoot?.(1, CASCADING_ROOT, undefined, false) + } + + expect(recordBreadcrumb).not.toHaveBeenCalled() + }) + + // Why a string and not undefined: '2' & 42 is 2, so a bitmask-only guard would + // keep the cascade alive on a value React never meant as a lane. + it('ends the cascade when pendingLanes is not a number', () => { + const hook = readHook() + for (let commit = 0; commit < REACT_COMMIT_CASCADE_NOTICE_LIMIT - 1; commit += 1) { + hook.onCommitFiberRoot?.(1, CASCADING_ROOT, undefined, false) + } + + hook.onCommitFiberRoot?.(1, { pendingLanes: '2' }, undefined, false) + for (let commit = 0; commit < REACT_COMMIT_CASCADE_NOTICE_LIMIT - 1; commit += 1) { + hook.onCommitFiberRoot?.(1, CASCADING_ROOT, undefined, false) + } + + expect(recordBreadcrumb).not.toHaveBeenCalled() + }) + + it('chains onto an existing callback instead of replacing it', () => { + const previous = vi.fn() + const hook = readHook() + hook.onCommitFiberRoot = previous + resetReactCommitCascadeObserverForTests() + installReactCommitCascadeObserver() + + hook.onCommitFiberRoot?.(7, CASCADING_ROOT, undefined, true) + + expect(previous).toHaveBeenCalledWith(7, CASCADING_ROOT, undefined, true) + }) + + // Why asserted: our throw would otherwise land in react-dom's own catch and + // silently unhook DevTools and Fast Refresh from the rest of the chain. + it('still calls the chained hook when our own work throws', () => { + const previous = vi.fn() + const hook = readHook() + hook.onCommitFiberRoot = previous + resetReactCommitCascadeObserverForTests() + installReactCommitCascadeObserver() + + const hostile = { + get pendingLanes(): number { + throw new Error('hostile root') + } + } + expect(() => hook.onCommitFiberRoot?.(1, hostile, undefined, false)).not.toThrow() + expect(previous).toHaveBeenCalledTimes(1) + }) + + it('is idempotent, so a second install cannot double-count commits', () => { + installReactCommitCascadeObserver() + const hook = readHook() + for (let commit = 0; commit < REACT_COMMIT_CASCADE_NOTICE_LIMIT - 1; commit += 1) { + hook.onCommitFiberRoot?.(1, CASCADING_ROOT, undefined, false) + } + + expect(recordBreadcrumb).not.toHaveBeenCalled() + }) +}) + +// Why: a reshuffled import or a bundler hoist disables the diagnostic with no +// test failure. This is what makes that visible in the field instead. +describe('install self-check', () => { + it('breadcrumbs when no commit ever reached the hook', () => { + vi.useFakeTimers() + resetReactCommitCascadeObserverForTests() + installReactCommitCascadeObserver() + + vi.advanceTimersByTime(REACT_COMMIT_CASCADE_INSTALL_CHECK_MS) + + expect(recordBreadcrumb).toHaveBeenCalledWith( + REACT_COMMIT_CASCADE_UNINSTALLED_BREADCRUMB, + undefined + ) + }) + + it('stays silent once any commit has been seen', () => { + vi.useFakeTimers() + resetReactCommitCascadeObserverForTests() + installReactCommitCascadeObserver() + readHook().onCommitFiberRoot?.(1, { pendingLanes: 0 }, undefined, false) + + vi.advanceTimersByTime(REACT_COMMIT_CASCADE_INSTALL_CHECK_MS) + + expect(recordBreadcrumb).not.toHaveBeenCalled() + }) + + // Why asserted: a hook that refuses the assignment is exactly the case the + // self-check exists for, so it must arm on the failure path too. + it('breadcrumbs when the hook refuses the callback assignment', () => { + vi.useFakeTimers() + resetReactCommitCascadeObserverForTests() + const frozenHook = Object.freeze({ isDisabled: false, supportsFiber: true }) + ;(globalThis as { __REACT_DEVTOOLS_GLOBAL_HOOK__?: unknown }).__REACT_DEVTOOLS_GLOBAL_HOOK__ = + frozenHook + + expect(() => installReactCommitCascadeObserver()).not.toThrow() + vi.advanceTimersByTime(REACT_COMMIT_CASCADE_INSTALL_CHECK_MS) + + expect((frozenHook as { onCommitFiberRoot?: unknown }).onCommitFiberRoot).toBeUndefined() + expect(recordBreadcrumb).toHaveBeenCalledWith( + REACT_COMMIT_CASCADE_UNINSTALLED_BREADCRUMB, + undefined + ) + }) + + it('breadcrumbs when no hook can be reached at all', () => { + vi.useFakeTimers() + resetReactCommitCascadeObserverForTests() + Object.defineProperty(globalThis, '__REACT_DEVTOOLS_GLOBAL_HOOK__', { + configurable: true, + get() { + throw new Error('no devtools global') + } + }) + + expect(() => installReactCommitCascadeObserver()).not.toThrow() + vi.advanceTimersByTime(REACT_COMMIT_CASCADE_INSTALL_CHECK_MS) + + expect(recordBreadcrumb).toHaveBeenCalledWith( + REACT_COMMIT_CASCADE_UNINSTALLED_BREADCRUMB, + undefined + ) + }) +}) + +/** + * Why an allocation assertion and not a timing one: this callback runs on every + * commit the app makes, so the cost that matters is garbage. Measured on this + * loop, an escaping object literal, a template string and a per-call closure + * each cost more than 3.8 MB where the current code costs tens of KB. + */ +describe('commit hook cost', () => { + const HOT_COMMITS = 1_000_000 + /** ~30x the measured zero-allocation baseline, ~4x under the cheapest regression. */ + const MAX_HEAP_GROWTH_BYTES = 1_000_000 + + function measureHeapGrowth(commit: () => void): number { + const collectGarbage = (globalThis as { gc?: () => void }).gc + // --expose-gc is pinned in config/vitest.config.ts execArgv. + expect(typeof collectGarbage).toBe('function') + for (let warmup = 0; warmup < 50_000; warmup += 1) { + commit() + } + collectGarbage?.() + const before = process.memoryUsage().heapUsed + for (let iteration = 0; iteration < HOT_COMMITS; iteration += 1) { + commit() + } + return process.memoryUsage().heapUsed - before + } + + it('allocates nothing on an ordinary commit', () => { + const hook = readHook() + const quietRoot = { pendingLanes: 0 } + + const growth = measureHeapGrowth(() => hook.onCommitFiberRoot?.(1, quietRoot, undefined, false)) + + expect(growth).toBeLessThan(MAX_HEAP_GROWTH_BYTES) + }) + + // Why separately: the cascading branch runs the counter, the arm check and the + // report guard, none of which may start allocating once a cascade is deep. + it('allocates nothing while a cascade is being counted', () => { + const hook = readHook() + + const growth = measureHeapGrowth(() => + hook.onCommitFiberRoot?.(1, CASCADING_ROOT, undefined, false) + ) + + expect(growth).toBeLessThan(MAX_HEAP_GROWTH_BYTES) + }) +}) + +// Why asserted: main.tsx imports this module for its side effect alone, so +// deleting the bare install call would disable the diagnostic in production. +describe('module self-install', () => { + it('counts commits after nothing but an import', async () => { + vi.useFakeTimers() + delete (globalThis as { __REACT_DEVTOOLS_GLOBAL_HOOK__?: unknown }) + .__REACT_DEVTOOLS_GLOBAL_HOOK__ + vi.resetModules() + + await import('./react-commit-cascade-observer') + + const hook = readHook() + for (let commit = 0; commit < REACT_COMMIT_CASCADE_NOTICE_LIMIT; commit += 1) { + hook.onCommitFiberRoot?.(1, CASCADING_ROOT, undefined, false) + } + + expect(recordBreadcrumb).toHaveBeenCalledWith( + REACT_COMMIT_CASCADE_BREADCRUMB, + expect.objectContaining({ commits: REACT_COMMIT_CASCADE_NOTICE_LIMIT }) + ) + }) +}) diff --git a/src/renderer/src/lib/react-commit-cascade-observer.ts b/src/renderer/src/lib/react-commit-cascade-observer.ts new file mode 100644 index 00000000000..bfac75898bd --- /dev/null +++ b/src/renderer/src/lib/react-commit-cascade-observer.ts @@ -0,0 +1,101 @@ +/** + * Installs the per-commit hook that feeds the cascade telemetry. + * + * Why the devtools callback and not or a root effect: Profiler is + * stripped from production react-dom, and a dependency-less useLayoutEffect + * fires per render of its OWN component, not per commit — measured, a root + * effect saw 1 of 11 commits a leaf drove. react-dom calls onCommitFiberRoot + * once per commit per root in both bundles, inside its own try/catch, and hands + * us the FiberRoot whose `pendingLanes` drive the reset rule. + * + * Ordering: this module only WRAPS `onCommitFiberRoot`, which react-dom re-reads + * at every commit, so it may be imported anywhere. The deadline belongs to + * react-devtools-commit-hook-shim, which must merely exist before react-dom's + * module evaluation and is therefore the entries' first import. + */ +import { observeReactCommit } from '@/lib/react-commit-cascade-telemetry' +import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' +import { + ensureReactDevtoolsCommitHook, + type ReactDevtoolsCommitHook +} from '@/lib/react-devtools-commit-hook-shim' + +export const REACT_COMMIT_CASCADE_UNINSTALLED_BREADCRUMB = 'react_commit_cascade_uninstalled' + +/** Long enough that any live renderer has committed; short enough to reach a crash report. */ +export const REACT_COMMIT_CASCADE_INSTALL_CHECK_MS = 10_000 + +type FiberRootLike = { pendingLanes?: unknown } + +let installed = false +let commitsSeen = 0 +let installCheckTimer: ReturnType | undefined + +/** + * A reshuffled import or a bundler hoist would disable the diagnostic with no + * test failure. Make that visible in the field instead of silent. + */ +function scheduleInstallSelfCheck(): void { + if (installCheckTimer !== undefined || typeof setTimeout !== 'function') { + return + } + installCheckTimer = setTimeout(() => { + if (commitsSeen === 0) { + recordRendererCrashBreadcrumb(REACT_COMMIT_CASCADE_UNINSTALLED_BREADCRUMB) + } + }, REACT_COMMIT_CASCADE_INSTALL_CHECK_MS) + // Why unref where available: the check must never hold a renderer teardown open. + ;(installCheckTimer as { unref?: () => void })?.unref?.() +} + +export function installReactCommitCascadeObserver(): void { + if (installed) { + return + } + try { + const hook = ensureReactDevtoolsCommitHook() + if (hook) { + installObserverOnHook(hook) + // Why only after the assignment: a hook that refuses it stays uninstalled, + // so a later call can retry rather than being gated out. + installed = true + } + } catch { + // A renderer without a patchable global still has to boot. + } + // Why outside the try: a failed install is exactly when the crumb matters. + scheduleInstallSelfCheck() +} + +function installObserverOnHook(hook: ReactDevtoolsCommitHook): void { + const previous = hook.onCommitFiberRoot + // Fixed arity, not rest args: this runs on every commit and must not allocate. + hook.onCommitFiberRoot = (rendererId, root, priorityLevel, didError) => { + // Why our own try/catch when react-dom already has one: theirs would + // swallow the rest of this callback, silently unhooking DevTools and + // Fast Refresh from the chain below. + try { + commitsSeen += 1 + const pendingLanes = (root as FiberRootLike | null)?.pendingLanes + // Why 0 and not a skip: a React shape change that hides pendingLanes must + // end the cascade, not freeze a stale depth that later commits push over + // the limit. 0 is the lane value that means "nothing pending", so it + // takes the same reset path and this fails safe to no crumb. + observeReactCommit(root, typeof pendingLanes === 'number' ? pendingLanes : 0) + } catch { + // Best-effort crash evidence only. + } + previous?.call(hook, rendererId, root, priorityLevel, didError) + } +} + +export function resetReactCommitCascadeObserverForTests(): void { + installed = false + commitsSeen = 0 + if (installCheckTimer !== undefined) { + clearTimeout(installCheckTimer) + installCheckTimer = undefined + } +} + +installReactCommitCascadeObserver() diff --git a/src/renderer/src/lib/react-commit-cascade-store-write-samples.test.ts b/src/renderer/src/lib/react-commit-cascade-store-write-samples.test.ts new file mode 100644 index 00000000000..17489399883 --- /dev/null +++ b/src/renderer/src/lib/react-commit-cascade-store-write-samples.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { create } from 'zustand' +import { withReactCommitCascadeWriteProbe } from '../store/react-commit-cascade-write-probe' +import { + MAX_REPORTED_CHANGED_KEYS, + MAX_SAMPLED_WRITES, + armReactCommitCascadeWriteSampling, + noteReactCommitCascadeStoreWrite, + readReactCommitCascadeWriteSummary, + resetReactCommitCascadeWriteSamples +} from './react-commit-cascade-store-write-samples' + +// Not an intersection with ErrorConstructor: both fields have to stay optional +// so the absent-captureStackTrace platform can be simulated. +type ErrorWithCapture = { + captureStackTrace?: (target: object, constructorOpt?: unknown) => void + stackTraceLimit?: number +} +const errorWithCapture = Error as unknown as ErrorWithCapture +const originalCapture = errorWithCapture.captureStackTrace +const originalLimit = errorWithCapture.stackTraceLimit + +/** Stands in for the zustand slice action that calls `set`. */ +function storeAction(): void { + noteReactCommitCascadeStoreWrite(storeAction, { ticks: 1 }) +} +function runawayEffect(): void { + storeAction() +} +function paneRender(): void { + runawayEffect() +} + +beforeEach(() => { + resetReactCommitCascadeWriteSamples() +}) + +afterEach(() => { + resetReactCommitCascadeWriteSamples() + errorWithCapture.captureStackTrace = originalCapture + errorWithCapture.stackTraceLimit = originalLimit +}) + +describe('driver stack frames', () => { + // Why more than one frame: frame 0 is the slice action every cascade shares. + // The caller above it is the loop we are actually hunting. + it('names callers above the frame the write came from', () => { + armReactCommitCascadeWriteSampling() + paneRender() + + const summary = readReactCommitCascadeWriteSummary() + const frames = summary.driverStack?.split('\n') ?? [] + expect(summary.driverFrame).toContain('runawayEffect') + expect(frames[0]).toBe(summary.driverFrame) + expect(frames.length).toBeGreaterThan(1) + expect(summary.driverStack).toContain('paneRender') + expect(summary.driverFrame).not.toContain('paneRender') + }) + + // Why: the redaction that strips paths is keyed on the detail NAME, so a path + // inside this value would ship a developer's home directory. + it('keeps every reported frame path-free', () => { + armReactCommitCascadeWriteSampling() + paneRender() + + expect(readReactCommitCascadeWriteSummary().driverStack).not.toContain('/') + }) +}) + +describe('capture failures', () => { + // Why asserted: the limit is process-global, so leaking a raised value would + // make every unrelated throw in the app capture eight frames forever. + it('restores Error.stackTraceLimit when the capture throws', () => { + errorWithCapture.stackTraceLimit = 3 + errorWithCapture.captureStackTrace = () => { + throw new Error('capture refused') + } + armReactCommitCascadeWriteSampling() + + expect(() => storeAction()).not.toThrow() + expect(errorWithCapture.stackTraceLimit).toBe(3) + expect(readReactCommitCascadeWriteSummary().storeWrites).toBe(1) + }) + + // Why asserted: no sample is ever pushed without captureStackTrace, so a cap + // bound to samples.length would leave key collection running for the whole + // cascade on any engine that lacks the API. + it('stops collecting after the sample cap when captureStackTrace is unavailable', () => { + delete errorWithCapture.captureStackTrace + armReactCommitCascadeWriteSampling() + for (let write = 0; write < 100; write += 1) { + noteReactCommitCascadeStoreWrite(storeAction, { [`key${write}`]: write }) + } + // Restored before asserting: vitest builds its own failure stacks with it. + errorWithCapture.captureStackTrace = originalCapture + + const summary = readReactCommitCascadeWriteSummary() + expect(summary.storeWrites).toBe(100) + expect(summary.driverFrame).toBeUndefined() + expect(summary.storeWriteSites).toBe(0) + expect(summary.changedKeys?.split(',')).toHaveLength(MAX_SAMPLED_WRITES) + expect(summary.changedKeys).not.toContain('key99') + expect(summary.changedKeys?.length).toBeLessThan(240) + }) + + // Why still asserted: one write can change many keys, so the report cap is + // what keeps the detail inside its 240-char budget. + it('caps reported changed keys from a single wide write', () => { + const wide = Object.fromEntries( + Array.from({ length: 40 }, (_, index) => [`key${index}`, index]) + ) + armReactCommitCascadeWriteSampling() + noteReactCommitCascadeStoreWrite(storeAction, wide) + + const summary = readReactCommitCascadeWriteSummary() + expect(summary.changedKeys?.split(',')).toHaveLength(MAX_REPORTED_CHANGED_KEYS) + expect(summary.changedKeys?.length).toBeLessThan(240) + }) +}) + +// Why measured through the real middleware: the arming condition is what keeps +// an ordinary write down to one boolean read, and nothing else asserts it end to end. +describe('disarmed cost', () => { + const WRITE_FLOOD = 10_000 + + it('captures no stack across a write flood with no cascade', () => { + const useFloodStore = create<{ ticks: number }>()( + withReactCommitCascadeWriteProbe(() => ({ ticks: 0 })) + ) + let captures = 0 + errorWithCapture.captureStackTrace = (target, boundary) => { + captures += 1 + originalCapture?.(target, boundary) + } + + for (let write = 0; write < WRITE_FLOOD; write += 1) { + useFloodStore.setState({ ticks: write }) + } + + expect(captures).toBe(0) + expect(useFloodStore.getState().ticks).toBe(WRITE_FLOOD - 1) + expect(readReactCommitCascadeWriteSummary().storeWrites).toBe(0) + }) +}) diff --git a/src/renderer/src/lib/react-commit-cascade-store-write-samples.ts b/src/renderer/src/lib/react-commit-cascade-store-write-samples.ts new file mode 100644 index 00000000000..ef8565255a8 --- /dev/null +++ b/src/renderer/src/lib/react-commit-cascade-store-write-samples.ts @@ -0,0 +1,157 @@ +/** + * Samples the store writes made during a suspected React commit cascade. + * + * Why sampling writes and not the throw site: React #185 lands on whichever + * fiber dispatched after a root-global counter tripped, so the reported + * component is a bystander (see shared/react-update-depth-attribution.ts). The + * writes that kept scheduling are the only frames that identify the loop. + * + * Cost discipline: the armed flag is the only thing the store-write path reads + * when no cascade is suspected, capture is capped per cascade, and frames are + * formatted at report time so a sampled write pays for nothing it may not use. + */ + +/** + * Read on every store write, so it is a plain field load on a shared object + * rather than a cross-module call. + */ +export const reactCommitCascadeWriteProbe = { armed: false } + +/** Frame 0 is the store action; the rest reach up into whatever loops over it. */ +const CAPTURE_STACK_FRAME_LIMIT = 8 +/** + * Frame 0 alone names a slice action, which every cascade shares. Two frames of + * caller is what distinguishes the effect driving the loop from its neighbours. + */ +const REPORTED_FRAMES_PER_SITE = 3 +/** Whole frames only, inside the 4000-char budget a `stack`-suffixed detail gets. */ +const MAX_DRIVER_STACK_CHARS = 3_800 +/** A loop repeats; six samples name every distinct participant worth naming. */ +export const MAX_SAMPLED_WRITES = 6 +/** Keeps `changedKeys` inside the 240-char detail cap without truncating mid-key. */ +export const MAX_REPORTED_CHANGED_KEYS = 12 + +type SampledWrite = { stack?: string } + +let storeWrites = 0 +let samples: SampledWrite[] = [] +let changedKeys: Set | null = null + +export type ReactCommitCascadeWriteSummary = { + storeWrites: number + storeWriteSites: number + driverFrame: string | undefined + driverStack: string | undefined + changedKeys: string | undefined +} + +export function armReactCommitCascadeWriteSampling(): void { + reactCommitCascadeWriteProbe.armed = true +} + +export function resetReactCommitCascadeWriteSamples(): void { + reactCommitCascadeWriteProbe.armed = false + storeWrites = 0 + samples = [] + changedKeys = null +} + +/** + * Call only while armed. `boundary` is the wrapping `set` function, so V8 elides + * our own frames and the first captured frame is the real caller. Typed as + * `object` because zustand's `set` is an overload set, not a plain signature. + */ +export function noteReactCommitCascadeStoreWrite(boundary: object, partial: unknown): void { + storeWrites += 1 + // Why the write count and not samples.length: samples only grows where + // Error.captureStackTrace exists, so that cap would never engage without it and + // key collection would run for the whole cascade. + if (storeWrites > MAX_SAMPLED_WRITES) { + return + } + // Why object-only: a functional updater's keys are unknowable without running it. + if (partial && typeof partial === 'object') { + changedKeys ??= new Set() + for (const key of Object.keys(partial)) { + changedKeys.add(key) + } + } + const capture = Error as ErrorConstructor & { + captureStackTrace?: (target: object, constructorOpt?: unknown) => void + stackTraceLimit?: number + } + if (typeof capture.captureStackTrace !== 'function') { + return + } + const previousLimit = capture.stackTraceLimit + const sample: SampledWrite = {} + try { + capture.stackTraceLimit = CAPTURE_STACK_FRAME_LIMIT + capture.captureStackTrace(sample, boundary) + samples.push(sample) + } catch { + // Best-effort crash evidence only. + } finally { + capture.stackTraceLimit = previousLimit + } +} + +/** `at fn (/Users/me/app/src/x.ts:1:2)` becomes `x.ts:1:2 fn`. */ +function reduceFrame(frame: string): string | undefined { + const match = /^\s*at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?\s*$/.exec(frame) + if (!match) { + return undefined + } + const [, fn, location, line, column] = match + // Why basename only: the redaction that strips paths is keyed on the detail + // NAME, so a full path inside this value would ship a developer's home dir. + const basename = location?.split(/[/\\]/).pop() ?? '' + return fn ? `${basename}:${line}:${column} ${fn}` : `${basename}:${line}:${column}` +} + +/** One reduced frame group per distinct call site, deepest frame first. */ +function sampledSiteFrames(): string[][] { + const sites: string[][] = [] + const seen = new Set() + for (const sample of samples) { + const frames = (sample.stack?.split('\n') ?? []) + .map(reduceFrame) + .filter((frame): frame is string => frame !== undefined) + .slice(0, REPORTED_FRAMES_PER_SITE) + const site = frames[0] + if (site === undefined || seen.has(site)) { + continue + } + seen.add(site) + sites.push(frames) + } + return sites +} + +/** Drops whole frames, so a truncated stack never ends mid-frame. */ +function joinWithinBudget(frames: string[]): string | undefined { + const kept: string[] = [] + let remaining = MAX_DRIVER_STACK_CHARS + for (const frame of frames) { + if (frame.length + 1 > remaining) { + break + } + kept.push(frame) + remaining -= frame.length + 1 + } + return kept.length > 0 ? kept.join('\n') : undefined +} + +/** Formats only here, so an unreported cascade pays nothing for its samples. */ +export function readReactCommitCascadeWriteSummary(): ReactCommitCascadeWriteSummary { + const sites = sampledSiteFrames() + const keys = changedKeys ? Array.from(changedKeys).slice(0, MAX_REPORTED_CHANGED_KEYS) : [] + return { + storeWrites, + storeWriteSites: sites.length, + // Why frame 0 stays alone: the main-side coalesce key clusters on it. + driverFrame: sites[0]?.[0], + driverStack: joinWithinBudget(sites.flat()), + changedKeys: keys.length > 0 ? keys.join(',') : undefined + } +} diff --git a/src/renderer/src/lib/react-commit-cascade-telemetry.test.ts b/src/renderer/src/lib/react-commit-cascade-telemetry.test.ts new file mode 100644 index 00000000000..89e0456c2ce --- /dev/null +++ b/src/renderer/src/lib/react-commit-cascade-telemetry.test.ts @@ -0,0 +1,339 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { sanitizeCrashReportDetails } from '../../../shared/crash-report-redaction' +import { REACT_NESTED_UPDATE_LIMIT } from '../../../shared/react-update-depth-attribution' +import { + REACT_CASCADING_LANES, + setReactCommitCascadeRendererSurface, + REACT_COMMIT_CASCADE_ARM_COMMITS, + REACT_COMMIT_CASCADE_BREADCRUMB, + REACT_COMMIT_CASCADE_MIN_REPORT_INTERVAL_MS, + REACT_COMMIT_CASCADE_NOTICE_LIMIT, + createReactCommitCascadeState, + recordReactCommit, + resetReactCommitCascadeTelemetryForTests, + type ReactCommitCascadeState +} from './react-commit-cascade-telemetry' +import { + noteReactCommitCascadeStoreWrite, + reactCommitCascadeWriteProbe, + readReactCommitCascadeWriteSummary +} from './react-commit-cascade-store-write-samples' + +const recordBreadcrumb = vi.fn() +vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: (name: string, data?: unknown) => recordBreadcrumb(name, data) +})) + +const ROOT = { pendingLanes: 2 } +const OTHER_ROOT = { pendingLanes: 2 } +/** SyncLane; what react-dom leaves pending on a cascading commit. */ +const SYNC_LANE = 2 +/** Any lane outside React's nested-update mask ends the cascade. */ +const IDLE_LANE = 1_073_741_824 + +function cascadePayload(callIndex = 0): Record { + const call = recordBreadcrumb.mock.calls[callIndex] + return (call?.[1] ?? {}) as Record +} + +function driveCommits(args: { + state: ReactCommitCascadeState + count: number + root?: unknown + lanes?: number + nowMs?: number + onCommit?: () => void +}): void { + const readNowMs = (): number => args.nowMs ?? 1_000 + for (let index = 0; index < args.count; index += 1) { + recordReactCommit({ + state: args.state, + root: args.root ?? ROOT, + pendingLanes: args.lanes ?? SYNC_LANE, + readNowMs + }) + args.onCommit?.() + } +} + +beforeEach(() => { + recordBreadcrumb.mockClear() + resetReactCommitCascadeTelemetryForTests() +}) + +// Why asserted: both thresholds only mean anything relative to React's own bail. +// If the notice limit ever meets it, the crumb stops beating the throw. +describe('cascade thresholds', () => { + it('derives arm and notice limits from React commit budget', () => { + expect(REACT_COMMIT_CASCADE_NOTICE_LIMIT).toBeLessThan(REACT_NESTED_UPDATE_LIMIT) + expect(REACT_COMMIT_CASCADE_ARM_COMMITS).toBeLessThan(REACT_COMMIT_CASCADE_NOTICE_LIMIT) + expect(REACT_CASCADING_LANES).toBe(42) + }) +}) + +describe('recordReactCommit', () => { + it('stays silent below the notice limit', () => { + const state = createReactCommitCascadeState() + driveCommits({ state, count: REACT_COMMIT_CASCADE_NOTICE_LIMIT - 1 }) + + expect(recordBreadcrumb).not.toHaveBeenCalled() + expect(state.commits).toBe(REACT_COMMIT_CASCADE_NOTICE_LIMIT - 1) + }) + + it('fires exactly once at the notice limit and not again in the same cascade', () => { + const state = createReactCommitCascadeState() + driveCommits({ state, count: REACT_COMMIT_CASCADE_NOTICE_LIMIT * 3 }) + + expect(recordBreadcrumb).toHaveBeenCalledTimes(1) + expect(recordBreadcrumb.mock.calls[0]?.[0]).toBe(REACT_COMMIT_CASCADE_BREADCRUMB) + expect(cascadePayload().commits).toBe(REACT_COMMIT_CASCADE_NOTICE_LIMIT) + }) + + it('emits flat primitives only', () => { + const state = createReactCommitCascadeState() + driveCommits({ state, count: REACT_COMMIT_CASCADE_NOTICE_LIMIT }) + + const payload = cascadePayload() + expect( + Object.values(payload).every((value) => value === null || typeof value !== 'object') + ).toBe(true) + expect(payload).toMatchObject({ + commits: REACT_COMMIT_CASCADE_NOTICE_LIMIT, + commitBudget: REACT_NESTED_UPDATE_LIMIT, + pendingLanes: SYNC_LANE, + storeWrites: 0, + storeWriteSites: 0, + rendererSurface: 'main' + }) + }) + + // Why this is the whole reset rule: React zeroes its counter the moment a + // commit leaves no sync lanes pending, so a lull must not accumulate. + it('ends the cascade when the root stops holding cascading lanes', () => { + const state = createReactCommitCascadeState() + driveCommits({ state, count: REACT_COMMIT_CASCADE_NOTICE_LIMIT - 1 }) + + driveCommits({ state, count: 1, lanes: 0 }) + + expect(state.commits).toBe(0) + expect(state.cascadeRoot).toBeNull() + driveCommits({ state, count: REACT_COMMIT_CASCADE_NOTICE_LIMIT - 1 }) + expect(recordBreadcrumb).not.toHaveBeenCalled() + }) + + it('ignores lanes outside React nested-update mask', () => { + const state = createReactCommitCascadeState() + driveCommits({ state, count: REACT_COMMIT_CASCADE_NOTICE_LIMIT * 2, lanes: IDLE_LANE }) + + expect(state.commits).toBe(0) + expect(recordBreadcrumb).not.toHaveBeenCalled() + }) + + // Why per root: React's counter is root-global but not cross-root, so two + // roots alternating commits are not one cascade. + it('restarts the count when a different root commits', () => { + const state = createReactCommitCascadeState() + driveCommits({ state, count: REACT_COMMIT_CASCADE_NOTICE_LIMIT - 1 }) + + driveCommits({ state, count: 1, root: OTHER_ROOT }) + + expect(state.commits).toBe(1) + expect(state.cascadeRoot).toBe(OTHER_ROOT) + expect(recordBreadcrumb).not.toHaveBeenCalled() + }) + + it('honours explicit limit overrides', () => { + const state = createReactCommitCascadeState() + for (let index = 0; index < 4; index += 1) { + recordReactCommit({ + state, + root: ROOT, + pendingLanes: SYNC_LANE, + readNowMs: () => 1_000, + noticeLimit: 4, + armCommits: 2 + }) + } + + expect(cascadePayload().commits).toBe(4) + }) + + // Why asserted: a clock read per commit is the one cost this design refuses. + it('reads the clock only at arm and at report', () => { + const state = createReactCommitCascadeState() + const readNowMs = vi.fn(() => 1_000) + for (let index = 0; index < REACT_COMMIT_CASCADE_NOTICE_LIMIT; index += 1) { + recordReactCommit({ state, root: ROOT, pendingLanes: SYNC_LANE, readNowMs }) + } + + expect(readNowMs).toHaveBeenCalledTimes(2) + }) +}) + +// Why asserted: the popout is a separate BrowserWindow with its own module +// state, so the payload is the only thing that says which window cascaded. +describe('renderer surface', () => { + it('reports the surface the cascade happened on', () => { + setReactCommitCascadeRendererSurface('dashboard-popout') + const state = createReactCommitCascadeState() + driveCommits({ state, count: REACT_COMMIT_CASCADE_NOTICE_LIMIT }) + + expect(cascadePayload().rendererSurface).toBe('dashboard-popout') + }) +}) + +describe('driver attribution', () => { + it('arms write sampling at the arm floor, not before', () => { + const state = createReactCommitCascadeState() + driveCommits({ state, count: REACT_COMMIT_CASCADE_ARM_COMMITS - 1 }) + expect(reactCommitCascadeWriteProbe.armed).toBe(false) + + driveCommits({ state, count: 1 }) + expect(reactCommitCascadeWriteProbe.armed).toBe(true) + }) + + it('names the driving write with a path-free frame', () => { + const state = createReactCommitCascadeState() + function drivingWrite(): void { + noteReactCommitCascadeStoreWrite(drivingWrite, { tabs: [] }) + } + driveCommits({ + state, + count: REACT_COMMIT_CASCADE_NOTICE_LIMIT, + onCommit: () => { + if (reactCommitCascadeWriteProbe.armed) { + drivingWrite() + } + } + }) + + const payload = cascadePayload() + expect(payload.storeWrites).toBeGreaterThan(0) + // Why the caller and not drivingWrite itself: captureStackTrace elides the + // boundary frame, so in production the first frame is whoever called `set`. + expect(String(payload.driverFrame)).toContain('onCommit') + expect(payload.changedKeys).toBe('tabs') + // Why: the path redaction is keyed on the detail NAME, so a path inside the + // value would ship a developer's home directory. + expect(String(payload.driverStack)).not.toContain('/') + // Why sanitize instead of matching the key name: the 4000-char budget is + // decided by a camel-split rule in crash-report-redaction, so a hand-copied + // regex here can pass while the real rule drops the frames to 240. + // Why resolve the key from the payload: hard-coding it here is the same + // drift this assertion exists to catch. + const stackKey = Object.keys(payload).find( + (key) => key !== 'driverFrame' && String(payload[key]).includes(String(payload.driverFrame)) + ) + expect(stackKey).toBeDefined() + const longFrames = 'a'.repeat(600) + const sanitized = sanitizeCrashReportDetails({ ...payload, [stackKey as string]: longFrames }) + expect(String(sanitized[stackKey as string])).toHaveLength(longFrames.length) + }) + + it('clears samples when a cascade ends', () => { + const state = createReactCommitCascadeState() + function drivingWrite(): void { + noteReactCommitCascadeStoreWrite(drivingWrite, { tabs: [] }) + } + driveCommits({ + state, + count: REACT_COMMIT_CASCADE_NOTICE_LIMIT, + onCommit: () => { + if (reactCommitCascadeWriteProbe.armed) { + drivingWrite() + } + } + }) + recordBreadcrumb.mockClear() + + driveCommits({ state, count: 1, lanes: 0 }) + driveCommits({ + state, + count: REACT_COMMIT_CASCADE_NOTICE_LIMIT, + nowMs: 1_000 + REACT_COMMIT_CASCADE_MIN_REPORT_INTERVAL_MS + 1 + }) + + expect(cascadePayload().storeWrites).toBe(0) + expect(cascadePayload().driverFrame).toBeUndefined() + }) +}) + +describe('report throttle', () => { + it('suppresses cascades inside the interval and carries the count forward', () => { + const state = createReactCommitCascadeState() + for (let cascade = 0; cascade < 3; cascade += 1) { + driveCommits({ state, count: 1, lanes: 0 }) + driveCommits({ state, count: REACT_COMMIT_CASCADE_NOTICE_LIMIT }) + } + + expect(recordBreadcrumb).toHaveBeenCalledTimes(1) + expect(state.suppressed).toBe(2) + + driveCommits({ state, count: 1, lanes: 0 }) + driveCommits({ + state, + count: REACT_COMMIT_CASCADE_NOTICE_LIMIT, + nowMs: 1_000 + REACT_COMMIT_CASCADE_MIN_REPORT_INTERVAL_MS + 1 + }) + + expect(recordBreadcrumb).toHaveBeenCalledTimes(2) + expect(cascadePayload(1).suppressed).toBe(2) + expect(state.suppressed).toBe(0) + }) +}) + +/** + * Why dimensional and not just functional: a cascade that ends releases its + * samples, but a renderer that oscillates for minutes runs that arm/end cycle + * thousands of times, and anything the reset misses accumulates there. + */ +describe('repeated arm and end cycles', () => { + const CYCLES = 1_000 + /** The steady state is a few KB of live objects; a per-cycle leak is megabytes. */ + const MAX_HEAP_GROWTH_BYTES = 1_000_000 + + function drivingWrite(): void { + noteReactCommitCascadeStoreWrite(drivingWrite, { tabs: [], panes: [] }) + } + + function runCascadeCycle(state: ReactCommitCascadeState): void { + driveCommits({ + state, + count: REACT_COMMIT_CASCADE_NOTICE_LIMIT, + onCommit: () => { + if (reactCommitCascadeWriteProbe.armed) { + drivingWrite() + } + } + }) + driveCommits({ state, count: 1, lanes: 0 }) + } + + it('leaves nothing behind across a thousand cascades', () => { + const collectGarbage = (globalThis as { gc?: () => void }).gc + // --expose-gc is pinned in config/vitest.config.ts execArgv. + expect(typeof collectGarbage).toBe('function') + const state = createReactCommitCascadeState() + for (let warmup = 0; warmup < 20; warmup += 1) { + runCascadeCycle(state) + } + collectGarbage?.() + const before = process.memoryUsage().heapUsed + + for (let cycle = 0; cycle < CYCLES; cycle += 1) { + runCascadeCycle(state) + } + collectGarbage?.() + + expect(process.memoryUsage().heapUsed - before).toBeLessThan(MAX_HEAP_GROWTH_BYTES) + expect(reactCommitCascadeWriteProbe.armed).toBe(false) + expect(readReactCommitCascadeWriteSummary()).toMatchObject({ + storeWrites: 0, + storeWriteSites: 0, + driverFrame: undefined, + driverStack: undefined, + changedKeys: undefined + }) + expect(state.cascadeRoot).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/react-commit-cascade-telemetry.ts b/src/renderer/src/lib/react-commit-cascade-telemetry.ts new file mode 100644 index 00000000000..73051d21599 --- /dev/null +++ b/src/renderer/src/lib/react-commit-cascade-telemetry.ts @@ -0,0 +1,205 @@ +/** + * Counts consecutive same-root commits that keep scheduling synchronous work, + * and breadcrumbs the cascade before React #185 throws. + * + * This mirrors React's own accounting rather than approximating it with a time + * window: react-dom resets its nested-update counter the moment a commit leaves + * no sync lanes pending, and increments it otherwise. Reading `root.pendingLanes` + * at commit time reproduces that reset exactly. + * + * Known over-count: React also requires the committed lanes to intersect its own + * mask, which we cannot read from the devtools callback. Dropping that term can + * only make us count a commit React would have skipped, so we fire early, never + * late — the safe direction for a diagnostic that must beat the throw. + * + * Out of scope by construction: a passive-effect (useEffect) loop reports + * pendingLanes 0 here, because passive effects flush after the commit callback. + * That is correct — React tracks those in nestedPassiveUpdateCount, which only + * console.errors in development and never throws #185. + */ +import { compactBreadcrumbData } from '@/lib/crash-breadcrumb-data' +import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' +import { + armReactCommitCascadeWriteSampling, + readReactCommitCascadeWriteSummary, + resetReactCommitCascadeWriteSamples +} from '@/lib/react-commit-cascade-store-write-samples' +import { readStoreListenerCount } from '@/store/store-listener-census' +import type { RendererSurface } from '@/lib/renderer-memory-sampling' +import { REACT_NESTED_UPDATE_LIMIT } from '../../../shared/react-update-depth-attribution' + +export const REACT_COMMIT_CASCADE_BREADCRUMB = 'react_commit_cascade' + +/** + * SyncLane | InputContinuousLane | DefaultLane. A root still holding any of + * these after a commit is what React counts as a nested update. + */ +export const REACT_CASCADING_LANES = 42 + +/** Headroom for the crumb to be built and sent before React throws above 50. */ +const REACT_COMMIT_CASCADE_NOTICE_HEADROOM = 10 +export const REACT_COMMIT_CASCADE_NOTICE_LIMIT = + REACT_NESTED_UPDATE_LIMIT - REACT_COMMIT_CASCADE_NOTICE_HEADROOM +/** + * Nothing in the app legitimately reaches 20 consecutive same-root cascading + * commits — the two deliberate oscillation loops are capped at 8 and 3 — and it + * leaves 20 commits for the sampled writes to land inside the loop. + */ +export const REACT_COMMIT_CASCADE_ARM_COMMITS = 20 +/** Matches RENDERER_BREADCRUMB_COALESCE_MS; main drops anything faster anyway. */ +export const REACT_COMMIT_CASCADE_MIN_REPORT_INTERVAL_MS = 30_000 + +export type ReactCommitCascadeState = { + /** + * Held strongly, and never dereferenced. The next commit that leaves no + * cascading lanes clears the slot — milliseconds away in a live renderer — + * so the only tree this pins is a React root unmounted mid-cascade, such as + * the per-decoration roots in useDiffCommentDecorator. + */ + cascadeRoot: unknown + commits: number + reported: boolean + armedAtMs: number | null + lastReportedAtMs: number | null + /** Cascades that reached the notice limit while the report interval was live. */ + suppressed: number +} + +export function createReactCommitCascadeState(): ReactCommitCascadeState { + return { + cascadeRoot: null, + commits: 0, + reported: false, + armedAtMs: null, + lastReportedAtMs: null, + suppressed: 0 + } +} + +const sharedState = createReactCommitCascadeState() +let rendererSurface: RendererSurface = 'main' + +export function setReactCommitCascadeRendererSurface(surface: RendererSurface): void { + rendererSurface = surface +} + +export function resetReactCommitCascadeTelemetryForTests(): void { + Object.assign(sharedState, createReactCommitCascadeState()) + rendererSurface = 'main' + resetReactCommitCascadeWriteSamples() +} + +function endCascade(state: ReactCommitCascadeState): void { + state.cascadeRoot = null + state.commits = 0 + state.reported = false + state.armedAtMs = null + resetReactCommitCascadeWriteSamples() +} + +function reportCascade(state: ReactCommitCascadeState, pendingLanes: number, nowMs: number): void { + const suppressed = state.suppressed + state.suppressed = 0 + state.lastReportedAtMs = nowMs + const writes = readReactCommitCascadeWriteSummary() + recordRendererCrashBreadcrumb( + REACT_COMMIT_CASCADE_BREADCRUMB, + compactBreadcrumbData({ + commits: state.commits, + commitBudget: REACT_NESTED_UPDATE_LIMIT, + elapsedMs: state.armedAtMs === null ? undefined : nowMs - state.armedAtMs, + pendingLanes, + // Why 0 is worth shipping: it says the loop is useState-driven, not store-driven. + storeWrites: writes.storeWrites, + storeWriteSites: writes.storeWriteSites, + driverFrame: writes.driverFrame, + driverStack: writes.driverStack, + changedKeys: writes.changedKeys, + storeListeners: readStoreListenerCount() ?? undefined, + rendererSurface, + suppressed: suppressed > 0 ? suppressed : undefined + }) + ) +} + +function recordCommit( + state: ReactCommitCascadeState, + root: unknown, + pendingLanes: number, + readNowMs: () => number, + noticeLimit: number, + armCommits: number, + minReportIntervalMs: number +): void { + if ((pendingLanes & REACT_CASCADING_LANES) === 0) { + if (state.cascadeRoot !== null) { + endCascade(state) + } + return + } + if (root !== state.cascadeRoot) { + endCascade(state) + state.cascadeRoot = root + } + + state.commits += 1 + if (state.commits < armCommits) { + return + } + if (state.commits === armCommits) { + state.armedAtMs = readNowMs() + armReactCommitCascadeWriteSampling() + return + } + if (state.reported || state.commits < noticeLimit) { + return + } + + state.reported = true + const nowMs = readNowMs() + // Why the renderer throttles too: nothing rate-limits this pipe, and every + // call is a structured clone plus an ipcRenderer.send. + const sinceReportMs = state.lastReportedAtMs === null ? null : nowMs - state.lastReportedAtMs + if (sinceReportMs !== null && sinceReportMs >= 0 && sinceReportMs < minReportIntervalMs) { + state.suppressed += 1 + return + } + reportCascade(state, pendingLanes, nowMs) +} + +/** Injectable entry point; production uses observeReactCommit. */ +export function recordReactCommit(args: { + state: ReactCommitCascadeState + root: unknown + pendingLanes: number + readNowMs: () => number + noticeLimit?: number + armCommits?: number + minReportIntervalMs?: number +}): void { + recordCommit( + args.state, + args.root, + args.pendingLanes, + args.readNowMs, + args.noticeLimit ?? REACT_COMMIT_CASCADE_NOTICE_LIMIT, + args.armCommits ?? REACT_COMMIT_CASCADE_ARM_COMMITS, + args.minReportIntervalMs ?? REACT_COMMIT_CASCADE_MIN_REPORT_INTERVAL_MS + ) +} + +/** + * Per-commit hot path: one property read, one mask, two compares, one + * increment. No clock read and no allocation until a cascade arms. + */ +export function observeReactCommit(root: unknown, pendingLanes: number): void { + recordCommit( + sharedState, + root, + pendingLanes, + Date.now, + REACT_COMMIT_CASCADE_NOTICE_LIMIT, + REACT_COMMIT_CASCADE_ARM_COMMITS, + REACT_COMMIT_CASCADE_MIN_REPORT_INTERVAL_MS + ) +} diff --git a/src/renderer/src/lib/react-devtools-commit-hook-shim.ts b/src/renderer/src/lib/react-devtools-commit-hook-shim.ts new file mode 100644 index 00000000000..7afde839020 --- /dev/null +++ b/src/renderer/src/lib/react-devtools-commit-hook-shim.ts @@ -0,0 +1,70 @@ +/** + * Guarantees __REACT_DEVTOOLS_GLOBAL_HOOK__ exists, and nothing else. + * + * Split out from the observer because the two halves have different deadlines: + * react-dom reads this global ONCE, at its own module evaluation, so a fresh + * shim must beat that import — but wrapping `onCommitFiberRoot` can happen at + * any time, since react-dom re-reads the property at every commit. This module + * therefore installs itself on import and has NO imports of its own, so a bare + * side-effect import cannot be outrun by a transitive graph that reaches + * react-dom, and cannot drag the breadcrumb recorder in ahead of any vi.mock. + */ + +/** + * Only what react-dom and react-refresh require. onCommitFiberUnmount, + * onPostCommitFiberRoot and setStrictMode are deliberately absent: react-dom + * guards each with a typeof check, and calls onCommitFiberUnmount once per + * DELETED FIBER, which is genuinely hot when terminal panes unmount. + */ +export type ReactDevtoolsCommitHook = { + isDisabled?: boolean + supportsFiber?: boolean + renderers?: Map + inject?: (renderer: unknown) => number + onCommitFiberRoot?: ( + rendererId: number, + root: unknown, + priorityLevel: unknown, + didError: boolean + ) => void +} + +// Indexed, not a global augmentation: bippy (a react-grab dependency) already +// declares this global with a narrower callback signature. +type GlobalWithReactDevtoolsHook = Record + +function createCommitHookShim(): ReactDevtoolsCommitHook { + const renderers = new Map() + return { + isDisabled: false, + supportsFiber: true, + renderers, + inject: (renderer) => { + const rendererId = renderers.size + 1 + renderers.set(rendererId, renderer) + return rendererId + }, + // Why a noop and not undefined: react-refresh captures this property and + // calls oldOnCommitFiberRoot.apply(this, arguments) with no typeof guard. + onCommitFiberRoot: () => {} + } +} + +/** Returns the existing hook untouched, or installs a minimal one. */ +export function ensureReactDevtoolsCommitHook(): ReactDevtoolsCommitHook | undefined { + try { + const globalWithHook = globalThis as unknown as GlobalWithReactDevtoolsHook + const existing = globalWithHook.__REACT_DEVTOOLS_GLOBAL_HOOK__ + if (existing) { + return existing + } + const hook = createCommitHookShim() + globalWithHook.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook + return hook + } catch { + // A renderer without a patchable global still has to boot. + return undefined + } +} + +ensureReactDevtoolsCommitHook() diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index cff89bd6b38..825ff536b6f 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -1,3 +1,9 @@ +// Why first, and why the import-free shim: react-dom reads +// __REACT_DEVTOOLS_GLOBAL_HOOK__ once at module evaluation, so the global has to +// exist before it. The observer below only wraps a property react-dom re-reads +// per commit, so its own import graph can evaluate whenever it likes. +import './lib/react-devtools-commit-hook-shim' +import './lib/react-commit-cascade-observer' import './assets/main.css' import { StrictMode } from 'react' diff --git a/src/renderer/src/popout.tsx b/src/renderer/src/popout.tsx index 00470df1374..3c84112d1a3 100644 --- a/src/renderer/src/popout.tsx +++ b/src/renderer/src/popout.tsx @@ -1,3 +1,9 @@ +// Why first, and why the import-free shim: react-dom reads +// __REACT_DEVTOOLS_GLOBAL_HOOK__ once at module evaluation, so the global has to +// exist before it. The observer below only wraps a property react-dom re-reads +// per commit, so its own import graph can evaluate whenever it likes. +import './lib/react-devtools-commit-hook-shim' +import './lib/react-commit-cascade-observer' import './assets/main.css' import { StrictMode, useEffect } from 'react' @@ -15,6 +21,7 @@ import { translate } from './i18n/i18n' import { useAppStore } from './store' import type { GlobalSettings } from '../../shared/global-settings-types' import { getOrCreateRendererRoot } from './lib/react-renderer-root' +import { setReactCommitCascadeRendererSurface } from './lib/react-commit-cascade-telemetry' // Why: the pop-out window is a separate BrowserWindow with its own React root, // so it must run the same renderer bootstrap as main.tsx (crash diagnostics, @@ -22,6 +29,7 @@ import { getOrCreateRendererRoot } from './lib/react-renderer-root' // window. It shares the preload/window.api but not the DOM or JS context. recordRendererCrashBreadcrumb('popout_bootstrap_started', { dev: import.meta.env.DEV }) installRendererCrashDiagnostics('dashboard-popout') +setReactCommitCascadeRendererSurface('dashboard-popout') function applyPopoutAppearance(settings: GlobalSettings | null): void { applyDocumentTheme(settings?.theme ?? 'system', { disableTransitions: false }) diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 3fd5b308585..35bc1845736 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -52,61 +52,64 @@ import { registerRuntimeHttpLinkBrowserOpener } from '@/lib/http-link-routing' import { installStoreListenerCensus } from './store-listener-census' +import { withReactCommitCascadeWriteProbe } from './react-commit-cascade-write-probe' import { registerRendererMemoryProfileContributor, summarizeStateCollectionSizes } from '@/lib/renderer-memory-profile' import { estimateStateCollectionKB } from '@/lib/state-collection-byte-estimate' -export const useAppStore = create()((...a) => { - // Why: the inner api is only reachable here, before create() copies subscribe onto the hook. - installStoreListenerCensus(a[2]) - return { - ...createRepoSlice(...a), - ...createSparsePresetsSlice(...a), - ...createWorktreeSlice(...a), - ...createTerminalSlice(...a), - ...createTabsSlice(...a), - ...createUISlice(...a), - ...createSettingsSlice(...a), - ...createKeybindingsSlice(...a), - ...createGitHubSlice(...a), - ...createHostedReviewSlice(...a), - ...createLinearSlice(...a), - ...createPreflightSlice(...a), - ...createJiraSlice(...a), - ...createEditorSlice(...a), - ...createStatsSlice(...a), - ...createMemorySlice(...a), - ...createWorkspaceSpaceSlice(...a), - ...createClaudeUsageSlice(...a), - ...createCodexUsageSlice(...a), - ...createOpenCodeUsageSlice(...a), - ...createBrowserSlice(...a), - ...createRateLimitSlice(...a), - ...createSshSlice(...a), - ...createRuntimeEnvironmentSshSlice(...a), - ...createAgentStatusSlice(...a), - ...createPaneForegroundAgentSlice(...a), - ...createDiffCommentsSlice(...a), - ...createDetectedAgentsSlice(...a), - ...createRuntimeDetectedAgentsSlice(...a), - ...createWorktreeNavHistorySlice(...a), - ...createDictationSlice(...a), - ...createWorkspaceCleanupSlice(...a), - ...createWorkspaceCleanupBrowseSlice(...a), - ...createRuntimeStatusSlice(...a), - ...createPullRequestGenerationSlice(...a), - ...createCommitMessageGenerationSlice(...a), - ...createPinnedTabCloseConfirmSlice(...a), - ...createRecentlyClosedTabsSlice(...a), - ...createOrcaProfilesSlice(...a), - ...createNewIssueDraftSlice(...a), - ...createTaskCreationDraftsSlice(...a), - ...createRemoteServerUpdatesSlice(...a), - ...createTerminalQuickCommandHostsSlice(...a) - } -}) +export const useAppStore = create()( + withReactCommitCascadeWriteProbe((...a) => { + // Why: the inner api is only reachable here, before create() copies subscribe onto the hook. + installStoreListenerCensus(a[2]) + return { + ...createRepoSlice(...a), + ...createSparsePresetsSlice(...a), + ...createWorktreeSlice(...a), + ...createTerminalSlice(...a), + ...createTabsSlice(...a), + ...createUISlice(...a), + ...createSettingsSlice(...a), + ...createKeybindingsSlice(...a), + ...createGitHubSlice(...a), + ...createHostedReviewSlice(...a), + ...createLinearSlice(...a), + ...createPreflightSlice(...a), + ...createJiraSlice(...a), + ...createEditorSlice(...a), + ...createStatsSlice(...a), + ...createMemorySlice(...a), + ...createWorkspaceSpaceSlice(...a), + ...createClaudeUsageSlice(...a), + ...createCodexUsageSlice(...a), + ...createOpenCodeUsageSlice(...a), + ...createBrowserSlice(...a), + ...createRateLimitSlice(...a), + ...createSshSlice(...a), + ...createRuntimeEnvironmentSshSlice(...a), + ...createAgentStatusSlice(...a), + ...createPaneForegroundAgentSlice(...a), + ...createDiffCommentsSlice(...a), + ...createDetectedAgentsSlice(...a), + ...createRuntimeDetectedAgentsSlice(...a), + ...createWorktreeNavHistorySlice(...a), + ...createDictationSlice(...a), + ...createWorkspaceCleanupSlice(...a), + ...createWorkspaceCleanupBrowseSlice(...a), + ...createRuntimeStatusSlice(...a), + ...createPullRequestGenerationSlice(...a), + ...createCommitMessageGenerationSlice(...a), + ...createPinnedTabCloseConfirmSlice(...a), + ...createRecentlyClosedTabsSlice(...a), + ...createOrcaProfilesSlice(...a), + ...createNewIssueDraftSlice(...a), + ...createTaskCreationDraftsSlice(...a), + ...createRemoteServerUpdatesSlice(...a), + ...createTerminalQuickCommandHostsSlice(...a) + } + }) +) registerHttpLinkStoreAccessor(() => useAppStore.getState()) registerRuntimeHttpLinkBrowserOpener(async (request) => { diff --git a/src/renderer/src/store/react-commit-cascade-write-probe.test.ts b/src/renderer/src/store/react-commit-cascade-write-probe.test.ts new file mode 100644 index 00000000000..0c18706e976 --- /dev/null +++ b/src/renderer/src/store/react-commit-cascade-write-probe.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { create, type StoreApi, type UseBoundStore } from 'zustand' +import { withReactCommitCascadeWriteProbe } from './react-commit-cascade-write-probe' + +const { probe, noteWrite } = vi.hoisted(() => ({ + probe: { armed: false }, + noteWrite: vi.fn() +})) +vi.mock('@/lib/react-commit-cascade-store-write-samples', () => ({ + reactCommitCascadeWriteProbe: probe, + noteReactCommitCascadeStoreWrite: (boundary: object, partial: unknown) => + noteWrite(boundary, partial) +})) + +type ProbedState = { ticks: number; label?: string } + +function createProbedStore(): UseBoundStore> { + return create()( + withReactCommitCascadeWriteProbe(() => ({ ticks: 0, label: 'initial' })) + ) +} + +beforeEach(() => { + probe.armed = false + noteWrite.mockReset() +}) + +describe('withReactCommitCascadeWriteProbe', () => { + // Why this matters most: this wraps every write the app makes. A diagnostic + // that throws here would drop the write and take app state with it. + it('lands the write even when the sampler throws', () => { + noteWrite.mockImplementation(() => { + throw new Error('sampler exploded') + }) + const useProbedStore = createProbedStore() + probe.armed = true + + expect(() => useProbedStore.setState({ ticks: 3 })).not.toThrow() + expect(useProbedStore.getState().ticks).toBe(3) + }) + + // Why asserted: create() copies api.setState onto the hook AFTER the creator + // runs, so a middleware that forgets this line leaves store.setState unprobed. + it('routes the api setState through the probe', () => { + const useProbedStore = createProbedStore() + probe.armed = true + + useProbedStore.setState({ ticks: 1 }) + + expect(noteWrite).toHaveBeenCalledTimes(1) + // The boundary must be the wrapper itself, so V8 elides it from the sample. + expect(noteWrite.mock.calls[0]?.[0]).toBe(useProbedStore.setState) + expect(noteWrite.mock.calls[0]?.[1]).toEqual({ ticks: 1 }) + }) + + it('passes the replace flag through', () => { + const useProbedStore = createProbedStore() + probe.armed = true + + useProbedStore.setState({ ticks: 2 }, true) + + expect(useProbedStore.getState()).toEqual({ ticks: 2 }) + }) + + it('costs nothing but a flag read while unarmed', () => { + const useProbedStore = createProbedStore() + + useProbedStore.setState({ ticks: 4 }) + + expect(noteWrite).not.toHaveBeenCalled() + expect(useProbedStore.getState().ticks).toBe(4) + }) +}) diff --git a/src/renderer/src/store/react-commit-cascade-write-probe.ts b/src/renderer/src/store/react-commit-cascade-write-probe.ts new file mode 100644 index 00000000000..229c46dcb0c --- /dev/null +++ b/src/renderer/src/store/react-commit-cascade-write-probe.ts @@ -0,0 +1,37 @@ +/** + * zustand middleware that samples store writes during a suspected React commit + * cascade, so the crash report names the code driving it. + * + * Why middleware and not a setState patch: create() hands slices the `set` + * closure built before `api` exists (zustand/esm/vanilla.mjs), so patching + * useAppStore.setState afterwards misses every slice-internal set() — and the + * slices are where the writes are. Wrapping the creator catches both, the same + * way store-listener-census has to reach the inner api to see hook subscribers. + * + * Cost when no cascade is suspected: one boolean field load per write. + */ +import { + noteReactCommitCascadeStoreWrite, + reactCommitCascadeWriteProbe +} from '@/lib/react-commit-cascade-store-write-samples' +import type { StateCreator } from 'zustand' + +export function withReactCommitCascadeWriteProbe( + createState: StateCreator +): StateCreator { + return (set, get, api) => { + const wrapped = ((partial: unknown, replace?: unknown): void => { + if (reactCommitCascadeWriteProbe.armed) { + try { + noteReactCommitCascadeStoreWrite(wrapped, partial) + } catch { + // This is the app's universal write path: a diagnostic that throws here + // would drop the write itself. + } + } + ;(set as (nextPartial: unknown, nextReplace?: unknown) => void)(partial, replace) + }) as typeof set + api.setState = wrapped as typeof api.setState + return createState(wrapped, get, api) + } +} diff --git a/src/shared/react-update-depth-attribution.ts b/src/shared/react-update-depth-attribution.ts index 7d79a1d36ad..98b45d478bd 100644 --- a/src/shared/react-update-depth-attribution.ts +++ b/src/shared/react-update-depth-attribution.ts @@ -4,6 +4,13 @@ * next after the counter trips. The catching boundary is a bystander picked by commit order. */ +/** + * react-dom 19.2.x NESTED_UPDATE_LIMIT. getRootForUpdatedFiber throws once the + * root-global nested-update counter passes it. Every diagnostic that budgets + * commits against React's bail must derive its threshold from this one number. + */ +export const REACT_NESTED_UPDATE_LIMIT = 50 + export const UNRELIABLE_BOUNDARY_ATTRIBUTION = 'unreliable' export type CrashReportAttribution = typeof UNRELIABLE_BOUNDARY_ATTRIBUTION From 2c07e7c1d522b0cbca806ada66a9c56b90620cd7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:46:42 -0700 Subject: [PATCH 056/152] fix(workspace-cleanup): color review pills by PR/MR state (#16726) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(workspace-cleanup): color review pills by PR/MR state The inactive-workspace review dialog rendered every linked review pill in one of two flat tones, so a merged PR, a closed PR, and an unlinked row all looked alike. Reuse the state colors the PR page and item dialog already use (purple merged, rose closed, slate draft, emerald open) and give the pill the matching state glyph. The mapping lived in two byte-identical copies; both now delegate to a shared review-state module, as does the sidebar's state-icon picker. * fix(workspace-cleanup): expose review state in confirmation rows * fix(workspace-cleanup): don't repeat the review number in the pill's sr-only text The confirmation row's screen-reader span read the whole tooltip, so the PR number was announced twice. Announce only what the color carries — the translated state label and title. * refactor(github): collapse the duplicated work-item state badge The PR page and the item dialog each carried their own copy of the badge: identical markup, identical base classes, identical open-state tone. Only the closed-ISSUE tone genuinely differs, so that becomes a parameter and the rest moves to one component. Also drops a one-line tone wrapper in workspace-cleanup and folds the review tooltip onto the screen-reader text it already duplicated. --- .../work-item-state-badge.tsx | 38 ++-------- .../github/review-state-presentation.ts | 41 ++++++++++ .../github/work-item-state-badge.tsx | 48 ++++++++++++ .../presentation/state-badge.tsx | 35 ++------- .../sidebar/worktree-review-helpers.tsx | 22 +----- .../workspace-cleanup-candidate-row-data.ts | 12 +-- .../workspace-cleanup-candidate-row.test.tsx | 74 +++++++++++++++++++ .../workspace-cleanup-candidate-row.tsx | 9 ++- .../workspace-cleanup-confirm-remove.test.tsx | 39 ++++++++++ .../workspace-cleanup-confirm-remove.tsx | 8 +- .../workspace-cleanup-metadata-chip.tsx | 8 +- .../workspace-cleanup-row-labels.ts | 13 +++- .../workspace-cleanup-status-pill.tsx | 8 +- 13 files changed, 256 insertions(+), 99 deletions(-) create mode 100644 src/renderer/src/components/github/review-state-presentation.ts create mode 100644 src/renderer/src/components/github/work-item-state-badge.tsx diff --git a/src/renderer/src/components/github-item-dialog/load-item-details/work-item-state-badge.tsx b/src/renderer/src/components/github-item-dialog/load-item-details/work-item-state-badge.tsx index cd2135b9873..0e05cb0803d 100644 --- a/src/renderer/src/components/github-item-dialog/load-item-details/work-item-state-badge.tsx +++ b/src/renderer/src/components/github-item-dialog/load-item-details/work-item-state-badge.tsx @@ -1,26 +1,14 @@ import React from 'react' -import { cn } from '@/lib/utils' +import { + getWorkItemStateTone, + WorkItemStateBadge as SharedWorkItemStateBadge +} from '@/components/github/work-item-state-badge' import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' -import { getStateLabel } from '@/components/github/work-item-state-presentation' +// Why: closed issues stay neutral here (may be completed/resolved); red is reserved +// for PR closed-without-merge. export function getStateTone(item: GitHubWorkItem): string { - if (item.type === 'pr') { - if (item.state === 'merged') { - return 'border-purple-500/30 bg-purple-500/10 text-purple-600 dark:text-purple-300' - } - if (item.state === 'draft') { - return 'border-slate-500/30 bg-slate-500/10 text-slate-600 dark:text-slate-300' - } - if (item.state === 'closed') { - return 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-300' - } - return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' - } - if (item.state === 'closed') { - // Why: keep closed issues neutral (may be completed/resolved); red is reserved for PR closed-without-merge. - return 'border-ring/50 bg-primary/10 text-foreground' - } - return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' + return getWorkItemStateTone(item, 'neutral') } export function WorkItemStateBadge({ @@ -30,15 +18,5 @@ export function WorkItemStateBadge({ item: GitHubWorkItem className?: string }): React.JSX.Element { - return ( - - {getStateLabel(item)} - - ) + return } diff --git a/src/renderer/src/components/github/review-state-presentation.ts b/src/renderer/src/components/github/review-state-presentation.ts new file mode 100644 index 00000000000..ac9ef42629e --- /dev/null +++ b/src/renderer/src/components/github/review-state-presentation.ts @@ -0,0 +1,41 @@ +import { GitMerge, GitPullRequestClosed, GitPullRequestDraft, type LucideIcon } from 'lucide-react' +import type { HostedReviewState } from '../../../../shared/hosted-review' + +/** Every review surface renders state from here; `unknown` means we have a number but no fetched state. */ +export type ReviewStateForDisplay = HostedReviewState | 'unknown' | null | undefined + +/** Also the tone for an open issue, which reads the same as an open review. */ +export const OPEN_REVIEW_STATE_TONE = + 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' + +/** Outline-pill tone: purple merged, rose closed, slate draft, emerald open, neutral unknown. */ +export function getReviewStateTone(state: ReviewStateForDisplay): string { + if (state === 'merged') { + return 'border-purple-500/30 bg-purple-500/10 text-purple-600 dark:text-purple-300' + } + if (state === 'draft') { + return 'border-slate-500/30 bg-slate-500/10 text-slate-600 dark:text-slate-300' + } + if (state === 'closed') { + return 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-300' + } + if (state === 'open') { + return OPEN_REVIEW_STATE_TONE + } + return 'border-border bg-background text-muted-foreground' +} + +// Why: the glyph must carry review state — tone alone made a draft with failing +// checks render as a red PR icon, which reads as closed. +export function getReviewStateIcon(state: ReviewStateForDisplay): LucideIcon | null { + if (state === 'merged') { + return GitMerge + } + if (state === 'closed') { + return GitPullRequestClosed + } + if (state === 'draft') { + return GitPullRequestDraft + } + return null +} diff --git a/src/renderer/src/components/github/work-item-state-badge.tsx b/src/renderer/src/components/github/work-item-state-badge.tsx new file mode 100644 index 00000000000..4286a7a2c2d --- /dev/null +++ b/src/renderer/src/components/github/work-item-state-badge.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import { cn } from '@/lib/utils' +import { getStateLabel } from '@/components/github/work-item-state-presentation' +import { + getReviewStateTone, + OPEN_REVIEW_STATE_TONE +} from '@/components/github/review-state-presentation' +import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' + +/** + * Whether a closed ISSUE reads as failure or as neutral completion. PR state tone + * never varies — only surfaces disagree on what a closed issue means. + */ +export type ClosedIssueTone = 'destructive' | 'neutral' + +export function getWorkItemStateTone(item: GitHubWorkItem, closedIssue: ClosedIssueTone): string { + if (item.type === 'pr') { + return getReviewStateTone(item.state) + } + if (item.state === 'closed') { + return closedIssue === 'neutral' + ? 'border-ring/50 bg-primary/10 text-foreground' + : 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-300' + } + return OPEN_REVIEW_STATE_TONE +} + +export function WorkItemStateBadge({ + item, + className, + closedIssue +}: { + item: GitHubWorkItem + className?: string + closedIssue: ClosedIssueTone +}): React.JSX.Element { + return ( + + {getStateLabel(item)} + + ) +} diff --git a/src/renderer/src/components/pull-request-page/presentation/state-badge.tsx b/src/renderer/src/components/pull-request-page/presentation/state-badge.tsx index 62d38f67806..e50b76344e4 100644 --- a/src/renderer/src/components/pull-request-page/presentation/state-badge.tsx +++ b/src/renderer/src/components/pull-request-page/presentation/state-badge.tsx @@ -1,25 +1,12 @@ import React from 'react' -import { cn } from '@/lib/utils' -import { getStateLabel } from '@/components/github/work-item-state-presentation' +import { + getWorkItemStateTone, + WorkItemStateBadge as SharedWorkItemStateBadge +} from '@/components/github/work-item-state-badge' import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' export function getStateTone(item: GitHubWorkItem): string { - if (item.type === 'pr') { - if (item.state === 'merged') { - return 'border-purple-500/30 bg-purple-500/10 text-purple-600 dark:text-purple-300' - } - if (item.state === 'draft') { - return 'border-slate-500/30 bg-slate-500/10 text-slate-600 dark:text-slate-300' - } - if (item.state === 'closed') { - return 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-300' - } - return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' - } - if (item.state === 'closed') { - return 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-300' - } - return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' + return getWorkItemStateTone(item, 'destructive') } /** Filled counterpart of `getStateTone` for high-emphasis surfaces like the PR page header. */ @@ -42,15 +29,5 @@ export function WorkItemStateBadge({ item: GitHubWorkItem className?: string }): React.JSX.Element { - return ( - - {getStateLabel(item)} - - ) + return } diff --git a/src/renderer/src/components/sidebar/worktree-review-helpers.tsx b/src/renderer/src/components/sidebar/worktree-review-helpers.tsx index 96e64fb4423..26a16c9f629 100644 --- a/src/renderer/src/components/sidebar/worktree-review-helpers.tsx +++ b/src/renderer/src/components/sidebar/worktree-review-helpers.tsx @@ -1,5 +1,6 @@ -import { GitMerge, GitPullRequestClosed, GitPullRequestDraft } from 'lucide-react' +import { GitMerge } from 'lucide-react' import { cn } from '@/lib/utils' +import { getReviewStateIcon } from '@/components/github/review-state-presentation' import { PullRequestIcon } from './WorktreeCardHelpers' import type { WorktreeCardPrDisplay } from './worktree-card-pr-display' @@ -23,23 +24,6 @@ export function getProviderName(review: WorktreeCardPrDisplay): string { return 'GitHub' } -// Why: the glyph must carry review state — tone alone made a draft with failing -// checks render as a red PR icon, which reads as closed. -function getStateIcon( - state: WorktreeCardPrDisplay['state'] -): React.ComponentType<{ className?: string }> | null { - if (state === 'merged') { - return GitMerge - } - if (state === 'closed') { - return GitPullRequestClosed - } - if (state === 'draft') { - return GitPullRequestDraft - } - return null -} - // Why: checks only gate a review that is actually open; draft/closed/merged keep // their state tone so the glyph agrees with its tooltip. A stateless row (folder // cards render one while a linked review is loading or its details failed) has no @@ -88,6 +72,6 @@ export function ReviewIcon({ }): React.JSX.Element { const providerIcon = variant === 'provider' && review.provider === 'gitlab' ? GitMerge : PullRequestIcon - const Icon = getStateIcon(review.state) ?? providerIcon + const Icon = getReviewStateIcon(review.state) ?? providerIcon return } diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts index 75103a98d0c..1f9d3d2ce52 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts @@ -1,9 +1,6 @@ import { translate } from '@/i18n/i18n' import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' -import { - getWorkspaceCleanupGitLabel, - type WorkspaceCleanupReviewInfo -} from './workspace-cleanup-presentation' +import { getWorkspaceCleanupGitLabel } from './workspace-cleanup-presentation' import { formatUnpushedCommitCount, formatWorkspaceCleanupContextDetail, @@ -172,13 +169,6 @@ function hasGitStatusPill(candidate: WorkspaceCleanupCandidate): boolean { return (candidate.git.upstreamAhead ?? 0) > 0 || candidate.git.clean === false } -export function getReviewPillTone(reviewInfo: WorkspaceCleanupReviewInfo): StatusPillTone { - if (reviewInfo.state === 'open' || reviewInfo.state === 'draft') { - return 'review' - } - return 'neutral' -} - export function getContextCount(candidate: WorkspaceCleanupCandidate): number { return ( candidate.localContext.terminalTabCount + diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx index d9deb16e8e9..9d4ee5a6e2f 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx @@ -247,4 +247,78 @@ describe('CandidateRow', () => { expect(container?.querySelector(`[aria-label="Select ${remoteName}"]`)).not.toBeNull() expect(container?.querySelector(`[aria-label="Host: builder"]`)).not.toBeNull() }) + + it('colors the review pill by state the way the PR page and item dialog do', () => { + const states = [ + { state: 'merged' as const, label: 'PR #1', hue: 'purple' }, + { state: 'closed' as const, label: 'PR #2', hue: 'rose' }, + { state: 'open' as const, label: 'PR #3', hue: 'emerald' }, + { state: 'draft' as const, label: 'PR #4', hue: 'slate' } + ] + + for (const { state, label, hue } of states) { + const candidate = makeCandidate() + + act(() => { + root?.render( + + ) + }) + + const pill = container?.querySelector(`[aria-label*="${label}"]`) + expect(pill?.className).toContain(`text-${hue}-`) + expect(pill?.className).not.toContain('text-muted-foreground') + } + }) + + it('leaves a review with an unfetched state neutral', () => { + const candidate = makeCandidate() + + act(() => { + root?.render( + + ) + }) + + const pill = container?.querySelector('[aria-label*="PR #9"]') + expect(pill?.className).toContain('text-muted-foreground') + }) }) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx index bec347d570d..fd4c442d55b 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx @@ -36,10 +36,13 @@ import { getCandidateFactStatuses, getContextCount, getDirtyGitLabel, - getReviewPillTone, getWorkspaceCleanupBlockerLabels, shouldShowGitMetadataChip } from './workspace-cleanup-candidate-row-data' +import { + getReviewStateIcon, + getReviewStateTone +} from '@/components/github/review-state-presentation' import { StatusPill } from './workspace-cleanup-status-pill' import { WorkspaceCleanupMetadataChip } from './workspace-cleanup-metadata-chip' import { WorkspaceCleanupForgetLocallyButton } from './workspace-cleanup-forget-locally-button' @@ -255,10 +258,10 @@ export const CandidateRow = React.memo(function CandidateRow({ ) : null} {reviewInfo.label ? ( ) : null} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx index dbffc7214e5..379a9d60d10 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx @@ -218,4 +218,43 @@ describe('WorkspaceCleanupConfirmRemove', () => { '1 workspace currently shows risk and may need a force delete' ) }) + + it('gives the review pill a text equivalent for its state color without repeating the number', () => { + const candidate = makeFacetCandidate({ + worktreeId: 'repo-1::/repo/merged-review', + displayName: 'Merged review', + path: '/repo/merged-review' + }) + + act(() => { + root.render( + + ) + }) + + const srText = container.querySelector('span.sr-only')?.textContent ?? '' + expect(srText).toContain('Merged') + expect(srText).not.toContain('PR #15716') + }) }) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx index c7ccae4a8fe..97a31aedc5c 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx @@ -15,11 +15,12 @@ import { getCandidateFactStatuses, formatContextDetailLabels, getDirtyGitLabel, - getReviewPillTone, shouldShowGitMetadataChip } from './workspace-cleanup-candidate-row-data' import type { WorkspaceCleanupReviewInfo } from './workspace-cleanup-presentation' import { formatWorkspaceCleanupRelativeTime } from './workspace-cleanup-relative-time' +import { getReviewStateTone } from '@/components/github/review-state-presentation' +import { getReviewStateSrText } from './workspace-cleanup-row-labels' import { StatusPill } from './workspace-cleanup-status-pill' import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list' import { @@ -255,7 +256,10 @@ function ConfirmRemoveRow({ ))} {reviewInfo.label ? ( - {reviewInfo.label} + + {reviewInfo.label} + {getReviewStateSrText(reviewInfo)} + ) : null} {contextPillLabels.map((label) => ( {label} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-metadata-chip.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-metadata-chip.tsx index 5fea9257680..bae88d8fc03 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-metadata-chip.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-metadata-chip.tsx @@ -8,12 +8,15 @@ export function WorkspaceCleanupMetadataChip({ icon: Icon, label, value, - tone = 'neutral' + tone = 'neutral', + toneClassName }: { icon: LucideIcon label: string value?: string tone?: StatusPillTone + /** Overrides `tone` for chips whose color comes from a domain state (e.g. review state). */ + toneClassName?: string }): React.JSX.Element { return ( @@ -25,7 +28,8 @@ export function WorkspaceCleanupMetadataChip({ tone === 'ready' && 'border-[color:color-mix(in_srgb,var(--git-decoration-added)_45%,transparent)] bg-[color:color-mix(in_srgb,var(--git-decoration-added)_10%,transparent)] text-[var(--git-decoration-added)]', tone === 'review' && 'bg-muted text-foreground', - tone === 'destructive' && 'border-destructive/30 text-destructive' + tone === 'destructive' && 'border-destructive/30 text-destructive', + toneClassName )} aria-label={label} > diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-labels.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-labels.ts index 887cb1c17d0..b7b897f1a22 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-labels.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-labels.ts @@ -1,9 +1,20 @@ import type { WorkspaceCleanupReviewInfo } from './workspace-cleanup-presentation' +import { getWorkspaceCleanupReviewStateLabel } from './workspace-cleanup-facet-labels' export function formatCompactActivityLabel(label: string): string { return label === 'Just now' ? 'now' : label.replace(/ ago$/, '') } export function getReviewTooltip(reviewInfo: WorkspaceCleanupReviewInfo): string { - return [reviewInfo.label, reviewInfo.state, reviewInfo.title].filter(Boolean).join(' · ') + return [reviewInfo.label, getReviewStateSrText(reviewInfo)].filter(Boolean).join(' · ') +} + +/** Text equivalent for the state color on pills that render the number visibly. */ +export function getReviewStateSrText(reviewInfo: WorkspaceCleanupReviewInfo): string { + if (!reviewInfo.state) { + return '' + } + return [getWorkspaceCleanupReviewStateLabel(reviewInfo.state), reviewInfo.title] + .filter(Boolean) + .join(' · ') } diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-status-pill.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-status-pill.tsx index 1e5786fda49..0a5efdd2bdc 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-status-pill.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-status-pill.tsx @@ -4,10 +4,13 @@ import type { StatusPillTone } from './workspace-cleanup-candidate-row-data' export function StatusPill({ children, - tone = 'neutral' + tone = 'neutral', + toneClassName }: { children: React.ReactNode tone?: StatusPillTone + /** Overrides `tone` for pills whose color comes from a domain state (e.g. review state). */ + toneClassName?: string }): React.JSX.Element { return ( {children} From 928d306b53be9284a0f1d1dcc35465b15b112795 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:48:53 -0700 Subject: [PATCH 057/152] fix(agent-hooks): stop the Windows hook launcher spelling the AV-denied flag pair (STA-5237) (#16739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(agent-hooks): stop the Windows hook launcher spelling the AV-denied flag pair (STA-5237) `-WindowStyle Hidden` + `-EncodedCommand` is denied at CreateProcess by Kaspersky on Windows 11, whatever the payload decodes to. Bash reports it as `Permission denied` and every managed hook event fails, so agent status never arrives; the parent shell also briefly cannot spawn anything afterwards, so a denied hook can take the user's next command down with it. Measured on the reporting host (#16003), with a harmless `exit 0` payload: -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand 126 -NoProfile -WindowStyle Hidden -EncodedCommand 126 -WindowStyle Hidden -EncodedCommand 126 -NoProfile -EncodedCommand 0 (5/5) #16576 removed `-ExecutionPolicy Bypass`, which is the one flag of the three NOT in the signature, so hooks kept failing after that fix. The pair that has to stop being spelled is `-WindowStyle Hidden` + `-EncodedCommand`. Because the change is to the shared switch constant, it covers every site that spells the denied pair in one edit: Claude via `wrapWindowsPowerShellEncodedCommand`, gemini/cursor/droid/command-code/copilot via `wrapWindowsHookCommand`, the `runtime-home-hook-command` unsafe-HOME fallback, and the spaced-path fallback for codex/grok/devin/antigravity. Only a flag is removed, so parser and payload compatibility is unchanged for every executor: the string is still a PowerShell command line, still one self-contained token, still base64-shielded. The tradeoff, recorded rather than hidden: `-WindowStyle Hidden` was the shipped fix for #14815 (+#14828, #15117, #15447, #15767), and this removes it. Its suppression was never measured — #14825 confirmed it visually, #16576's author stated it "remains unverified on a real box", and #15506's author argued it cannot help a `.cmd` child with no console to inherit. The console is allocated by the parent chain, not by this command line. A live window measurement is still outstanding and is called out in the PR. Also adds `windows-hook-payload-delivery.test.ts` to the PR CI Windows leg, which had never run it. * test(agent-hooks): keep launcher token out of source grep --- .github/workflows/pr.yml | 1 + src/main/agent-hooks/installer-utils.test.ts | 4 +- .../windows-powershell-hook-launcher.test.ts | 48 ++++++++++--------- .../windows-powershell-hook-launcher.ts | 41 +++++++++------- src/main/claude/hook-service.test.ts | 8 +--- .../hook-service-managed-install.test.ts | 2 +- src/main/command-code/hook-service.test.ts | 2 +- src/main/cursor/hook-service.test.ts | 2 +- src/main/droid/hook-service.test.ts | 2 +- src/main/gemini/hook-service.test.ts | 2 +- src/main/grok/hook-service.test.ts | 2 +- 11 files changed, 61 insertions(+), 53 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 0862ac31fe7..a256da5112c 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -703,6 +703,7 @@ jobs: src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts src/shared/child-process/windows-command-line.win32.test.ts + src/main/agent-hooks/windows-hook-payload-delivery.test.ts src/main/windows/windows-pty-job.win32.test.ts src/main/windows/windows-host-job.win32.test.ts src/main/wsl/wsl-runner.test.ts diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index 88c73550da2..3cf2525ba1e 100644 --- a/src/main/agent-hooks/installer-utils.test.ts +++ b/src/main/agent-hooks/installer-utils.test.ts @@ -604,7 +604,7 @@ describe('wrapPosixHookCommand', () => { }) const qualifiedWindowsPowerShellCommand = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -EncodedCommand \S+$/ function decodeWindowsHookCommand(command: string): string { const encodedCommand = command.match(/ -EncodedCommand (\S+)$/)?.[1] @@ -762,7 +762,7 @@ describe('wrapRuntimeHomeHookCommand', () => { // applies to the exact same string (#16003). const command = wrapRuntimeHomeHookCommand('claude-hook') - expect(command).toContain('powershell.exe" -NoProfile -WindowStyle Hidden -EncodedCommand ') + expect(command).toContain('powershell.exe" -NoProfile -EncodedCommand ') expect(command).not.toMatch(/-ExecutionPolicy/i) }) diff --git a/src/main/agent-hooks/windows-powershell-hook-launcher.test.ts b/src/main/agent-hooks/windows-powershell-hook-launcher.test.ts index e58fd25c960..df307d87035 100644 --- a/src/main/agent-hooks/windows-powershell-hook-launcher.test.ts +++ b/src/main/agent-hooks/windows-powershell-hook-launcher.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { encodeWindowsPowerShellHookCommand, + getWindowsPowerShellExecutablePath, WINDOWS_POWERSHELL_HOOK_SWITCHES, wrapWindowsPowerShellEncodedCommand } from './windows-powershell-hook-launcher' @@ -12,38 +13,41 @@ function decodePayload(command: string): string { } /* - * Two reproduced Windows failures constrain this one string, in opposite - * directions: + * #16003 — endpoint security (Kaspersky, Windows 11) denies process creation + * for the encoded launcher shape whatever the payload decodes to, and no + * exclusion re-enabled it. Measured on the reporting host: the denied shape + * exits 126 with or without the policy switch and with or without `-NoProfile`, + * while `-NoProfile -EncodedCommand` alone runs 5/5. So the command line spells + * neither the policy bypass (moved into the payload by #16576) nor the denied + * window switch. * - * #16003 — endpoint security (Kaspersky Premium, Windows 11) denies process - * creation for `-ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand` - * whatever the payload decodes to, and no exclusion re-enabled it. The triple - * must stop being spelled. - * - * #14815 (+ #14828, #15117, #15447, #15767) — without `-WindowStyle Hidden` - * every hook event allocates a console that takes foreground and eats the - * user's keystrokes, and strands a visible window outright when the hook blocks - * on stdin. Window suppression must stay. - * - * Both hold only if the flag that leaves the command line is the policy bypass, - * which is the one with an exact in-payload equivalent. + * The counter-pressure is real and is NOT resolved by these assertions: + * #14815 (+ #14828, #15117, #15447, #15767) reported consoles taking + * foreground. The previous suppression switch was that fix, but its behavior + * was never measured — see the launcher's comment. These tests pin the shape + * so it cannot be restored silently; whether a console appears is a question + * for a live window measurement, which no unit test here can answer. */ describe('windows PowerShell hook launcher', () => { - it('never spells the denied flag triple on the command line', () => { + it('never spells a denied flag on the command line', () => { const command = wrapWindowsPowerShellEncodedCommand('exit 0') + const switches = command.replace(/ -EncodedCommand \S+$/, '') expect(WINDOWS_POWERSHELL_HOOK_SWITCHES).not.toMatch(/-ExecutionPolicy/i) - expect(command.replace(/ -EncodedCommand \S+$/, '')).not.toMatch(/-ExecutionPolicy/i) + expect(switches).not.toMatch(/-ExecutionPolicy/i) + // Why: measured exit 126 for the denied switch paired with -EncodedCommand. + expect(WINDOWS_POWERSHELL_HOOK_SWITCHES).toBe('-NoProfile') + expect(switches).toBe(`${getWindowsPowerShellExecutablePath()} -NoProfile`) }) - it('keeps hiding the console window on every hook event (#14815)', () => { - // Dropping this flag is not a cosmetic flash: the console takes foreground - // and swallows what the user is typing into Orca (#14828), and never closes - // at all when the hook blocks reading stdin (#14815). + it('pins the exact launcher shape so a flag cannot come back unnoticed', () => { + // Why this is worth a test: #16576 round 3 dropped a required launcher + // invariant by accident and updated the tests to match, so nothing caught + // it. Restoring either denied switch re-breaks every hook on an AV host. const command = wrapWindowsPowerShellEncodedCommand('exit 0') - expect(WINDOWS_POWERSHELL_HOOK_SWITCHES).toBe('-NoProfile -WindowStyle Hidden') - expect(command).toMatch(/ -NoProfile -WindowStyle Hidden -EncodedCommand [A-Za-z0-9+/=]+$/) + expect(WINDOWS_POWERSHELL_HOOK_SWITCHES).toBe('-NoProfile') + expect(command).toMatch(/ -NoProfile -EncodedCommand [A-Za-z0-9+/=]+$/) }) it('keeps the execution-policy bypass, in the payload where AV cannot read it', () => { diff --git a/src/main/agent-hooks/windows-powershell-hook-launcher.ts b/src/main/agent-hooks/windows-powershell-hook-launcher.ts index aeec04380db..b9a9f6dd208 100644 --- a/src/main/agent-hooks/windows-powershell-hook-launcher.ts +++ b/src/main/agent-hooks/windows-powershell-hook-launcher.ts @@ -14,26 +14,33 @@ export function getWindowsPowerShellExecutablePath(): string { * Switches for the PowerShell that relays hook output and exit status * (#14818 — conhost does neither). * - * `-WindowStyle Hidden` stays. It is the shipped fix for #14815 and its four - * duplicates (#14828, #15117, #15447, #15767): without it Windows allocates a - * console per hook event, which steals foreground from whatever the user is - * typing into, and strands a permanently visible window whenever a hook blocks - * reading stdin (see hook-stdin-contract.ts). Those are reported, reproduced - * user-facing failures on every hook event of every managed agent. + * The command line spells no flag beyond `-NoProfile`, because AV denies the + * combinations. #16003 measured, on the reporting Kaspersky host: * - * `-ExecutionPolicy Bypass` is the flag that leaves the command line, because - * it is the only one of the three with an exact in-payload equivalent (below): - * it costs nothing to move. #16003 measured `-NoProfile -ExecutionPolicy Bypass - * -WindowStyle Hidden -EncodedCommand` as denied at CreateProcess (exit 126, - * Kaspersky Premium on Windows 11) no matter what the payload decodes to, so - * the triple has to stop being spelled; dropping the policy flag breaks it. + * -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand 126 + * -NoProfile -WindowStyle Hidden -EncodedCommand 126 + * -WindowStyle Hidden -EncodedCommand 126 + * -NoProfile -EncodedCommand 0 (5/5) + * -NoProfile -ExecutionPolicy Bypass -Command 0 (5/5) * - * What is not established: that the remaining pair clears that AV signature — - * the reporter measured no two-flag encoded shape. If it turns out not to, the - * answer is another launcher shape that still suppresses the window, not - * trading a reproduced regression for an unmeasured hope. + * The denial is at CreateProcess and is independent of the payload: `exit 0` is + * denied too, and bash reports it as `Permission denied`. So `-WindowStyle + * Hidden` + `-EncodedCommand` is the pair that has to stop being spelled. + * #16576 removed `-ExecutionPolicy Bypass` (kept in-payload below), which was + * the one flag of the three NOT in the signature — hooks kept failing. + * + * `-WindowStyle Hidden` was the shipped fix for #14815 (+#14828, #15117, + * #15447, #15767). Removing it is a real tradeoff and is recorded as such: its + * suppression was never measured — #14825 confirmed it *visually*, #16576's + * author stated it "remains unverified on a real box", and #15506's author + * argued it cannot help a `.cmd` child that has no console to inherit. The + * console is allocated by the parent chain, not by this command line. + * + * Do not restore the flag to fix a console report. That trades every hook on an + * AV host for a flicker. The answer is to shorten the interpreter chain — the + * shipped doctrine of #15520 and #15595 — or a launcher that owns no console. */ -export const WINDOWS_POWERSHELL_HOOK_SWITCHES = '-NoProfile -WindowStyle Hidden' +export const WINDOWS_POWERSHELL_HOOK_SWITCHES = '-NoProfile' // Why: redirected PowerShell progress becomes CLIXML that can corrupt merged JSON // output. It must be the FIRST statement: Set-ExecutionPolicy autoloads diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index 315ee6dd228..531c092e3eb 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -40,9 +40,7 @@ describe('getWindowsManagedLifecycleHook', () => { const hook = getWindowsManagedLifecycleHook(scriptPath) expect(hook.args).toBeUndefined() - expect(hook.command).toMatch( - /\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand / - ) + expect(hook.command).toMatch(/\/powershell\.exe -NoProfile -EncodedCommand /) expect(hook.command).not.toContain(scriptPath) // Why: Git Bash/MSYS mangles backslash paths and slash-prefixed switches. expect(hook.command.replace(/-EncodedCommand \S+$/, '')).not.toMatch(/\\| \/[a-zA-Z]+( |$)/) @@ -384,9 +382,7 @@ describe('ClaudeHookService.install', () => { for (const eventName of ['UserPromptSubmit', 'Stop', 'StopFailure']) { const hook = settings.hooks[eventName]?.[0]?.hooks?.[0] expect(hook?.args).toBeUndefined() - expect(hook?.command).toMatch( - /\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand / - ) + expect(hook?.command).toMatch(/\/powershell\.exe -NoProfile -EncodedCommand /) expect(hook?.command).not.toContain(scriptPath) const encoded = hook?.command.match(/-EncodedCommand (\S+)$/)?.[1] diff --git a/src/main/codex/hook-service-managed-install.test.ts b/src/main/codex/hook-service-managed-install.test.ts index 5d0021f9499..3e242dc2cf4 100644 --- a/src/main/codex/hook-service-managed-install.test.ts +++ b/src/main/codex/hook-service-managed-install.test.ts @@ -31,7 +31,7 @@ import { CodexHookService } from './hook-service' import { runExclusivelyForCodexTrustConfig } from './codex-trust-config-mutation-queue' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -EncodedCommand \S+$/ const homes = setupCodexHookHomes(homedirMock, getPathMock) diff --git a/src/main/command-code/hook-service.test.ts b/src/main/command-code/hook-service.test.ts index f5b0f83af62..9765ef5b2f4 100644 --- a/src/main/command-code/hook-service.test.ts +++ b/src/main/command-code/hook-service.test.ts @@ -21,7 +21,7 @@ vi.mock('os', async () => { import { CommandCodeHookService } from './hook-service' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -EncodedCommand \S+$/ describe('CommandCodeHookService', () => { let homeDir: string diff --git a/src/main/cursor/hook-service.test.ts b/src/main/cursor/hook-service.test.ts index 36f6aa94b48..c6aef9aefc5 100644 --- a/src/main/cursor/hook-service.test.ts +++ b/src/main/cursor/hook-service.test.ts @@ -23,7 +23,7 @@ import { CURSOR_EVENTS, type CursorEvent } from './hook-events' const CURSOR_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'cursor-hook.cmd' : 'cursor-hook.sh' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -EncodedCommand \S+$/ type InstalledCursorHooks = { hooks: Record diff --git a/src/main/droid/hook-service.test.ts b/src/main/droid/hook-service.test.ts index bd145fdc1de..7486a12b71b 100644 --- a/src/main/droid/hook-service.test.ts +++ b/src/main/droid/hook-service.test.ts @@ -25,7 +25,7 @@ vi.mock('os', async () => { import { DroidHookService } from './hook-service' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -EncodedCommand \S+$/ describe('DroidHookService', () => { let homeDir: string diff --git a/src/main/gemini/hook-service.test.ts b/src/main/gemini/hook-service.test.ts index a835a6278f8..551c40bb0b1 100644 --- a/src/main/gemini/hook-service.test.ts +++ b/src/main/gemini/hook-service.test.ts @@ -26,7 +26,7 @@ vi.mock('os', async (importOriginal) => { import { GeminiHookService } from './hook-service' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -EncodedCommand \S+$/ describe('GeminiHookService', () => { let homeDir: string diff --git a/src/main/grok/hook-service.test.ts b/src/main/grok/hook-service.test.ts index 4b1625614ac..251ae64fa33 100644 --- a/src/main/grok/hook-service.test.ts +++ b/src/main/grok/hook-service.test.ts @@ -33,7 +33,7 @@ import { POSIX_HOOK_STDIN_READER } from '../agent-hooks/hook-stdin-contract' const GROK_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -EncodedCommand \S+$/ // Why (#14828): Windows registers the bare script path when it is cmd-safe and only falls back // to the encoded launcher for a profile path that is not (#6078). windows-hook-launcher-chain From 7c3bfe72d7fdba55436af410a384838550e9371c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:52:14 -0700 Subject: [PATCH 058/152] fix(windows): stop a wedged process-table reader retaining a callback per cooldown (#16696) * fix(windows): stop a wedged process-table reader retaining a callback per cooldown The vendored reader pushes every callback onto a module-global queue and drains it only when the request holding its `requestInProgress` latch completes. When a Toolhelp32 snapshot never comes back, that latch is stuck for the life of the process, so the 30 s cooldown -- which let one probe through per window -- bounded the rate of new callbacks but not the total: one more closure retained every 30 s, forever, plus a full 3 s deadline block on whichever caller drew the probe. Gate on the outstanding read instead. Once a read misses its deadline and has not called back, every further read is refused until that read's callback fires, which bounds retention at exactly one callback. Nothing is given up on recovery: a probe queued behind the latch could never have observed the drain anyway, whereas the stuck callback firing IS the drain, so the reader now resumes the instant it recovers rather than up to 30 s later. It matters more on a relay, which binds the bare addon with no JS queue to absorb the retries. Each read there is a `Napi::AsyncWorker`, so a wedged one holds a libuv threadpool slot for good and one probe per window would have pinned all four default threads inside ~2 minutes -- hanging every async `fs` and DNS call in that process, not just the process table. A wedge still does not engage the PowerShell fallback, and a wedged read still rejects rather than resolving empty, so "unavailable" stays distinguishable from "nothing is running" on every host. Fixes STA-5499. * fix(windows): invalidate stale reader deadlines on reset --- docs/reference/windows-process-enumeration.md | 34 +++++++ .../windows/windows-process-table.test.ts | 90 +++++++++++++++---- src/main/windows/windows-process-table.ts | 59 ++++++------ 3 files changed, 133 insertions(+), 50 deletions(-) diff --git a/docs/reference/windows-process-enumeration.md b/docs/reference/windows-process-enumeration.md index 115cc7040a9..e4c91329c46 100644 --- a/docs/reference/windows-process-enumeration.md +++ b/docs/reference/windows-process-enumeration.md @@ -46,6 +46,40 @@ risk — but the headroom is roughly 2x on time and 1.7x on bytes, not the ~4x t parse, and the read rejects, so a busy host loses the table rather than receiving a wrong one. +## When a read wedges + +The vendored reader pushes every callback onto a module-global queue and drains +that queue only when the request holding its `requestInProgress` latch +completes. If a Toolhelp32 snapshot never comes back — an EDR hook, a restricted +token, a worker that dies — the latch is stuck for the life of the process and +every later call parks another closure in that queue. + +Two guards, and they work together: + +- a **3 s deadline** on each read, so a caller gets a rejection instead of a + promise that never settles; +- a **sticky wedge**: once a read misses its deadline and has not called back, + the module refuses every further read until that read's callback fires. + +The wedge used to be a 30 s cooldown that let one probe through per window. That +bounded the *rate* of new callbacks but not the total: a permanently wedged +reader retained one more closure every 30 s for as long as the app ran, and each +probe also blocked its caller for the full 3 s deadline first. Gating on the +outstanding read instead bounds retention at exactly one callback, and gives up +nothing on recovery — a probe queued behind the latch could never have observed +recovery anyway, whereas the stuck callback firing is the drain itself. On the +relay's bare addon, which has no queue of its own, it is also what keeps Orca +from re-entering `CreateToolhelp32Snapshot` while a call is still running. + +That last part is not just tidiness. The addon runs each read as a +`Napi::AsyncWorker`, so a wedged read holds a libuv threadpool slot for good. On +the relay the JS queue is not there to absorb the retries, so one probe per +window would have pinned all four default threads inside ~2 minutes — hanging +every async `fs` and DNS call in that process, not only the process table. + +A wedge does **not** engage the PowerShell fallback; see the next section for +why only absence does. + ## The relay has no binding, and falls back Relay deployment installs only `node-pty` and `@parcel/watcher` on the remote diff --git a/src/main/windows/windows-process-table.test.ts b/src/main/windows/windows-process-table.test.ts index 919a80deb1c..8bbd0ed7ccb 100644 --- a/src/main/windows/windows-process-table.test.ts +++ b/src/main/windows/windows-process-table.test.ts @@ -184,7 +184,7 @@ describe('PowerShell fallback when the native binding is absent', () => { }) }) -describe('wedge cooldown', () => { +describe('sticky wedge', () => { let platform: PropertyDescriptor | undefined beforeEach(() => { @@ -204,7 +204,7 @@ describe('wedge cooldown', () => { // The vendored reader latches a global while a request is in flight and // drains its queue only when that request completes. In the wedge this // guards against it never does, so every retry would add a closure that is - // never called. One probe per cooldown bounds that. + // never called. vi.useFakeTimers() const getAllProcesses = vi.fn(() => {}) __setWindowsProcessTreeLoaderForTests(() => ({ @@ -218,15 +218,15 @@ describe('wedge cooldown', () => { await firstAssertion expect(getAllProcesses).toHaveBeenCalledTimes(1) - await expect(readWindowsProcessTableFresh()).rejects.toThrow(/cooling down/) + await expect(readWindowsProcessTableFresh()).rejects.toThrow(/wedged/) expect(getAllProcesses).toHaveBeenCalledTimes(1) }) - it('lets exactly one caller probe when the cooldown expires', async () => { - // Without claiming the recovery slot before probing, every concurrent - // caller passes the cooldown check at expiry and each enqueues a callback - // into the still-latched native queue -- so each cycle leaks another batch - // rather than bounding it to one probe. + it('never re-enters the reader while the timed-out read is still out', async () => { + // A cooldown bounds the RATE of new callbacks, not the total: one probe per + // window still retains one more closure in the still-latched native queue + // every window, for the life of the app. Only the outstanding read can + // reopen the gate, so a permanent wedge retains exactly one callback. vi.useFakeTimers() const getAllProcesses = vi.fn(() => {}) __setWindowsProcessTreeLoaderForTests(() => ({ @@ -240,16 +240,72 @@ describe('wedge cooldown', () => { await wedgeAssertion expect(getAllProcesses).toHaveBeenCalledTimes(1) - await vi.advanceTimersByTimeAsync(30_000) - const attempts = [ - readWindowsProcessTableFresh().catch(() => 'rejected'), - readWindowsProcessTableFresh().catch(() => 'rejected'), - readWindowsProcessTableFresh().catch(() => 'rejected') - ] - await vi.advanceTimersByTimeAsync(3_000) - await Promise.all(attempts) + // Four windows of the cooldown this replaced, each with concurrent callers. + // Rejection reasons are deliberately not asserted here: the leak this test + // pins is the call count, and it must fail on that alone. + for (let window = 0; window < 4; window += 1) { + await vi.advanceTimersByTimeAsync(30_000) + const attempts = [ + readWindowsProcessTableFresh().catch(() => 'rejected'), + readWindowsProcessTableFresh().catch(() => 'rejected'), + readWindowsProcessTableFresh().catch(() => 'rejected') + ] + // Long enough for a probe's own deadline, had one been let through. + await vi.advanceTimersByTimeAsync(3_000) + expect(await Promise.all(attempts)).toEqual(['rejected', 'rejected', 'rejected']) + } - // One recovery probe, not three. + // Still the one callback the wedge is holding -- not one more per window. + expect(getAllProcesses).toHaveBeenCalledTimes(1) + }) + + it('resumes as soon as the timed-out read finally calls back', async () => { + // The stuck request completing is what drains the vendored queue, so it is + // the only honest evidence the reader recovered. Waiting out a wall clock + // afterwards would strand a reader that is already answering. + vi.useFakeTimers() + let stuck: ((rows: typeof NATIVE | undefined) => void) | undefined + const getAllProcesses = vi.fn((cb: (rows: typeof NATIVE | undefined) => void) => { + if (stuck) { + cb(NATIVE) + return + } + stuck = cb + }) + __setWindowsProcessTreeLoaderForTests(() => ({ + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + getAllProcesses + })) + + const wedge = readWindowsProcessTableFresh() + const wedgeAssertion = expect(wedge).rejects.toThrow(/timed out/) + await vi.advanceTimersByTimeAsync(3_000) + await wedgeAssertion + await expect(readWindowsProcessTableFresh()).rejects.toThrow(/wedged/) + + stuck?.(NATIVE) + await expect(readWindowsProcessTableFresh()).resolves.toHaveLength(NATIVE.length) + expect(getAllProcesses).toHaveBeenCalledTimes(2) + }) + + it('ignores a pending timeout from before a test reset', async () => { + vi.useFakeTimers() + const getAllProcesses = vi.fn((_cb: (rows: typeof NATIVE | undefined) => void) => {}) + __setWindowsProcessTreeLoaderForTests(() => ({ + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + getAllProcesses + })) + + const staleRead = readWindowsProcessTableFresh() + const staleAssertion = expect(staleRead).rejects.toThrow(/timed out/) + await vi.advanceTimersByTimeAsync(0) + expect(getAllProcesses).toHaveBeenCalledTimes(1) + resetWindowsProcessTableForTests() + await vi.advanceTimersByTimeAsync(3_000) + await staleAssertion + + getAllProcesses.mockImplementation((cb) => cb(NATIVE)) + await expect(readWindowsProcessTableFresh()).resolves.toHaveLength(NATIVE.length) expect(getAllProcesses).toHaveBeenCalledTimes(2) }) diff --git a/src/main/windows/windows-process-table.ts b/src/main/windows/windows-process-table.ts index e03ecee6e29..35dab09d100 100644 --- a/src/main/windows/windows-process-table.ts +++ b/src/main/windows/windows-process-table.ts @@ -146,20 +146,18 @@ function loadWindowsProcessTree(): WindowsProcessTreeModule | null { const WINDOWS_PROCESS_QUERY_TIMEOUT_MS = 3_000 /** - * How long to stop calling the reader after it misses its deadline. - * - * Why a cooldown and not just the deadline: a timed-out call leaves its - * callback in the vendored module's queue, and that queue only drains when the - * latched request finally completes -- which, in the wedge this guards against, - * never happens. Retrying at the caller's poll rate would then add a closure - * per tick forever. One probe per cooldown bounds it. + * Reads that missed their deadline and have not called back yet. + * Refusing re-entry bounds both vendored callbacks and relay addon workers to + * one; read ids keep a late callback from clearing a newer wedge. */ -const WINDOWS_PROCESS_QUERY_COOLDOWN_MS = 30_000 +const unreturnedReads = new Set() +let readSequence = 0 +let nativeReaderEpoch = 0 -let wedgedUntilMs = 0 -// Why a generation: a request that already lost its deadline must not later -// clear or re-arm the wedge on behalf of the request that replaced it. -let readGeneration = 0 +function resetNativeReaderState(): void { + nativeReaderEpoch += 1 + unreturnedReads.clear() +} function readNativeRows(): Promise { const native = moduleLoader() @@ -177,19 +175,13 @@ function readNativeRows(): Promise { // tree dead. "Unavailable" has to stay distinguishable from "empty". return Promise.reject(new Error('windows process table unavailable')) } - const startedAt = Date.now() - if (startedAt < wedgedUntilMs) { - return Promise.reject(new Error('windows process table is cooling down after a timeout')) + if (unreturnedReads.size > 0) { + return Promise.reject( + new Error('windows process table is wedged: an earlier read has not returned') + ) } - if (wedgedUntilMs > 0) { - // Coming out of a wedge: re-arm the cooldown BEFORE probing, so exactly one - // caller gets through. Without this every concurrent caller passes the - // check above at expiry, each enqueues a callback into the still-latched - // native queue, and each cooldown cycle leaks another batch rather than - // bounding it to one probe. - wedgedUntilMs = startedAt + WINDOWS_PROCESS_QUERY_COOLDOWN_MS - } - const generation = ++readGeneration + const readId = ++readSequence + const readerEpoch = nativeReaderEpoch // Why always both flags: each adds an OpenProcess per process (Memory a // GetProcessMemoryInfo, CommandLine a PEB read), so asking for less would be // cheaper -- 15.9ms p50 versus 30.6ms at 1050 processes. But every read shares @@ -204,18 +196,19 @@ function readNativeRows(): Promise { let deadline: ReturnType | undefined try { deadline = setTimeout(() => { - if (generation === readGeneration) { - wedgedUntilMs = Date.now() + WINDOWS_PROCESS_QUERY_COOLDOWN_MS + // Test resets invalidate deadlines owned by the prior injected reader. + if (readerEpoch === nativeReaderEpoch) { + unreturnedReads.add(readId) } reject(new Error('windows process table timed out')) }, WINDOWS_PROCESS_QUERY_TIMEOUT_MS) deadline.unref?.() native.getAllProcesses((processes) => { clearTimeout(deadline) - // A callback proves the reader is answering, so stop refusing. - if (generation === readGeneration) { - wedgedUntilMs = 0 - } + // A callback proves this read drained, so stop refusing. Unconditional: + // dropping an id that was never added is a no-op, and only the read + // that actually wedged can be holding the gate shut. + unreturnedReads.delete(readId) if (!processes) { reject(new Error('windows process table returned no snapshot')) return @@ -303,7 +296,7 @@ export function __setWindowsProcessTreeLoaderForTests( ): void { moduleLoader = loader ?? loadWindowsProcessTree cachedModule = undefined - wedgedUntilMs = 0 + resetNativeReaderState() snapshotReader.reset() } @@ -314,7 +307,7 @@ export function __setWindowsProcessTreeRequireForTests( requireNative = resolve ?? requireFromMain moduleLoader = loadWindowsProcessTree cachedModule = undefined - wedgedUntilMs = 0 + resetNativeReaderState() snapshotReader.reset() } @@ -330,5 +323,5 @@ export function __setWindowsProcessTableCimScanForTests( export function resetWindowsProcessTableForTests(): void { snapshotReader.reset() cachedModule = undefined - wedgedUntilMs = 0 + resetNativeReaderState() } From fecdf0bde8850b81403df4609fbc98d0e805f31f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:13:32 -0700 Subject: [PATCH 059/152] fix(daemon): recover concurrent spawns after disconnect (#16818) --- src/main/daemon/daemon-endpoint-errors.ts | 4 + ...on-pty-adapter-concurrent-recovery.test.ts | 78 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 src/main/daemon/daemon-pty-adapter-concurrent-recovery.test.ts diff --git a/src/main/daemon/daemon-endpoint-errors.ts b/src/main/daemon/daemon-endpoint-errors.ts index 663b11ff3fa..b792f0e7044 100644 --- a/src/main/daemon/daemon-endpoint-errors.ts +++ b/src/main/daemon/daemon-endpoint-errors.ts @@ -1,4 +1,5 @@ import { DAEMON_ENDPOINT_LOST_MESSAGE } from './daemon-endpoint-ownership' +import { DaemonConnectionLostError } from './daemon-errors' import { DAEMON_UNAVAILABLE_RECONNECT_MESSAGE } from './types' /** @@ -17,6 +18,9 @@ export function isDaemonGoneError(err: unknown): boolean { if (!(err instanceof Error)) { return false } + if (err instanceof DaemonConnectionLostError) { + return true + } const errno = err as NodeJS.ErrnoException if ((errno.code === 'ENOENT' || errno.code === 'ECONNREFUSED') && errno.syscall === 'connect') { return true diff --git a/src/main/daemon/daemon-pty-adapter-concurrent-recovery.test.ts b/src/main/daemon/daemon-pty-adapter-concurrent-recovery.test.ts new file mode 100644 index 00000000000..12805c0b3ee --- /dev/null +++ b/src/main/daemon/daemon-pty-adapter-concurrent-recovery.test.ts @@ -0,0 +1,78 @@ +import { expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { DaemonServer } from './daemon-server' +import { createMockSubprocess } from './daemon-pty-adapter-test-harness' +import { getDaemonSocketPath } from './daemon-spawner' + +it('recovers concurrent spawns rejected by an adapter-initiated disconnect', async () => { + const concurrentSpawnCount = 70 + const dir = mkdtempSync(join(tmpdir(), 'daemon-concurrent-recovery-test-')) + const socketPath = getDaemonSocketPath(dir) + const tokenPath = join(dir, 'test.token') + const allPreparationsEntered = Promise.withResolvers() + const releaseHeldPreparations = Promise.withResolvers() + const spawnedSessionIds: string[] = [] + let preparationCount = 0 + + const spawnSubprocess: ConstructorParameters[0]['spawnSubprocess'] = ( + options + ) => { + spawnedSessionIds.push(options.sessionId) + return createMockSubprocess() + } + let server = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess, + // Hold siblings until one retriable reply makes the adapter disconnect their shared socket. + preparePtySpawn: async () => { + const preparationNumber = ++preparationCount + if (preparationCount === concurrentSpawnCount) { + allPreparationsEntered.resolve() + } + await allPreparationsEntered.promise + if (preparationNumber === 1) { + throw new Error('Connection lost') + } + await releaseHeldPreparations.promise + } + }) + await server.start() + + const respawn = vi.fn(async () => { + await server.shutdown() + releaseHeldPreparations.resolve() + server = new DaemonServer({ socketPath, tokenPath, spawnSubprocess }) + await server.start() + }) + const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn }) + + try { + const sessionIds = Array.from( + { length: concurrentSpawnCount }, + (_, index) => `high-volume-${index}` + ) + const results = await Promise.all( + sessionIds.map((sessionId) => adapter.spawn({ sessionId, cols: 80, rows: 24 })) + ) + + expect(preparationCount).toBe(concurrentSpawnCount) + expect(respawn).toHaveBeenCalledTimes(1) + expect(respawn).toHaveBeenCalledWith('daemon_died') + expect(new Set(results.map(({ id }) => id))).toEqual(new Set(sessionIds)) + expect(new Set(spawnedSessionIds)).toEqual(new Set(sessionIds)) + + const later = await adapter.spawn({ sessionId: 'after-recovery', cols: 80, rows: 24 }) + expect(later.id).toBe('after-recovery') + expect(respawn).toHaveBeenCalledTimes(1) + expect(spawnedSessionIds).toHaveLength(concurrentSpawnCount + 1) + } finally { + adapter.dispose() + releaseHeldPreparations.resolve() + await server.shutdown() + rmSync(dir, { recursive: true, force: true }) + } +}) From cc384c5a3d12508684156dd7ce5d3eaff121380f Mon Sep 17 00:00:00 2001 From: Mark Xian Date: Fri, 28 Aug 2026 03:32:29 +0800 Subject: [PATCH 060/152] fix(agent-hooks): post posix payloads as json (#11292) * fix(agent-hooks): post posix payloads as json * fix(agent-hooks): mark header merged envelopes * docs(agent-hooks): describe header merge envelope * fix(agent-hooks): encode posix metadata headers * test(agent-hooks): update WSL JSON hook assertions * fix(agent-hooks): negotiate raw JSON transport * fix(agent-hooks): preserve packed metadata in POSIX shells * test(agent-hooks): include hook envelope in relay boundary inventory --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- config/tsconfig.cli.json | 1 + src/main/agent-hooks/hook-post-command.ts | 37 +++++++++ src/main/agent-hooks/installer-utils.test.ts | 21 +++++ .../server-endpoint-file-lifecycle.test.ts | 1 + .../server-hook-http-ingest.test.ts | 32 +++++++ src/main/agent-hooks/server.ts | 17 ++-- src/main/claude/hook-service.test.ts | 10 ++- src/main/claude/hook-service.ts | 19 ++--- .../codex/hook-service-wsl-runtime.test.ts | 6 ++ src/main/codex/hook-service.ts | 22 ++--- src/main/ipc/pty/host-env/spawn-env-keys.ts | 1 + src/main/pty/wsl-orca-env.test.ts | 2 + src/main/pty/wsl-orca-env.ts | 1 + src/main/runtime/orca-runtime.ts | 1 + src/relay/agent-hook-endpoint-coordinates.ts | 8 +- src/relay/agent-hook-server.test.ts | 40 ++++++--- src/relay/agent-hook-server.ts | 22 +++-- ...ent-hook-listener-relay-dependency.test.ts | 1 + .../agent-hook-listener-transport.test.ts | 81 +++++++++++++++++- .../endpoint-publication.ts | 4 + .../agent-hook-listener/hook-envelope.ts | 83 +++++++++++++++++++ src/shared/agent-hook-types.ts | 3 + 22 files changed, 356 insertions(+), 57 deletions(-) create mode 100644 src/main/agent-hooks/hook-post-command.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index f4555ae4a48..cec4e80cff5 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -5,6 +5,7 @@ "../src/shared/**/*", "../src/main/agent-state-file-reader.ts", "../src/main/agent-hooks/hook-stdin-contract.ts", + "../src/main/agent-hooks/hook-post-command.ts", "../src/main/agent-hooks/hook-config-write-path.ts", "../src/main/agent-hooks/hooks-json-read.ts", "../src/main/agent-hooks/installer-utils.ts", diff --git a/src/main/agent-hooks/hook-post-command.ts b/src/main/agent-hooks/hook-post-command.ts new file mode 100644 index 00000000000..1595e681899 --- /dev/null +++ b/src/main/agent-hooks/hook-post-command.ts @@ -0,0 +1,37 @@ +import type { AgentHookSource } from '../../shared/agent-hook-relay' +import { ORCA_HOOK_RAW_JSON_TRANSPORT } from '../../shared/agent-hook-types' + +export function buildPosixAgentHookPostCommand( + source: AgentHookSource, + options: { curlCommand?: string; indent?: string } = {} +): string[] { + const curlCommand = options.curlCommand ?? 'curl' + const indent = options.indent ?? ' ' + return [ + `if [ "\${ORCA_AGENT_HOOK_TRANSPORT:-}" = "${ORCA_HOOK_RAW_JSON_TRANSPORT}" ] && command -v base64 >/dev/null 2>&1 && command -v tr >/dev/null 2>&1; then`, + ` orca_hook_metadata=$(printf '%s\\037%s\\037%s\\037%s\\037%s\\037%s' "$ORCA_PANE_KEY" "$ORCA_TAB_ID" "$ORCA_AGENT_LAUNCH_TOKEN" "$ORCA_WORKTREE_ID" "$ORCA_AGENT_HOOK_ENV" "$ORCA_AGENT_HOOK_VERSION" | base64 | tr -d '\\n') && \\`, + ` [ -n "$orca_hook_metadata" ] && \\`, + ` printf '%s' "$payload" | ${curlCommand} -sS -X POST "http://127.0.0.1:\${ORCA_AGENT_HOOK_PORT}/hook/${source}" \\`, + ` ${indent}--connect-timeout "\${connect_timeout:-0.5}" --max-time "\${max_time:-1.5}" \\`, + ` ${indent}--noproxy "127.0.0.1" \\`, + ` ${indent}-H "Content-Type: application/json" \\`, + ` ${indent}-H "X-Orca-Agent-Hook-Token: \${ORCA_AGENT_HOOK_TOKEN}" \\`, + ` ${indent}-H "X-Orca-Agent-Hook-Meta-Encoding: base64" \\`, + ` ${indent}-H "X-Orca-Agent-Hook-Meta: \${orca_hook_metadata}" \\`, + ` ${indent}--data-binary @-`, + 'else', + ` printf '%s' "$payload" | ${curlCommand} -sS -X POST "http://127.0.0.1:\${ORCA_AGENT_HOOK_PORT}/hook/${source}" \\`, + ` ${indent}--connect-timeout "\${connect_timeout:-0.5}" --max-time "\${max_time:-1.5}" \\`, + ` ${indent}--noproxy "127.0.0.1" \\`, + ` ${indent}-H "Content-Type: application/x-www-form-urlencoded" \\`, + ` ${indent}-H "X-Orca-Agent-Hook-Token: \${ORCA_AGENT_HOOK_TOKEN}" \\`, + ` ${indent}--data-urlencode "paneKey=\${ORCA_PANE_KEY}" \\`, + ` ${indent}--data-urlencode "tabId=\${ORCA_TAB_ID}" \\`, + ` ${indent}--data-urlencode "launchToken=\${ORCA_AGENT_LAUNCH_TOKEN}" \\`, + ` ${indent}--data-urlencode "worktreeId=\${ORCA_WORKTREE_ID}" \\`, + ` ${indent}--data-urlencode "env=\${ORCA_AGENT_HOOK_ENV}" \\`, + ` ${indent}--data-urlencode "version=\${ORCA_AGENT_HOOK_VERSION}" \\`, + ` ${indent}--data-urlencode "payload@-"`, + 'fi' + ] +} diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index 3cf2525ba1e..215979206e9 100644 --- a/src/main/agent-hooks/installer-utils.test.ts +++ b/src/main/agent-hooks/installer-utils.test.ts @@ -30,6 +30,7 @@ import { writeHooksJson, type HooksConfig } from './installer-utils' +import { buildPosixAgentHookPostCommand } from './hook-post-command' import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract' import { wrapRuntimeHomeHookCommand } from './runtime-home-hook-command' @@ -882,6 +883,26 @@ describe('buildWindowsAgentHookPostCommand', () => { }) }) +describe('buildPosixAgentHookPostCommand', () => { + it('uses raw JSON only when the listener advertises support', () => { + const command = buildPosixAgentHookPostCommand('claude').join('\n') + + expect(command).toContain('ORCA_AGENT_HOOK_TRANSPORT:-}') + expect(command).toContain('raw-json-v1') + expect(command).toContain('command -v base64') + expect(command).toContain('command -v tr') + expect(command).toContain('Content-Type: application/json') + expect(command).toContain('X-Orca-Agent-Hook-Meta-Encoding: base64') + expect(command).toContain('X-Orca-Agent-Hook-Meta: ${orca_hook_metadata}') + expect(command).toContain("printf '%s\\037%s\\037%s\\037%s\\037%s\\037%s'") + expect(command).toContain('$ORCA_PANE_KEY') + expect(command).toContain('$ORCA_WORKTREE_ID') + expect(command).toContain('--data-binary @-') + expect(command).toContain('Content-Type: application/x-www-form-urlencoded') + expect(command).toContain('--data-urlencode "payload@-"') + }) +}) + describe('buildWindowsAgentHookCurlPostCommand', () => { it('posts form fields via curl.exe and reads the payload from stdin', () => { const command = buildWindowsAgentHookCurlPostCommand('codex') diff --git a/src/main/agent-hooks/server-endpoint-file-lifecycle.test.ts b/src/main/agent-hooks/server-endpoint-file-lifecycle.test.ts index b567e726695..91b7c495532 100644 --- a/src/main/agent-hooks/server-endpoint-file-lifecycle.test.ts +++ b/src/main/agent-hooks/server-endpoint-file-lifecycle.test.ts @@ -66,6 +66,7 @@ describe('Endpoint file lifecycle', () => { expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_TOKEN=${expectedToken}`) expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_ENV=development`) expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_VERSION=1`) + expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_TRANSPORT=raw-json-v1`) } finally { server.stop() } diff --git a/src/main/agent-hooks/server-hook-http-ingest.test.ts b/src/main/agent-hooks/server-hook-http-ingest.test.ts index 55f2c6e1007..f89dcebd005 100644 --- a/src/main/agent-hooks/server-hook-http-ingest.test.ts +++ b/src/main/agent-hooks/server-hook-http-ingest.test.ts @@ -44,6 +44,38 @@ async function postClaudeHook( } describe('AgentHookServer listener replay', () => { + it('accepts raw JSON hook bodies with base64 metadata headers', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN, + 'X-Orca-Agent-Hook-Meta-Encoding': 'base64', + 'X-Orca-Agent-Hook-Meta': Buffer.from( + [PANE, 'tab-1', '', 'wt-1', 'production', ''].join('\x1f') + ).toString('base64') + }, + body: JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt: 'raw JSON' }) + }) + + expect(response.status).toBe(204) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + worktreeId: 'wt-1', + state: 'working', + prompt: 'raw JSON' + }) + ]) + } finally { + server.stop() + } + }) + it('caches and notifies status/main/plugin before retry scheduling and HTTP response', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index ba29d3bc211..311ea277879 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -8,7 +8,10 @@ import { join } from 'node:path' import { track } from '../telemetry/client' import { getCohortAtEmit } from '../telemetry/cohort-classifier' import { AGENT_KIND_VALUES, type AgentKind } from '../../shared/telemetry-events' -import { ORCA_HOOK_PROTOCOL_VERSION } from '../../shared/agent-hook-types' +import { + ORCA_HOOK_PROTOCOL_VERSION, + ORCA_HOOK_RAW_JSON_TRANSPORT +} from '../../shared/agent-hook-types' import { clearAllListenerCaches, clearPaneCacheState, @@ -46,6 +49,7 @@ import { } from '../../shared/agent-hook-listener/listener-limits' import { isNewTurnEvent } from '../../shared/agent-hook-listener/provider-event-routing' import { normalizeHookPayload } from '../../shared/agent-hook-listener' +import { mergeAgentHookRequestHeaders } from '../../shared/agent-hook-listener/hook-envelope' import { parseFormEncodedBody, readRequestBody @@ -2616,8 +2620,9 @@ export class AgentHookServer { return } - trackEmptyPaneKeyHook(body) - const aliasedBody = this.normalizeHookBodyPaneKeyAlias(body) + const hookBody = mergeAgentHookRequestHeaders(body, req.headers) + trackEmptyPaneKeyHook(hookBody) + const aliasedBody = this.normalizeHookBodyPaneKeyAlias(hookBody) const normalized = this.normalizeLocalHookPayload(source, aliasedBody) const statusDisposition = normalized.event ? this.getAgentStatusDisposition(normalized.event.paneKey, { @@ -3129,7 +3134,8 @@ export class AgentHookServer { ORCA_AGENT_HOOK_PORT: String(this.port), ORCA_AGENT_HOOK_TOKEN: this.token, ORCA_AGENT_HOOK_ENV: this.env, - ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION + ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION, + ORCA_AGENT_HOOK_TRANSPORT: ORCA_HOOK_RAW_JSON_TRANSPORT } // Why: hooks source this file at invocation; dev namespaces it so parallel `pnpm dev` runs don't steal each other's hooks. if (this.endpointFileWritten && this.endpointFilePathCache) { @@ -3156,7 +3162,8 @@ export class AgentHookServer { port: this.port, token: this.token, env: this.env, - version: ORCA_HOOK_PROTOCOL_VERSION + version: ORCA_HOOK_PROTOCOL_VERSION, + transport: ORCA_HOOK_RAW_JSON_TRANSPORT }) this.endpointFileWritten = ok } diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index 531c092e3eb..e2937015bea 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -539,12 +539,14 @@ describe('ClaudeHookService.installRemote', () => { expect(script!.indexOf('printf "{}\\n"')).toBe( script!.indexOf('#!/bin/sh') + '#!/bin/sh\n'.length ) - // Why: payload is piped to curl via stdin (`payload@-`) so it never lands - // on the curl command line (EDR oversized-command-line false positive), - // matching the Windows curl.exe hook post. + // Why: payload stays on stdin, while metadata headers avoid URL-encoded IDS signatures. expect(script).toContain('printf \'%s\' "$payload" | curl') + expect(script).toContain('-H "Content-Type: application/json"') + expect(script).toContain('orca_hook_metadata=$(printf') + expect(script).toContain('unset ORCA_AGENT_HOOK_TRANSPORT') + expect(script).toContain('-H "X-Orca-Agent-Hook-Meta: ${orca_hook_metadata}"') + expect(script).toContain('--data-binary @-') expect(script).toContain('--data-urlencode "payload@-"') - expect(script).not.toContain('--data-urlencode "payload=${payload}"') expect(fs.modes.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toBe(0o755) // Why: no remote statusLine — this path serves SSH remotes and WSL guests, whose relay // listener doesn't route /statusline/claude and whose accounts aren't attributable locally. diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 3dba1121a20..b3ae8d01136 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -9,6 +9,7 @@ import { writeManagedScript, type HooksConfig } from '../agent-hooks/installer-utils' +import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -114,25 +115,17 @@ function getManagedScript( // Why: refresh endpoint coordinates for PTYs surviving an Orca restart. // Why: suppress parse errors so they neither leak nor trip outer set -e. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', + ' unset ORCA_AGENT_HOOK_TRANSPORT', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' spool_hook_event', ' exit 0', 'fi', - // Why: post form fields because path-bearing payloads are unsafe in hand-built JSON. - // Why: pipe payload to curl stdin to keep large output off the command line. - 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/claude" \\', - ' --connect-timeout 0.5 --max-time 1.5 \\', - ' -H "Content-Type: application/x-www-form-urlencoded" \\', - ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', - ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', - ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', - ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', - ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', - ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', - ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', + // Why: keep full hook JSON off the command line and avoid IDS-friendly URL-encoded paths. + ...buildPosixAgentHookPostCommand('claude').map((line, index, lines) => + index === lines.length - 1 ? `${line} >/dev/null 2>&1 || spool_hook_event` : line + ), 'exit 0', '' ].join('\n') diff --git a/src/main/codex/hook-service-wsl-runtime.test.ts b/src/main/codex/hook-service-wsl-runtime.test.ts index 8e892e69147..5694e4fe9bb 100644 --- a/src/main/codex/hook-service-wsl-runtime.test.ts +++ b/src/main/codex/hook-service-wsl-runtime.test.ts @@ -275,11 +275,15 @@ describe('Codex WSL runtime hook install', () => { it('generates a POSIX hook that bridges WSL loopback failures through Windows curl', async () => { const script = _internals.getManagedScript('posix') expect(script).toContain('load_hook_endpoint()') + expect(script).toContain('unset ORCA_AGENT_HOOK_TRANSPORT') expect(script).toContain('"set ORCA_AGENT_HOOK_TOKEN="*)') expect(script).toContain('post_codex_hook()') expect(script).toContain('is_wsl_runtime()') expect(script).toContain('WSL_DISTRO_NAME') expect(script).toContain('windows_curl=$(command -v curl.exe 2>/dev/null || true)') + expect(script).toContain('-H "Content-Type: application/json"') + expect(script).toContain('-H "X-Orca-Agent-Hook-Meta-Encoding: base64"') + expect(script).toContain('--data-binary @-') expect(script).toContain('--data-urlencode "payload@-"') expect(script).toContain('if post_codex_hook curl >/dev/null 2>&1; then') expect(script).toContain('post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1 || true') @@ -302,6 +306,7 @@ describe('Codex WSL runtime hook install', () => { 'set ORCA_AGENT_HOOK_TOKEN=fresh-token', 'set ORCA_AGENT_HOOK_ENV=development', 'set ORCA_AGENT_HOOK_VERSION=1', + 'set ORCA_AGENT_HOOK_TRANSPORT=raw-json-v1', '' ].join('\r\n'), 'utf-8' @@ -333,6 +338,7 @@ describe('Codex WSL runtime hook install', () => { const posted = readFileSync(capturePath, 'utf-8') expect(posted).toContain('http://127.0.0.1:43210/hook/codex') expect(posted).toContain('X-Orca-Agent-Hook-Token: fresh-token') + expect(posted).toContain('Content-Type: application/json') expect(posted).not.toContain('stale-token') } ) diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index a25f14bcd89..0ea7837f154 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -19,6 +19,7 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command' import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path' import { writeFileAtomically } from '../codex-accounts/fs-utils' @@ -823,6 +824,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { // Why: sourcing refreshes PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps reporting after a restart (see claude/hook-service.ts). 'load_hook_endpoint() {', ' endpoint_path="$1"', + ' unset ORCA_AGENT_HOOK_TRANSPORT', ' case "$endpoint_path" in', ' *.cmd)', // Why: Windows passes endpoint.cmd into WSL via WSLENV; parse only Orca's known assignments since cmd.exe `set` lines aren't shell syntax. @@ -834,6 +836,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' "set ORCA_AGENT_HOOK_TOKEN="*) ORCA_AGENT_HOOK_TOKEN=${endpoint_line#*=} ;;', ' "set ORCA_AGENT_HOOK_ENV="*) ORCA_AGENT_HOOK_ENV=${endpoint_line#*=} ;;', ' "set ORCA_AGENT_HOOK_VERSION="*) ORCA_AGENT_HOOK_VERSION=${endpoint_line#*=} ;;', + ' "set ORCA_AGENT_HOOK_TRANSPORT="*) ORCA_AGENT_HOOK_TRANSPORT=${endpoint_line#*=} ;;', ' esac', ' done < "$endpoint_path"', ' ;;', @@ -853,20 +856,11 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' curl_bin="$1"', ' connect_timeout="${2:-0.5}"', ' max_time="${3:-1.5}"', - // Why: worktreeId embeds a path, so hand-building JSON in shell is unsafe with quotes/newlines; post raw payload plus metadata as form fields instead. - // Why: pipe payload to curl's stdin (`payload@-`) not an inline arg, so tens-of-KB tool output stays off the command line (EDR false positives). - ' printf \'%s\' "$payload" | "$curl_bin" -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\', - ' --connect-timeout "$connect_timeout" --max-time "$max_time" \\', - ' --noproxy "127.0.0.1" \\', - ' -H "Content-Type: application/x-www-form-urlencoded" \\', - ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', - ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', - ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', - ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', - ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', - ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', - ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-"', + // Why: keep full hook JSON off the command line and avoid URL-encoding paths/commands into IDS-friendly traversal signatures. + ...buildPosixAgentHookPostCommand('codex', { + curlCommand: '"$curl_bin"', + indent: ' ' + }).map((line) => ` ${line}`), '}', 'is_wsl_runtime() {', ' [ -n "$WSL_DISTRO_NAME" ] && return 0', diff --git a/src/main/ipc/pty/host-env/spawn-env-keys.ts b/src/main/ipc/pty/host-env/spawn-env-keys.ts index 0cd0b183729..cabbde7b0ec 100644 --- a/src/main/ipc/pty/host-env/spawn-env-keys.ts +++ b/src/main/ipc/pty/host-env/spawn-env-keys.ts @@ -3,6 +3,7 @@ export const AGENT_HOOK_RUNTIME_ENV_KEYS = [ 'ORCA_AGENT_HOOK_TOKEN', 'ORCA_AGENT_HOOK_ENV', 'ORCA_AGENT_HOOK_VERSION', + 'ORCA_AGENT_HOOK_TRANSPORT', 'ORCA_AGENT_HOOK_ENDPOINT', // Why: PR 2778 briefly exported this path; keep deleting stale inherited values so older PTYs can't leak the reverted path. 'ORCA_CLAUDE_AGENT_STATUS_SETTINGS' diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index f1275403001..133844dec78 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -73,6 +73,7 @@ describe('addOrcaWslInteropEnv', () => { ORCA_AGENT_HOOK_TOKEN: 'token', ORCA_AGENT_HOOK_ENV: 'dev', ORCA_AGENT_HOOK_VERSION: '1', + ORCA_AGENT_HOOK_TRANSPORT: 'raw-json-v1', ORCA_WSL_HOOK_INSTANCE: 'testinstance', ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'wsl', ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'local', @@ -95,6 +96,7 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_TOKEN/u') expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_ENV/u') expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_VERSION/u') + expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_TRANSPORT/u') expect(env.WSLENV).toContain('ORCA_WSL_HOOK_INSTANCE/u') expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND/u') expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID/u') diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index a6fad65ca61..0fe855ba3b0 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -90,6 +90,7 @@ export function addOrcaWslInteropEnv(env: Record): void { 'ORCA_AGENT_HOOK_TOKEN/u', 'ORCA_AGENT_HOOK_ENV/u', 'ORCA_AGENT_HOOK_VERSION/u', + 'ORCA_AGENT_HOOK_TRANSPORT/u', `ORCA_AGENT_HOOK_ENDPOINT/${endpointFlag}`, ...opencodeOverlayEntries, 'ORCA_WSL_HOOK_RELAY_VERSION/u', diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 0b25b3cef46..f83e5e777e4 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2845,6 +2845,7 @@ const AGENT_HOOK_RUNTIME_ENV_KEYS = [ 'ORCA_AGENT_HOOK_TOKEN', 'ORCA_AGENT_HOOK_ENV', 'ORCA_AGENT_HOOK_VERSION', + 'ORCA_AGENT_HOOK_TRANSPORT', 'ORCA_AGENT_HOOK_ENDPOINT' ] as const diff --git a/src/relay/agent-hook-endpoint-coordinates.ts b/src/relay/agent-hook-endpoint-coordinates.ts index 868fc120689..ac066f8afd9 100644 --- a/src/relay/agent-hook-endpoint-coordinates.ts +++ b/src/relay/agent-hook-endpoint-coordinates.ts @@ -4,7 +4,10 @@ import { basename, dirname, join } from 'node:path' import { homedir } from 'node:os' -import { ORCA_HOOK_PROTOCOL_VERSION } from '../shared/agent-hook-types' +import { + ORCA_HOOK_PROTOCOL_VERSION, + ORCA_HOOK_RAW_JSON_TRANSPORT +} from '../shared/agent-hook-types' // Why: relay's userData equivalent under $HOME so each user on a shared dev box gets their own 0o700 dir. const RELAY_HOOKS_DIR_NAME = '.orca-relay' @@ -49,7 +52,8 @@ export function buildRelayHookPtyEnv(coordinates: { ORCA_AGENT_HOOK_PORT: String(coordinates.port), ORCA_AGENT_HOOK_TOKEN: coordinates.token, ORCA_AGENT_HOOK_ENV: coordinates.env, - ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION + ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION, + ORCA_AGENT_HOOK_TRANSPORT: ORCA_HOOK_RAW_JSON_TRANSPORT } if (coordinates.endpointFileWritten) { env.ORCA_AGENT_HOOK_ENDPOINT = coordinates.endpointFilePath diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index e247706b5dc..c3a99b8a20c 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -8,6 +8,7 @@ import { endpointDirForRelaySocket } from './agent-hook-endpoint-coordinates' import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' import { makePaneKey } from '../shared/stable-pane-id' import * as agentHookListener from '../shared/agent-hook-listener/grok-result-discovery' +import { HOOK_REQUEST_MAX_BYTES } from '../shared/agent-hook-listener/request-body' const LEAF_ID = '11111111-1111-4111-8111-111111111111' const PANE_KEY = makePaneKey('tab-1', LEAF_ID) @@ -42,7 +43,7 @@ describe('RelayAgentHookServer', () => { expect(endpointDir).not.toContain('\\\\.\\pipe') }) - it('forwards a parsed Claude UserPromptSubmit POST as a normalized envelope', async () => { + it('forwards a raw Claude JSON POST with base64 metadata headers', async () => { const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() const server = new RelayAgentHookServer({ endpointDir: dir, forward }) await server.start() @@ -52,16 +53,13 @@ describe('RelayAgentHookServer', () => { method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': token + 'X-Orca-Agent-Hook-Token': token, + 'X-Orca-Agent-Hook-Meta-Encoding': 'base64', + 'X-Orca-Agent-Hook-Meta': Buffer.from( + [PANE_KEY, 'tab-1', '', 'wt-1', 'remote', '1'].join('\x1f') + ).toString('base64') }, - body: JSON.stringify({ - paneKey: PANE_KEY, - tabId: 'tab-1', - worktreeId: 'wt-1', - env: 'remote', - version: '1', - payload: { hook_event_name: 'UserPromptSubmit', prompt: 'hi' } - }) + body: JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt: 'hi' }) }) expect(res.status).toBe(204) expect(forward).toHaveBeenCalledTimes(1) @@ -330,6 +328,27 @@ describe('RelayAgentHookServer', () => { } }) + it('rejects unknown hook paths before parsing the request body', async () => { + const forward = vi.fn() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const { port, token } = server.getCoordinates() + const res = await fetch(`http://127.0.0.1:${port}/hook/unknown`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ value: 'x'.repeat(HOOK_REQUEST_MAX_BYTES + 1) }) + }) + expect(res.status).toBe(404) + expect(forward).not.toHaveBeenCalled() + } finally { + server.stop() + } + }) + it('replays cached payloads on demand', async () => { const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() const server = new RelayAgentHookServer({ endpointDir: dir, forward }) @@ -492,6 +511,7 @@ describe('RelayAgentHookServer', () => { expect(env.ORCA_AGENT_HOOK_TOKEN).toBeTruthy() expect(env.ORCA_AGENT_HOOK_ENV).toBe('remote') expect(env.ORCA_AGENT_HOOK_VERSION).toBe('1') + expect(env.ORCA_AGENT_HOOK_TRANSPORT).toBe('raw-json-v1') expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeTruthy() } finally { server.stop() diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index ec980470dd4..59c1cbd8e00 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -2,7 +2,10 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:ht import { randomUUID } from 'node:crypto' import { join } from 'node:path' -import { ORCA_HOOK_PROTOCOL_VERSION } from '../shared/agent-hook-types' +import { + ORCA_HOOK_PROTOCOL_VERSION, + ORCA_HOOK_RAW_JSON_TRANSPORT +} from '../shared/agent-hook-types' import { clearAllListenerCaches, clearPaneCacheState, @@ -15,6 +18,7 @@ import { } from '../shared/agent-hook-listener/endpoint-publication' import { HOOK_REQUEST_SLOWLORIS_MS } from '../shared/agent-hook-listener/listener-limits' import { normalizeHookPayload } from '../shared/agent-hook-listener' +import { mergeAgentHookRequestHeaders } from '../shared/agent-hook-listener/hook-envelope' import { readRequestBody } from '../shared/agent-hook-listener/request-body' import { resolveHookSource } from '../shared/agent-hook-listener/source-routing' import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' @@ -174,7 +178,8 @@ export class RelayAgentHookServer { port: this.port, token: this.token, env: this.env, - version: ORCA_HOOK_PROTOCOL_VERSION + version: ORCA_HOOK_PROTOCOL_VERSION, + transport: ORCA_HOOK_RAW_JSON_TRANSPORT }) return this.endpointFileWritten } @@ -252,7 +257,6 @@ export class RelayAgentHookServer { req.destroy() }) try { - const body = await readRequestBody(req) const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname const source = resolveHookSource(pathname) if (!source) { @@ -260,16 +264,18 @@ export class RelayAgentHookServer { res.end() return } - const event = normalizeHookPayload(this.state, source, body, this.env, { + const body = await readRequestBody(req) + const hookBody = mergeAgentHookRequestHeaders(body, req.headers) + const event = normalizeHookPayload(this.state, source, hookBody, this.env, { deferCompactOwnershipToClient: true }) if (event) { // TODO: once normalizeHookPayload returns validated env/version, drop bodyEnv/bodyVersion and source them from the listener result. - const env = hookBodyEnv(body) - const version = hookBodyVersion(body) + const env = hookBodyEnv(hookBody) + const version = hookBodyVersion(hookBody) this.applyEvent(event, source, env, version) - this.retryScheduler.scheduleAssistantMessageRetry(source, body, event, env, version) - this.retryScheduler.scheduleCodexSubagentPoll(source, body, event, env, version) + this.retryScheduler.scheduleAssistantMessageRetry(source, hookBody, event, env, version) + this.retryScheduler.scheduleCodexSubagentPoll(source, hookBody, event, env, version) } res.writeHead(204) res.end() diff --git a/src/shared/agent-hook-listener-relay-dependency.test.ts b/src/shared/agent-hook-listener-relay-dependency.test.ts index e05ef801f7f..e23277e9f18 100644 --- a/src/shared/agent-hook-listener-relay-dependency.test.ts +++ b/src/shared/agent-hook-listener-relay-dependency.test.ts @@ -122,6 +122,7 @@ describe('agent hook listener relay dependency boundary', () => { 'agent-hook-listener.ts', 'agent-hook-listener/endpoint-publication.ts', 'agent-hook-listener/grok-result-discovery.ts', + 'agent-hook-listener/hook-envelope.ts', 'agent-hook-listener/listener-limits.ts', 'agent-hook-listener/listener-state.ts', 'agent-hook-listener/providers/codex-state.ts', diff --git a/src/shared/agent-hook-listener-transport.test.ts b/src/shared/agent-hook-listener-transport.test.ts index 344be39eca3..f0a86986414 100644 --- a/src/shared/agent-hook-listener-transport.test.ts +++ b/src/shared/agent-hook-listener-transport.test.ts @@ -14,7 +14,10 @@ import { parseFormEncodedBody, readRequestBody } from './agent-hook-listener/request-body' +import { mergeAgentHookRequestHeaders } from './agent-hook-listener/hook-envelope' +import { createHookListenerState } from './agent-hook-listener/listener-state' import { resolveHookSource } from './agent-hook-listener/source-routing' +import { normalizeHookPayload } from './agent-hook-listener' import { clearGrokSessionPathLookupCacheForTests } from './grok-session-paths' type FakeIncomingMessage = EventEmitter & { @@ -38,6 +41,10 @@ function expectRequestParserListenersReleased(req: FakeIncomingMessage): void { } describe('shared agent-hook-listener', () => { + const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111' + const b64 = (value: string): string => Buffer.from(value, 'utf8').toString('base64') + const packedMetadata = (...values: string[]): string => b64(values.join('\x1f')) + afterEach(() => { clearGrokSessionPathLookupCacheForTests() vi.unstubAllEnvs() @@ -60,6 +67,76 @@ describe('shared agent-hook-listener', () => { expectRequestParserListenersReleased(req) }) + it('normalizes a raw JSON hook body with metadata headers', async () => { + const req = createReadableRequest({ + 'content-type': 'application/json', + 'x-orca-pane-key': paneKey, + 'x-orca-worktree-id': 'repo::/tmp/work', + 'x-orca-agent-hook-env': 'production', + 'x-orca-agent-hook-version': '1' + }) + const body = readRequestBody(req as unknown as IncomingMessage) + req.emit('data', Buffer.from('{"hook_event_name":"UserPromptSubmit","prompt":"hello"}')) + req.emit('end') + + const merged = mergeAgentHookRequestHeaders(await body, req.headers) + expect( + normalizeHookPayload(createHookListenerState(), 'claude', merged, 'production') + ).toMatchObject({ + paneKey, + worktreeId: 'repo::/tmp/work', + payload: { state: 'working', prompt: 'hello' } + }) + }) + + it('decodes base64 metadata headers without corrupting path text', () => { + const worktreeId = 'repo::/tmp/中文 worktree' + const merged = mergeAgentHookRequestHeaders( + { hook_event_name: 'UserPromptSubmit', prompt: 'hello' }, + { + 'x-orca-agent-hook-meta-encoding': 'base64', + 'x-orca-agent-hook-meta': packedMetadata( + paneKey, + 'tab-1', + '', + worktreeId, + 'production', + '1' + ) + } + ) + + expect( + normalizeHookPayload(createHookListenerState(), 'claude', merged, 'production') + ).toMatchObject({ + paneKey, + tabId: 'tab-1', + worktreeId, + payload: { state: 'working', prompt: 'hello' } + }) + }) + + it('keeps raw envelope-like fields inside the agent payload', () => { + const rawBody = { + paneKey: 'payload-owned-pane', + payload: { hook_event_name: 'PayloadField' }, + hook_event_name: 'UserPromptSubmit', + prompt: 'hello' + } + const merged = mergeAgentHookRequestHeaders(rawBody, { + 'x-orca-pane-key': paneKey, + 'x-orca-tab-id': 'tab-1' + }) + + expect(merged).toMatchObject({ paneKey, tabId: 'tab-1', payload: rawBody }) + expect( + normalizeHookPayload(createHookListenerState(), 'claude', merged, 'production') + ).toMatchObject({ + paneKey, + payload: { state: 'working', prompt: 'hello' } + }) + }) + it('releases request parser listeners after rejecting an oversized body', async () => { const req = createReadableRequest({ 'content-type': 'application/json' }) const body = readRequestBody(req as unknown as IncomingMessage) @@ -168,13 +245,15 @@ describe('shared agent-hook-listener', () => { port: 12345, token: 'abcdef-0123', env: 'production', - version: '1' + version: '1', + transport: 'raw-json-v1' }) expect(ok).toBe(true) const text = readFileSync(finalPath, 'utf8') expect(text).toContain('ORCA_AGENT_HOOK_PORT=12345') expect(text).toContain('ORCA_AGENT_HOOK_TOKEN=abcdef-0123') expect(text).toContain('ORCA_AGENT_HOOK_VERSION=1') + expect(text).toContain('ORCA_AGENT_HOOK_TRANSPORT=raw-json-v1') // POSIX 0o600 — owner read/write only. if (process.platform !== 'win32') { const mode = statSync(finalPath).mode & 0o777 diff --git a/src/shared/agent-hook-listener/endpoint-publication.ts b/src/shared/agent-hook-listener/endpoint-publication.ts index cb09f204a01..76964c00254 100644 --- a/src/shared/agent-hook-listener/endpoint-publication.ts +++ b/src/shared/agent-hook-listener/endpoint-publication.ts @@ -21,6 +21,7 @@ export type EndpointFileFields = { token: string env: string version: string + transport?: string } /** Atomically write the endpoint file at `endpointDir/`. @@ -39,6 +40,9 @@ export function writeEndpointFile( ['ORCA_AGENT_HOOK_ENV', fields.env], ['ORCA_AGENT_HOOK_VERSION', fields.version] ] + if (fields.transport) { + valuesToWrite.push(['ORCA_AGENT_HOOK_TRANSPORT', fields.transport]) + } for (const [key, value] of valuesToWrite) { if (!isShellSafeEndpointValue(value)) { console.error( diff --git a/src/shared/agent-hook-listener/hook-envelope.ts b/src/shared/agent-hook-listener/hook-envelope.ts index 5435f9fe912..57c29da338f 100644 --- a/src/shared/agent-hook-listener/hook-envelope.ts +++ b/src/shared/agent-hook-listener/hook-envelope.ts @@ -1,8 +1,91 @@ +import { Buffer } from 'node:buffer' +import type { IncomingHttpHeaders } from 'node:http' + import type { AgentHookSource } from '../agent-hook-relay' import { parsePaneKey } from '../stable-pane-id' import { MAX_PANE_KEY_LEN, warnOnHookEnvOrVersionMismatch } from './listener-limits' import type { HookListenerState } from './listener-state' import { parseAgentHookJson } from './request-body' + +function readHookHeader(headers: IncomingHttpHeaders, name: string): string | undefined { + const value = headers[name] + return Array.isArray(value) ? value[0] : value +} + +function decodeBase64HookHeader(value: string): string | undefined { + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 === 1) { + return undefined + } + const decoded = Buffer.from(value, 'base64').toString('utf8') + const normalizedInput = value.replace(/=+$/, '') + const normalizedRoundTrip = Buffer.from(decoded, 'utf8').toString('base64').replace(/=+$/, '') + return normalizedRoundTrip === normalizedInput ? decoded : undefined +} + +function readHookMetadataHeader( + headers: IncomingHttpHeaders, + name: string, + encoding: 'base64' | undefined +): string | undefined { + const value = readHookHeader(headers, name) + if (value === undefined) { + return undefined + } + return encoding === 'base64' ? decodeBase64HookHeader(value) : value +} + +type HookMetadata = { + paneKey: string + tabId?: string + launchToken?: string + worktreeId?: string + env?: string + version?: string +} + +function readPackedHookMetadata( + headers: IncomingHttpHeaders, + encoding: 'base64' | undefined +): HookMetadata | null { + const encoded = readHookHeader(headers, 'x-orca-agent-hook-meta') + if (encoded === undefined || encoding !== 'base64') { + return null + } + const decoded = decodeBase64HookHeader(encoded) + if (decoded === undefined) { + return null + } + // POSIX command substitution strips NUL bytes, so use the shell-safe unit separator. + const fields = decoded.split('\x1f') + if (fields.length !== 6 || !fields[0]) { + return null + } + const [paneKey, tabId, launchToken, worktreeId, env, version] = fields + return { paneKey, tabId, launchToken, worktreeId, env, version } +} + +/** Rebuilds the canonical envelope for POSIX hooks that carry raw JSON bodies. */ +export function mergeAgentHookRequestHeaders(body: unknown, headers: IncomingHttpHeaders): unknown { + const metadataEncoding = + readHookHeader(headers, 'x-orca-agent-hook-meta-encoding')?.trim().toLowerCase() === 'base64' + ? 'base64' + : undefined + const metadata = readPackedHookMetadata(headers, metadataEncoding) ?? { + paneKey: readHookMetadataHeader(headers, 'x-orca-pane-key', metadataEncoding) ?? '', + tabId: readHookMetadataHeader(headers, 'x-orca-tab-id', metadataEncoding), + launchToken: readHookMetadataHeader(headers, 'x-orca-launch-token', metadataEncoding), + worktreeId: readHookMetadataHeader(headers, 'x-orca-worktree-id', metadataEncoding), + env: readHookMetadataHeader(headers, 'x-orca-agent-hook-env', metadataEncoding), + version: readHookMetadataHeader(headers, 'x-orca-agent-hook-version', metadataEncoding) + } + if (!metadata.paneKey) { + return body + } + return { + ...metadata, + payload: body + } +} function readEnvelopeString(record: Record, key: string): string | undefined { const value = record[key] if (typeof value !== 'string') { diff --git a/src/shared/agent-hook-types.ts b/src/shared/agent-hook-types.ts index 5319dffceaf..248638c5079 100644 --- a/src/shared/agent-hook-types.ts +++ b/src/shared/agent-hook-types.ts @@ -49,3 +49,6 @@ export type AgentHookInstallStatus = { // rewritten on every install() call so there is no durable on-disk v1 script // to inherit. Reserve the next bump for a real wire change. export const ORCA_HOOK_PROTOCOL_VERSION = '1' as const + +// Why: absence means the listener predates raw-JSON metadata headers, so managed scripts must keep using form posts. +export const ORCA_HOOK_RAW_JSON_TRANSPORT = 'raw-json-v1' as const From 9635e6822f491ed66847e01f155edf7d7052da61 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:35:03 -0700 Subject: [PATCH 061/152] Normalize project catalog rows defensively (#16826) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip: normalize untrusted project catalog rows at load boundaries * fix(catalog): make ProjectHostSetup field types true at the ingest boundary Crash 3bcc5be3: a setup row whose repoId arrived null reached Settings' projectByRepoId memo and threw on .trim(). The type said `string`; persisted JSON and remote hosts on other versions can disagree. Normalize project/setup rows where untrusted data enters typed code — the persisted-state load (marking dirty, which is the migration), profile transfer reads, the repo-derived projection, and the renderer's IPC/RPC ingest and adoption steps — instead of re-guarding each consumer. Also covers `setup.path`, whose identical crash is on the sidebar render path. Coercion only: never drops a row, never adds or removes an optional key, and returns input references when a row already conforms, so selector and useMemo identity is unchanged. * refactor(catalog): drop the `as` casts the normalizer introduced A change whose thesis is "stop the declared types from lying" should not use `as` to paper over types. The four source casts all came from the row normalizers returning `readonly` arrays into mutably-owned fields. Take and return mutable arrays instead, and copy at the one caller that holds a readonly projection — where identity is not load-bearing, unlike the persistence path, whose dirty check compares it. Tests built deliberately malformed rows by casting a literal. Build a valid row and `Reflect.set` the bad value onto it, which says outright that the fixture violates its type; parse the non-array case from JSON, which is how it actually arrives. Fixtures that were merely incomplete needed no cast at all — `Repo` requires only the five fields they already had. Also narrow normalizeLoadedProjectCatalog to the two fields it reads. * refactor(profiles): validate untrusted profile JSON instead of asserting it Removes the last introduced cast and the unsound pre-existing ones in the file this change already touches. The test cast is gone because narrowing normalizeLoadedProjectCatalog to the two fields it reads made `{}` assignable on its own. arrayOrEmpty and recordOrEmpty checked the shape and then asserted the element type, which is the same "declared type is a promise the data does not keep" problem this change exists to fix. Array.isArray already narrows on its own, and a generic isRecord narrows the value part, so both assertions delete outright. JSON.parse returns any, so annotating the binding beats asserting its result. The two remaining `as const` in project-host-setup-actions are untouched and deliberate: a literal assertion narrows a type rather than overriding it. * Make project catalog normalizers handle null rows defensively Instead of crashing when corrupt or null catalog rows are encountered, the normalizers now gracefully repair them with default values. Refactored helper functions for clarity and added type predicates to improve type narrowing. --- .../profile-project-state-file.ts | 31 +-- .../normalize-loaded-profile-state.ts | 7 +- .../normalize-loaded-project-catalog.test.ts | 67 ++++++ .../normalize-loaded-state-collections.ts | 22 ++ .../project-catalog-null-field-ingest.test.ts | 167 ++++++++++++++ .../store/projects/project-host-routing.ts | 31 ++- .../projects/project-host-setup-actions.ts | 28 ++- .../src/store/projects/project-update.ts | 5 +- .../project-catalog-row-normalization.test.ts | 207 ++++++++++++++++++ .../project-catalog-row-normalization.ts | 123 +++++++++++ src/shared/project-host-setup-projection.ts | 10 +- 11 files changed, 663 insertions(+), 35 deletions(-) create mode 100644 src/main/persistence/loading-store/normalize-loaded-project-catalog.test.ts create mode 100644 src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts create mode 100644 src/shared/project-catalog-row-normalization.test.ts create mode 100644 src/shared/project-catalog-row-normalization.ts diff --git a/src/main/orca-profiles/profile-project-state-file.ts b/src/main/orca-profiles/profile-project-state-file.ts index 5c623f19bad..d4f291a09e4 100644 --- a/src/main/orca-profiles/profile-project-state-file.ts +++ b/src/main/orca-profiles/profile-project-state-file.ts @@ -3,8 +3,11 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from ' import { homedir } from 'node:os' import { dirname } from 'node:path' import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../shared/constants' -import type { ExecutionHostId } from '../../shared/execution-host' import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection' +import { + normalizeProjectHostSetupRows, + normalizeProjectRows +} from '../../shared/project-catalog-row-normalization' import { carryProjectStateThroughIdentityChange } from '../../shared/project-identity-succession' import type { PersistedState } from '../../shared/persisted-state-types' import type { Project, ProjectHostSetup } from '../../shared/project-types' @@ -16,16 +19,16 @@ import { getOrcaProfileDataFile } from './profile-index-store' export type TransferProfileState = PersistedState -function isRecord(value: unknown): value is Record { +function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } function arrayOrEmpty(value: unknown): T[] { - return Array.isArray(value) ? (value as T[]) : [] + return Array.isArray(value) ? value : [] } function recordOrEmpty(value: unknown): Record { - return isRecord(value) ? (value as Record) : {} + return isRecord(value) ? value : {} } export function readProfileState(profileId: string, userDataPath: string): TransferProfileState { @@ -34,13 +37,17 @@ export function readProfileState(profileId: string, userDataPath: string): Trans if (!existsSync(dataFile)) { return structuredClone(defaults) } - const parsed = JSON.parse(readFileSync(dataFile, 'utf-8')) as Partial + const parsed: Partial = JSON.parse(readFileSync(dataFile, 'utf-8')) return rebuildRepoBackedProjectState({ ...defaults, ...parsed, repos: arrayOrEmpty(parsed.repos), - projects: arrayOrEmpty(parsed.projects), - projectHostSetups: arrayOrEmpty(parsed.projectHostSetups), + // Why normalize: another profile's file is untrusted JSON, and a null repoId/path here would be + // carried straight into the importing app's state. + projects: normalizeProjectRows(arrayOrEmpty(parsed.projects)), + projectHostSetups: normalizeProjectHostSetupRows( + arrayOrEmpty(parsed.projectHostSetups) + ), projectGroups: arrayOrEmpty(parsed.projectGroups), folderWorkspaces: arrayOrEmpty(parsed.folderWorkspaces), sparsePresetsByRepo: recordOrEmpty(parsed.sparsePresetsByRepo), @@ -59,17 +66,15 @@ export function readProfileState(profileId: string, userDataPath: string): Trans ui: isRecord(parsed.ui) ? { ...defaults.ui, ...parsed.ui } : defaults.ui, githubCache: isRecord(parsed.githubCache) ? { - pr: recordOrEmpty((parsed.githubCache as PersistedState['githubCache']).pr), - issue: recordOrEmpty((parsed.githubCache as PersistedState['githubCache']).issue) + pr: recordOrEmpty(parsed.githubCache.pr), + issue: recordOrEmpty(parsed.githubCache.issue) } : defaults.githubCache, workspaceSession: isRecord(parsed.workspaceSession) ? { ...getDefaultWorkspaceSession(), ...parsed.workspaceSession } : defaults.workspaceSession, - workspaceSessionsByHostId: isRecord(parsed.workspaceSessionsByHostId) - ? (parsed.workspaceSessionsByHostId as Partial< - Record - >) + workspaceSessionsByHostId: isRecord(parsed.workspaceSessionsByHostId) + ? parsed.workspaceSessionsByHostId : {}, sshTargets: arrayOrEmpty(parsed.sshTargets), sshRemotePtyLeases: arrayOrEmpty(parsed.sshRemotePtyLeases), diff --git a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts index 464a1eaa9b8..2286e40d54b 100644 --- a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts +++ b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts @@ -21,7 +21,8 @@ import { normalizeLoadedUiState } from './normalize-loaded-ui-state' import { normalizeLoadedAutomationRuns, normalizeLoadedHostSessions, - normalizeLoadedLocalSession + normalizeLoadedLocalSession, + normalizeLoadedProjectCatalog } from './normalize-loaded-state-collections' import { normalizeRetiredNameRegistryMap } from './retired-name-registry-normalization' @@ -33,6 +34,7 @@ export function normalizeLoadedProfileState( ): PersistedState { const { defaults, migratedExternalVisibility, osc52ClipboardNoticePending } = terminal const { normalizedOnboarding, normalizedProjectGroups, loadedCompactWorktreeCards } = profile + const projectCatalog = normalizeLoadedProjectCatalog(parsed, markNeedsSave) return { ...defaults, @@ -42,6 +44,9 @@ export function normalizeLoadedProfileState( ), projectGroups: normalizedProjectGroups, repos: migratedExternalVisibility.repos, + // Why: persisted catalog rows are untrusted JSON; consumers call string methods on fields the type says are strings. + projects: projectCatalog.projects, + projectHostSetups: projectCatalog.projectHostSetups, folderWorkspaces: normalizeFolderWorkspaces(parsed.folderWorkspaces, normalizedProjectGroups), folderWorkspaceDiffComments: normalizeFolderWorkspaceDiffComments( parsed.folderWorkspaceDiffComments diff --git a/src/main/persistence/loading-store/normalize-loaded-project-catalog.test.ts b/src/main/persistence/loading-store/normalize-loaded-project-catalog.test.ts new file mode 100644 index 00000000000..8a535b41919 --- /dev/null +++ b/src/main/persistence/loading-store/normalize-loaded-project-catalog.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { Project, ProjectHostSetup } from '../../../shared/project-types' +import { normalizeLoadedProjectCatalog } from './normalize-loaded-state-collections' + +// Why Reflect.set and not a cast: these rows deliberately violate their declared types, which is +// exactly what a stale on-disk catalog does. +function corrupt(row: T, overrides: Record): T { + for (const [key, value] of Object.entries(overrides)) { + Reflect.set(row, key, value) + } + return row +} + +function makeParsed( + setups: ProjectHostSetup[], + projects: Project[] = [] +): Partial> { + return { projects, projectHostSetups: setups } +} + +function makeBadSetup(): ProjectHostSetup { + const setup: ProjectHostSetup = { + id: 'setup-1', + projectId: 'project-1', + hostId: 'local', + repoId: 'repo-1', + path: '/Users/alice/orca', + displayName: 'orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + return corrupt(setup, { repoId: null, path: null }) +} + +describe('normalizeLoadedProjectCatalog', () => { + it('repairs stored rows whose field types do not match the declared ones', () => { + const result = normalizeLoadedProjectCatalog(makeParsed([makeBadSetup()]), vi.fn()) + expect(result.projectHostSetups[0]?.repoId).toBe('') + expect(result.projectHostSetups[0]?.path).toBe('') + }) + + // Why: without a save the bad rows stay on disk, get repaired again every launch, and this + // host keeps publishing them to paired clients. Marking dirty is the migration. + it('marks the profile dirty so the repair is persisted', () => { + const markNeedsSave = vi.fn() + normalizeLoadedProjectCatalog(makeParsed([makeBadSetup()]), markNeedsSave) + expect(markNeedsSave).toHaveBeenCalled() + }) + + it('leaves a conforming catalog untouched and does not schedule a save', () => { + const markNeedsSave = vi.fn() + const setups = [{ ...makeBadSetup(), repoId: 'repo-1', path: '/repo' }] + const parsed = makeParsed(setups) + const result = normalizeLoadedProjectCatalog(parsed, markNeedsSave) + expect(result.projectHostSetups).toBe(setups) + expect(markNeedsSave).not.toHaveBeenCalled() + }) + + it('tolerates missing or non-array collections', () => { + const result = normalizeLoadedProjectCatalog({}, vi.fn()) + expect(result.projects).toEqual([]) + expect(result.projectHostSetups).toEqual([]) + }) +}) diff --git a/src/main/persistence/loading-store/normalize-loaded-state-collections.ts b/src/main/persistence/loading-store/normalize-loaded-state-collections.ts index 978515a5752..2b133d486b7 100644 --- a/src/main/persistence/loading-store/normalize-loaded-state-collections.ts +++ b/src/main/persistence/loading-store/normalize-loaded-state-collections.ts @@ -1,4 +1,8 @@ import type { PersistedState } from '../../../shared/persisted-state-types' +import { + normalizeProjectHostSetupRows, + normalizeProjectRows +} from '../../../shared/project-catalog-row-normalization' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { parseWorkspaceSessionSalvaging } from '../../../shared/workspace-session-salvage' import { @@ -64,3 +68,21 @@ export function normalizeLoadedAutomationRuns( } return runs } + +/** + * Repairs project/setup rows whose stored field types do not match the declared ones — a null + * `repoId` or `path` written by an older build reaches every consumer that calls `.trim()` on it. + * Marking dirty is the migration: without a save the bad rows stay on disk and are repaired again + * every launch, and this host keeps publishing them to paired clients over the wire. + */ +export function normalizeLoadedProjectCatalog( + parsed: Partial>, + markNeedsSave: () => void +): Pick { + const projects = normalizeProjectRows(parsed.projects ?? []) + const projectHostSetups = normalizeProjectHostSetupRows(parsed.projectHostSetups ?? []) + if (projects !== parsed.projects || projectHostSetups !== parsed.projectHostSetups) { + markNeedsSave() + } + return { projects, projectHostSetups } +} diff --git a/src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts b/src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts new file mode 100644 index 00000000000..1d57bde1b87 --- /dev/null +++ b/src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Project, ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import { getProjectHostSetupProjectionFromState } from '../project-host-setup-selector' +import { fetchProjectHostSetupCompatibility, setupWithFetchedOwner } from './project-host-routing' + +// Crash 3bcc5be3 (v1.4.188, Linux, page.settings boundary): a setup row whose repoId arrived +// null reached Settings' projectByRepoId useMemo and threw "Cannot read properties of null +// (reading 'trim')". Normalizing at ingest is what must make that unreachable — by the time a +// row is in the store, its declared field types hold. +const repos = [ + { id: 'repo-1', path: '/Users/alice/orca', displayName: 'orca', badgeColor: '#000', addedAt: 1 } +] satisfies Repo[] + +const projects = [ + { + id: 'repo:repo-1', + displayName: 'orca', + badgeColor: '#000', + sourceRepoIds: ['repo-1'], + createdAt: 1, + updatedAt: 1 + } +] satisfies Project[] + +// Mirrors the Settings.tsx projectByRepoId useMemo that crashed. +function buildProjectByRepoIdLikeSettings( + setups: readonly ProjectHostSetup[], + projectList: readonly Project[] +): Map { + const projectById = new Map(projectList.map((project) => [project.id, project])) + const byRepoId = new Map() + for (const setup of setups) { + const project = projectById.get(setup.projectId) + if (project && setup.repoId.trim()) { + byRepoId.set(setup.repoId, project) + } + } + return byRepoId +} + +// Mirrors project-grouping.ts's checkout identity, which runs on the sidebar render path. +function checkoutIdentityLikeSidebar(setup: ProjectHostSetup): string { + return setup.path.trim() || setup.repoId || setup.id +} + +// Why Reflect.set and not a cast: the second row deliberately violates its declared types, which +// is exactly what an older host publishes over the wire. +function badSetups(): ProjectHostSetup[] { + const base = (id: string, displayName: string): ProjectHostSetup => ({ + id, + projectId: 'repo:repo-1', + hostId: 'local', + repoId: 'repo-1', + path: '/Users/alice/orca', + displayName, + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + }) + const corrupted = base('repo:repo-1::local::2', 'orca-2') + Reflect.set(corrupted, 'repoId', null) + Reflect.set(corrupted, 'path', null) + return [base('repo:repo-1::local', 'orca'), corrupted] +} + +function stubProjectsApi(setups: ProjectHostSetup[], projectRows: Project[] = projects): void { + vi.stubGlobal('window', { + api: { + projects: { + list: vi.fn().mockResolvedValue(projectRows), + listHostSetups: vi.fn().mockResolvedValue(setups) + } + } + }) +} + +describe('project catalog ingest with non-string row fields', () => { + it('coerces a null repoId and path on the local IPC boundary', async () => { + stubProjectsApi(badSetups()) + const projection = await fetchProjectHostSetupCompatibility({ kind: 'local' }, repos) + const row = projection.setups.find((setup) => setup.id === 'repo:repo-1::local::2') + expect(row?.repoId).toBe('') + expect(row?.path).toBe('') + }) + + // Why: a remote host on a different Orca version is a first-class source of these rows, and + // decoders hand them over verbatim — the client cannot assume the host already repaired them. + it('coerces on the remote adoption boundary too', () => { + const adopted = setupWithFetchedOwner(badSetups()[1]!, { + kind: 'environment', + environmentId: 'env-1' + }) + expect(adopted.repoId).toBe('') + expect(adopted.path).toBe('') + expect(adopted.hostId).toBe('runtime:env-1') + }) + + it('lets the Settings projectByRepoId memo run instead of throwing on .trim()', async () => { + stubProjectsApi(badSetups()) + const ingested = await fetchProjectHostSetupCompatibility({ kind: 'local' }, repos) + const projection = getProjectHostSetupProjectionFromState({ + repos, + projects: [...ingested.projects], + projectHostSetups: [...ingested.setups] + }) + expect(() => + buildProjectByRepoIdLikeSettings(projection.setups, projection.projects) + ).not.toThrow() + // Why: an empty repoId is not an openable repo row, so it must not be indexed. + const byRepoId = buildProjectByRepoIdLikeSettings(projection.setups, projection.projects) + expect(byRepoId.has('')).toBe(false) + expect([...byRepoId.keys()]).toEqual(['repo-1']) + }) + + // Why: a null path takes down the whole main window rather than one error boundary, because + // the sidebar grouping index is built on every render. + it('lets the sidebar checkout identity run instead of throwing on a null path', async () => { + stubProjectsApi(badSetups()) + const ingested = await fetchProjectHostSetupCompatibility({ kind: 'local' }, repos) + expect(() => ingested.setups.map(checkoutIdentityLikeSidebar)).not.toThrow() + }) + + // Why: a coerced field must change one value, never which rows exist. + it('does not add or drop rows because a field was repaired', async () => { + stubProjectsApi(badSetups()) + const repaired = await fetchProjectHostSetupCompatibility({ kind: 'local' }, repos) + const clean = badSetups() + ;(clean[1] as { repoId: string; path: string }).repoId = '' + ;(clean[1] as { repoId: string; path: string }).path = '' + stubProjectsApi(clean) + const untouched = await fetchProjectHostSetupCompatibility({ kind: 'local' }, repos) + expect(repaired.setups.map((setup) => setup.id)).toEqual( + untouched.setups.map((setup) => setup.id) + ) + expect(repaired.projects.map((project) => project.id)).toEqual( + untouched.projects.map((project) => project.id) + ) + }) + + // Why: this is a zustand selector compared with Object.is, so instability is a re-render storm. + it('keeps the projection reference-stable per (repos, projects, setups) input', async () => { + stubProjectsApi(badSetups()) + const ingested = await fetchProjectHostSetupCompatibility({ kind: 'local' }, repos) + const args = { + repos, + projects: [...ingested.projects], + projectHostSetups: [...ingested.setups] + } + expect(getProjectHostSetupProjectionFromState(args)).toBe( + getProjectHostSetupProjectionFromState(args) + ) + }) + + // Why: nothing else in the ingest path allocates, so a clean catalog must hand back the very + // rows it was given — TerminalPane and TabBarQuickCommandsButton use them as useMemo deps. + it('preserves row identity when the catalog is already clean', async () => { + const clean = badSetups() + ;(clean[1] as { repoId: string; path: string }).repoId = '' + ;(clean[1] as { repoId: string; path: string }).path = '' + stubProjectsApi(clean) + const projection = await fetchProjectHostSetupCompatibility({ kind: 'local' }, repos) + expect(projection.setups[0]).toBe(clean[0]) + expect(projection.setups[1]).toBe(clean[1]) + }) +}) diff --git a/src/renderer/src/store/projects/project-host-routing.ts b/src/renderer/src/store/projects/project-host-routing.ts index 43e32524ad3..1a4a45ea03a 100644 --- a/src/renderer/src/store/projects/project-host-routing.ts +++ b/src/renderer/src/store/projects/project-host-routing.ts @@ -9,6 +9,11 @@ import { projectHostSetupProjectionFromRepos, type ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection' +import { + normalizeProjectHostSetupRow, + normalizeProjectHostSetupRows, + normalizeProjectRows +} from '../../../../shared/project-catalog-row-normalization' import { PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY @@ -51,13 +56,16 @@ export function setupWithFetchedOwner( setup: ProjectHostSetup, target: RuntimeClientTarget ): ProjectHostSetup { + // Why here: every setup row crossing IPC or RPC into the renderer passes through this + // adoption step, so it is where the declared field types stop being aspirational. + const adopted = normalizeProjectHostSetupRow(setup) const hostId = getRuntimeTargetHostId(target) if (target.kind !== 'environment') { - return setup + return adopted } - const executionHostId = setup.executionHostId ?? setup.hostId + const executionHostId = adopted.executionHostId ?? adopted.hostId return { - ...setup, + ...adopted, hostId, executionHostId: executionHostId === LOCAL_EXECUTION_HOST_ID ? hostId : executionHostId, runtimeOwnerEnvironmentId: target.environmentId, @@ -66,6 +74,15 @@ export function setupWithFetchedOwner( } } +function normalizeProjectCatalogProjection( + projection: ProjectHostSetupProjection +): ProjectHostSetupProjection { + return { + projects: normalizeProjectRows([...projection.projects]), + setups: normalizeProjectHostSetupRows([...projection.setups]) + } +} + async function assertProjectHostSetupRuntimeCapability(target: RuntimeClientTarget): Promise { if (target.kind !== 'environment') { return @@ -95,10 +112,10 @@ export async function fetchProjectHostSetupCompatibility( if (!projectsApi?.list || !projectsApi.listHostSetups) { throw new Error('projects_api_unavailable') } - return { + return normalizeProjectCatalogProjection({ projects: await projectsApi.list(), setups: await projectsApi.listHostSetups() - } + }) } await assertProjectHostSetupRuntimeCapability(target) const [projectResponse, setupResponse] = await Promise.all([ @@ -110,7 +127,9 @@ export async function fetchProjectHostSetupCompatibility( }) ]) return { - projects: projectResponse.projects, + // Why projects too: the same wire response carries them, and a remote host on another + // Orca version can publish a row whose declared field types do not hold. + projects: normalizeProjectRows([...projectResponse.projects]), setups: setupResponse.setups.map((setup) => setupWithFetchedOwner(setup, target)) } } catch { diff --git a/src/renderer/src/store/projects/project-host-setup-actions.ts b/src/renderer/src/store/projects/project-host-setup-actions.ts index 97e74dbc9b8..dbc106a6afa 100644 --- a/src/renderer/src/store/projects/project-host-setup-actions.ts +++ b/src/renderer/src/store/projects/project-host-setup-actions.ts @@ -16,6 +16,7 @@ import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared import type { RepoSlice } from '../repos/repo-state' import { ERROR_TOAST_DURATION } from '../repos/repo-state' import { repoWithFetchedOwner } from '../repos/owner-routing' +import { normalizeProjectRow } from '../../../../shared/project-catalog-row-normalization' import { assertProjectHostSetupMutationRuntimeCapabilities, getProjectSetupRuntimeTarget, @@ -57,6 +58,7 @@ export function createProjectHostSetupActions( const repo = repoWithFetchedOwner(result.repo, target) const repoHostId = getRepoExecutionHostId(repo) const setup = setupWithFetchedOwner(result.setup, target) + const project = normalizeProjectRow(result.project) set((s) => { const nextRepos = s.repos.some((entry) => repoMatchesHostIdentity(entry, repo.id, repoHostId) @@ -65,9 +67,9 @@ export function createProjectHostSetupActions( repoMatchesHostIdentity(entry, repo.id, repoHostId) ? repo : entry ) : [...s.repos, repo] - const nextProjects = s.projects.some((entry) => entry.id === result.project.id) - ? s.projects.map((entry) => (entry.id === result.project.id ? result.project : entry)) - : [...s.projects, result.project] + const nextProjects = s.projects.some((entry) => entry.id === project.id) + ? s.projects.map((entry) => (entry.id === project.id ? project : entry)) + : [...s.projects, project] const nextSetups = s.projectHostSetups.some((entry) => entry.id === setup.id) ? s.projectHostSetups.map((entry) => (entry.id === setup.id ? setup : entry)) : [...s.projectHostSetups, setup] @@ -80,7 +82,7 @@ export function createProjectHostSetupActions( toast.success(translate('auto.store.slices.repos.8bb3ad7935', 'Project added'), { description: repo.displayName }) - return { ...result, repo, setup } + return { ...result, project, repo, setup } } catch (err) { console.error('Failed to set up project on host:', err) const message = err instanceof Error ? err.message : String(err) @@ -108,15 +110,16 @@ export function createProjectHostSetupActions( ) ).result const setup = setupWithFetchedOwner(result.setup, target) + const project = normalizeProjectRow(result.project) set((s) => ({ - projects: s.projects.some((entry) => entry.id === result.project.id) - ? s.projects.map((entry) => (entry.id === result.project.id ? result.project : entry)) - : [...s.projects, result.project], + projects: s.projects.some((entry) => entry.id === project.id) + ? s.projects.map((entry) => (entry.id === project.id ? project : entry)) + : [...s.projects, project], projectHostSetups: s.projectHostSetups.some((entry) => entry.id === setup.id) ? s.projectHostSetups.map((entry) => (entry.id === setup.id ? setup : entry)) : [...s.projectHostSetups, setup] })) - return { project: result.project, setup } + return { project, setup } } catch (err) { console.error('Failed to create project host setup:', err) const message = err instanceof Error ? err.message : String(err) @@ -147,6 +150,7 @@ export function createProjectHostSetupActions( ) ).result const setup = setupWithFetchedOwner(result.setup, target) + const project = normalizeProjectRow(result.project) const repo = result.repo ? repoWithFetchedOwner(result.repo, target) : undefined const repoHostId = repo ? getRepoExecutionHostId(repo) : null set((s) => ({ @@ -157,14 +161,14 @@ export function createProjectHostSetupActions( ) : [...s.repos, repo] : s.repos, - projects: s.projects.some((entry) => entry.id === result.project.id) - ? s.projects.map((entry) => (entry.id === result.project.id ? result.project : entry)) - : [...s.projects, result.project], + projects: s.projects.some((entry) => entry.id === project.id) + ? s.projects.map((entry) => (entry.id === project.id ? project : entry)) + : [...s.projects, project], projectHostSetups: s.projectHostSetups.some((entry) => entry.id === setup.id) ? s.projectHostSetups.map((entry) => (entry.id === setup.id ? setup : entry)) : [...s.projectHostSetups, setup] })) - return { ...result, repo, setup } + return { ...result, project, repo, setup } } catch (err) { console.error('Failed to update project host setup:', err) const message = err instanceof Error ? err.message : String(err) diff --git a/src/renderer/src/store/projects/project-update.ts b/src/renderer/src/store/projects/project-update.ts index 7b3ece6fd62..75c65f24ee3 100644 --- a/src/renderer/src/store/projects/project-update.ts +++ b/src/renderer/src/store/projects/project-update.ts @@ -6,6 +6,7 @@ import { notifyInstalledAgentSkillsChanged } from '@/hooks/installed-agent-skill import type { RepoSlice } from '../repos/repo-state' import { getProjectUpdateRuntimeTarget } from './project-host-routing' import { mergeUpdatedProjectCompatibilityProject } from './project-compatibility-core' +import { normalizeProjectRow } from '../../../../shared/project-catalog-row-normalization' export function createProjectUpdateActions( set: Parameters>[0], @@ -29,11 +30,13 @@ export function createProjectUpdateActions( if (!updatedProject) { return false } + // Why: the merge spreads updatedProject.sourceRepoIds, which throws if the host sent a non-array. + const normalizedProject = normalizeProjectRow(updatedProject) const runtimePreferenceChanged = 'localWindowsRuntimePreference' in updates set((state) => ({ projects: state.projects.map((project) => project.id === projectId - ? mergeUpdatedProjectCompatibilityProject(project, updatedProject, updates) + ? mergeUpdatedProjectCompatibilityProject(project, normalizedProject, updates) : project ), folderWorkspacePathStatuses: {} diff --git a/src/shared/project-catalog-row-normalization.test.ts b/src/shared/project-catalog-row-normalization.test.ts new file mode 100644 index 00000000000..acab501f41e --- /dev/null +++ b/src/shared/project-catalog-row-normalization.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from 'vitest' +import type { Project, ProjectHostSetup } from './project-types' +import { + normalizeProjectHostSetupRow, + normalizeProjectHostSetupRows, + normalizeProjectRow, + normalizeProjectRows +} from './project-catalog-row-normalization' + +// Why Reflect.set and not a cast: these fixtures deliberately violate the declared types, which is +// the whole point — writing the bad value onto a valid row says that outright. +function corrupt(row: T, overrides: Record): T { + for (const [key, value] of Object.entries(overrides)) { + Reflect.set(row, key, value) + } + return row +} + +function makeSetup(overrides: Record = {}): ProjectHostSetup { + const setup: ProjectHostSetup = { + id: 'setup-1', + projectId: 'project-1', + hostId: 'local', + repoId: 'repo-1', + path: '/Users/alice/orca', + displayName: 'orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 2 + } + return corrupt(setup, overrides) +} + +function makeProject(overrides: Record = {}): Project { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#737373', + sourceRepoIds: ['repo-1'], + createdAt: 1, + updatedAt: 2 + } + return corrupt(project, overrides) +} + +describe('normalizeProjectHostSetupRow', () => { + it('returns the input reference when every declared type already holds', () => { + const setup = makeSetup() + expect(normalizeProjectHostSetupRow(setup)).toBe(setup) + }) + + it.each([ + ['null', null], + ['undefined', undefined], + ['a number', 42], + ['an object', {}] + ])('coerces a %s repoId to an empty string', (_label, repoId) => { + expect(normalizeProjectHostSetupRow(makeSetup({ repoId })).repoId).toBe('') + }) + + // Why path: `project-grouping.ts` calls `.trim()` and `parseWslUncPath()` on it during + // sidebar render, so the same bad row takes down the main window, not one error boundary. + it.each([ + ['null', null], + ['a number', 7] + ])('coerces a %s path to an empty string', (_label, path) => { + expect(normalizeProjectHostSetupRow(makeSetup({ path })).path).toBe('') + }) + + it('coerces every other required string field', () => { + const normalized = normalizeProjectHostSetupRow( + makeSetup({ id: null, projectId: null, displayName: null }) + ) + expect(normalized.id).toBe('') + expect(normalized.projectId).toBe('') + expect(normalized.displayName).toBe('') + }) + + it('falls back to the local host id for a non-string hostId', () => { + expect(normalizeProjectHostSetupRow(makeSetup({ hostId: null })).hostId).toBe('local') + }) + + // Why 0: the catalog merge already reads 0 as "unknown", so it degrades instead of + // restamping a fake timestamp that would win min()/max() against a real one. + it.each([ + ['null', null], + ['NaN', Number.NaN], + ['a string', '5'] + ])('coerces a %s timestamp to 0', (_label, value) => { + const normalized = normalizeProjectHostSetupRow( + makeSetup({ createdAt: value, updatedAt: value }) + ) + expect(normalized.createdAt).toBe(0) + expect(normalized.updatedAt).toBe(0) + }) + + // Why: these are only ever compared, never string-method'd — inventing a fallback would + // silently change what a corrupt row claims about itself. + it('leaves the setupState and setupMethod unions untouched', () => { + const normalized = normalizeProjectHostSetupRow( + makeSetup({ repoId: null, setupState: 'bogus', setupMethod: null }) + ) + expect(normalized.setupState).toBe('bogus') + expect(normalized.setupMethod).toBeNull() + }) + + // Why: `mergeProjectCompatibilityProject` and the setup projection branch on `in` / + // `!== undefined`, so materialising an absent optional key changes merge behaviour. + it('never adds or removes an optional key', () => { + const withoutOptionals = normalizeProjectHostSetupRow(makeSetup({ repoId: null })) + expect('connectionId' in withoutOptionals).toBe(false) + expect('worktreeBasePath' in withoutOptionals).toBe(false) + const withOptionals = normalizeProjectHostSetupRow( + makeSetup({ repoId: null, connectionId: null, worktreeBasePath: '/base' }) + ) + expect('connectionId' in withOptionals).toBe(true) + expect(withOptionals.worktreeBasePath).toBe('/base') + }) +}) + +describe('normalizeProjectRow', () => { + it('returns the input reference when every declared type already holds', () => { + const project = makeProject() + expect(normalizeProjectRow(project)).toBe(project) + }) + + // Why: the compatibility merge spreads `sourceRepoIds`, which throws on a non-array. + it.each([ + ['null', null, []], + ['a string', 'repo-1', []], + ['an array with holes', ['repo-1', null, 3], ['repo-1']] + ])('repairs a %s sourceRepoIds', (_label, sourceRepoIds, expected) => { + expect(normalizeProjectRow(makeProject({ sourceRepoIds })).sourceRepoIds).toEqual(expected) + }) + + it('coerces the required string fields', () => { + const normalized = normalizeProjectRow( + makeProject({ id: null, displayName: null, badgeColor: 42 }) + ) + expect(normalized.id).toBe('') + expect(normalized.displayName).toBe('') + expect(normalized.badgeColor).toBe('') + }) + + // Why: `'localWindowsRuntimePreference' in overlay` decides whether a preference clear wins. + it('never materialises an absent localWindowsRuntimePreference', () => { + const normalized = normalizeProjectRow(makeProject({ id: null })) + expect('localWindowsRuntimePreference' in normalized).toBe(false) + }) +}) + +describe('row array normalization', () => { + it('repairs null rows without throwing', () => { + const setups = [null] as unknown as ProjectHostSetup[] + const projects = [null] as unknown as Project[] + expect(normalizeProjectHostSetupRows(setups)[0]).toMatchObject({ + id: '', + repoId: '', + path: '' + }) + expect(normalizeProjectRows(projects)[0]).toMatchObject({ + id: '', + sourceRepoIds: [] + }) + }) + + it('returns the input array when nothing needed repair', () => { + const setups = [makeSetup(), makeSetup({ id: 'setup-2' })] + expect(normalizeProjectHostSetupRows(setups)).toBe(setups) + const projects = [makeProject()] + expect(normalizeProjectRows(projects)).toBe(projects) + }) + + // Why: TerminalPane and TabBarQuickCommandsButton use these arrays as useMemo deps, so an + // untouched row must keep its object identity across normalization. + it('reuses untouched rows and only reallocates the repaired one', () => { + const setups = [ + makeSetup(), + makeSetup({ id: 'setup-2', repoId: null }), + makeSetup({ id: 's3' }) + ] + const normalized = normalizeProjectHostSetupRows(setups) + expect(normalized).not.toBe(setups) + expect(normalized[0]).toBe(setups[0]) + expect(normalized[1]).not.toBe(setups[1]) + expect(normalized[2]).toBe(setups[2]) + }) + + // Why: coercing a field must change one value, never which rows exist — setting a "changed" + // flag on the old selector normalizer unioned in repo-derived rows and invented phantom rows. + it('preserves row count and order', () => { + const setups = [makeSetup({ repoId: null }), makeSetup({ id: 'setup-2' })] + expect(normalizeProjectHostSetupRows(setups).map((setup) => setup.id)).toEqual([ + 'setup-1', + 'setup-2' + ]) + }) + + it('treats a non-array as empty', () => { + // Why JSON.parse: a persisted catalog really can hold a non-array here, and parsing is how it + // arrives — the parameter type cannot express it. + const notAnArray: never[] = JSON.parse('null') + expect(normalizeProjectHostSetupRows(notAnArray)).toEqual([]) + expect(normalizeProjectRows(notAnArray)).toEqual([]) + }) +}) diff --git a/src/shared/project-catalog-row-normalization.ts b/src/shared/project-catalog-row-normalization.ts new file mode 100644 index 00000000000..437545e639a --- /dev/null +++ b/src/shared/project-catalog-row-normalization.ts @@ -0,0 +1,123 @@ +import { LOCAL_EXECUTION_HOST_ID } from './execution-host' +import type { Project, ProjectHostSetup } from './project-types' + +/** + * Repairs untrusted `Project` / `ProjectHostSetup` rows so their declared field types are true. + * + * These rows reach typed code from persisted JSON and from remote Orca hosts running a different + * version, where a field the type promises is a `string` can arrive `null`, missing, or another + * type entirely — while consumers call `.trim()` on it unconditionally (crash 3bcc5be3). Call + * these at every boundary such a row enters the app, so nothing downstream has to re-guard. + * + * Coercion only. A normalizer never drops a row, never adds or removes an optional key, and + * returns its input reference untouched when it already conforms — callers keep row and array + * identity, which selectors and `useMemo` deps depend on. + * + * The string-literal unions (`setupState`, `setupMethod`) are deliberately left alone: they are + * only ever compared, so an unknown value degrades instead of crashing, and inventing a fallback + * would silently change what a corrupt row claims about itself. + */ + +function isString(value: unknown): value is string { + return typeof value === 'string' +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +// Why 0 and not now(): the catalog merge already reads 0 as "timestamp unknown", not as the epoch. +function isTimestamp(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +function stringValue(value: unknown, fallback = ''): string { + return isString(value) ? value : fallback +} + +function timestampValue(value: unknown): number { + return isTimestamp(value) ? value : 0 +} + +export function normalizeProjectHostSetupRow(setup: ProjectHostSetup): ProjectHostSetup { + const row: Record = isRecord(setup) ? setup : {} + const needsRepair = + !isString(row.id) || + !isString(row.projectId) || + !isString(row.hostId) || + !isString(row.repoId) || + !isString(row.path) || + !isString(row.displayName) || + !isTimestamp(row.createdAt) || + !isTimestamp(row.updatedAt) + if (!needsRepair) { + return setup + } + return { + ...row, + id: stringValue(row.id), + projectId: stringValue(row.projectId), + hostId: stringValue(row.hostId, LOCAL_EXECUTION_HOST_ID) as ProjectHostSetup['hostId'], + repoId: stringValue(row.repoId), + path: stringValue(row.path), + displayName: stringValue(row.displayName), + setupState: (row.setupState === undefined + ? 'error' + : row.setupState) as ProjectHostSetup['setupState'], + setupMethod: (row.setupMethod === undefined + ? 'legacy-repo' + : row.setupMethod) as ProjectHostSetup['setupMethod'], + createdAt: timestampValue(row.createdAt), + updatedAt: timestampValue(row.updatedAt) + } +} + +export function normalizeProjectRow(project: Project): Project { + const row: Record = isRecord(project) ? project : {} + const sourceRepoIdsConform = Array.isArray(row.sourceRepoIds) && row.sourceRepoIds.every(isString) + const needsRepair = + !isString(row.id) || + !isString(row.displayName) || + !isString(row.badgeColor) || + !sourceRepoIdsConform || + !isTimestamp(row.createdAt) || + !isTimestamp(row.updatedAt) + if (!needsRepair) { + return project + } + return { + ...row, + id: stringValue(row.id), + displayName: stringValue(row.displayName), + badgeColor: stringValue(row.badgeColor), + sourceRepoIds: Array.isArray(row.sourceRepoIds) + ? row.sourceRepoIds.filter((repoId): repoId is string => isString(repoId)) + : [], + createdAt: timestampValue(row.createdAt), + updatedAt: timestampValue(row.updatedAt) + } +} + +function normalizeRows(rows: T[], normalize: (row: T) => T): T[] { + if (!Array.isArray(rows)) { + return [] + } + let repaired: T[] | null = null + for (let index = 0; index < rows.length; index += 1) { + const row = rows[index]! + const normalized = normalize(row) + if (normalized !== row && !repaired) { + repaired = rows.slice(0, index) + } + repaired?.push(normalized) + } + return repaired ?? rows +} + +export function normalizeProjectHostSetupRows(setups: ProjectHostSetup[]): ProjectHostSetup[] { + return normalizeRows(setups, normalizeProjectHostSetupRow) +} + +export function normalizeProjectRows(projects: Project[]): Project[] { + return normalizeRows(projects, normalizeProjectRow) +} diff --git a/src/shared/project-host-setup-projection.ts b/src/shared/project-host-setup-projection.ts index 8c3e5d15643..090f421ccdd 100644 --- a/src/shared/project-host-setup-projection.ts +++ b/src/shared/project-host-setup-projection.ts @@ -1,4 +1,8 @@ import { getRepoExecutionHostId } from './execution-host' +import { + normalizeProjectHostSetupRow, + normalizeProjectRow +} from './project-catalog-row-normalization' import { normalizeGitHubRemoteHost } from './git-remote-host-alias' import { githubRepoIdentityKey, isDefaultGitHubHost } from './github/repository-identity-key' import type { Project, ProjectHostSetup, ProjectProviderIdentity } from './project-types' @@ -312,7 +316,9 @@ export function projectHostSetupProjectionFromRepos( const project = existing ? mergeProjectRepo(existing.project, repo) : createProjectFromRepo(repo) - const setup = createSetupFromRepo(repo, projectId) + // Why normalize here: a repo row is untrusted persisted/wire data too, and these + // constructors copy repo.path / repo.id straight onto fields consumers call .trim() on. + const setup = normalizeProjectHostSetupRow(createSetupFromRepo(repo, projectId)) projectById.set(projectId, { project }) @@ -320,7 +326,7 @@ export function projectHostSetupProjectionFromRepos( } return { - projects: [...projectById.values()].map((entry) => entry.project), + projects: [...projectById.values()].map((entry) => normalizeProjectRow(entry.project)), setups } } From 3558cf943f2284b6d3335df386d711cf1adf5b10 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:05:12 -0700 Subject: [PATCH 062/152] fix(codex): heal WSL hooks before typed launches (#16535) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(codex): heal WSL hooks before typed launches * test(codex): keep launcher fixture type-safe on Windows * fix(build): list codex-home-wsl-env in the CLI typecheck project `managed-home-shell-preflight.ts` is already in the CLI project's include list and now imports `wslCodexRuntimeHomeForGuestHome` from `src/main/pty/codex-home-wsl-env.ts`, which the list did not cover — TS6307, so the CLI typecheck failed on every push. Added the single module rather than a `src/main/pty/**` glob: it is a 31-line leaf with no imports of its own, so it does not widen what the CLI bundle can reach. * fix(codex): converge the two WSL hook install lanes onto one writer Two independent readiness reviews agreed the Orca-terminal boundary holds, but Codex Sol found a P1 the other rated P2: the new just-in-time repair raced the existing relay installer and the two produced DIFFERENT hook and trust representations for the same managed home. Two unserialized writers emitting different formats is worse than the bug this PR fixes, because it fails intermittently rather than cleanly — a pane works or does not depending on which lane won. - Relay Codex installs now delegate to the runtime-home writer, so there is one canonical representation instead of two. Redirected scripts use the runtime path, the readable wrapper, and the prepended group. - `installForRuntimeHomeSerialized` puts every asynchronous WSL caller for a given home on one queue (`wslInstallQueues`), so concurrent panes cannot interleave writes. Also rewrites the stale pin test the new `-x` guard broke. It asserted the defect — "would run the impostor if the preflight carried an unqualified command name", expecting the hijack marker to exist. The guard is a security improvement, so the test now asserts the contract: an unqualified preflight is skipped and the marker is never written. Rewritten to the new behavior, not loosened or deleted. 818 tests pass across the affected suites; typecheck clean. The changed-file quality gate could not run locally — its pnpm engine-warning JSON parser fails under Node 26 — so CI covers it. The boundary both reviews verified is untouched: paired/relay/mobile clients stay hard-blocked from the RPC, params remain shape-locked to the managed home suffix with traversal rejection, nothing is written outside the managed home, and macOS/Linux stay inert. * fix(codex): serialize resolved WSL hook homes * fix(codex): recover managed WSL homes after restart * fix(wsl): translate Codex preflight through WSLENV * fix(cli): cover bounded WSL Codex repair * fix(codex): coalesce duplicate WSL hook repairs * fix(codex): verify reconstructed WSL homes --- src/cli/handlers/agent-hooks.test.ts | 28 ++++ src/cli/handlers/agent-hooks.ts | 19 +++ .../daemon-bash-rcfile.txt | 2 +- .../daemon-fish-init.txt | 2 +- .../daemon-zsh-zshenv.txt | 2 +- .../local-bash-rcfile.txt | 2 +- .../local-fish-init.txt | 2 +- .../local-zsh-zshenv.txt | 2 +- .../remote-hook-service-installers.test.ts | 2 +- src/main/agent-hooks/wsl-hook-fs-adapter.ts | 31 +++- src/main/agent-hooks/wsl-hook-relay-deps.ts | 8 + .../wsl-hook-relay-guest-install.ts | 6 +- .../wsl-hook-relay-live.integration.test.ts | 17 +- .../wsl-hook-relay-manager.test.ts | 39 ++++- .../agent-hooks/wsl-hook-relay-manager.ts | 35 ++++- .../codex-trust-config-mutation-queue.test.ts | 32 ++++ .../codex/hook-service-wsl-runtime.test.ts | 63 +++++++- src/main/codex/hook-service.ts | 43 +++++- .../managed-home-shell-preflight.test.ts | 137 +++++++++++++++++ .../codex/managed-wsl-codex-home-registry.ts | 115 ++++++++++++++ .../codex/managed-wsl-home-shell-preflight.ts | 75 +++++++++ .../ipc/pty-spawn-env-terminal-basics.test.ts | 27 ++++ src/main/ipc/pty/host-env/assembly.ts | 2 +- ...dex-preflight-profile-path-rewrite.test.ts | 6 +- .../pty/codex-shell-launch-preflight.test.ts | 71 ++++++++- src/main/pty/codex-shell-launch-preflight.ts | 15 +- src/main/pty/wsl-orca-env.test.ts | 2 +- src/main/pty/wsl-orca-env.ts | 1 + .../runtime/rpc/methods/agent-hooks.test.ts | 145 ++++++++++++++++++ src/main/runtime/rpc/methods/agent-hooks.ts | 38 +++++ src/main/runtime/rpc/methods/index.ts | 2 + 31 files changed, 917 insertions(+), 54 deletions(-) create mode 100644 src/main/codex/managed-wsl-codex-home-registry.ts create mode 100644 src/main/codex/managed-wsl-home-shell-preflight.ts create mode 100644 src/main/runtime/rpc/methods/agent-hooks.test.ts create mode 100644 src/main/runtime/rpc/methods/agent-hooks.ts diff --git a/src/cli/handlers/agent-hooks.test.ts b/src/cli/handlers/agent-hooks.test.ts index c42531364a8..4fcc186b0d8 100644 --- a/src/cli/handlers/agent-hooks.test.ts +++ b/src/cli/handlers/agent-hooks.test.ts @@ -92,6 +92,7 @@ describe('agent hooks CLI handler', () => { }) afterEach(() => { + vi.unstubAllEnvs() vi.restoreAllMocks() rmSync(userDataPath, { recursive: true, force: true }) }) @@ -139,6 +140,33 @@ describe('agent hooks CLI handler', () => { }) }) + it('forwards WSL pane routing to the runtime exactly once without using the host installer', async () => { + const home = '/home/jin/.local/share/orca/codex-runtime-home/home' + vi.stubEnv('CODEX_HOME', home) + vi.stubEnv('ORCA_CODEX_HOME', home) + vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu-24.04') + callMock.mockResolvedValue({ result: { state: 'installed' } }) + + await main(['agent', 'hooks', 'prepare-codex'], userDataPath) + + expect(callMock).toHaveBeenCalledExactlyOnceWith( + 'agentHooks.prepareCodexForWslPane', + { codexHome: home, orcaCodexHome: home, wslDistro: 'Ubuntu-24.04' }, + { timeoutMs: 50_000 } + ) + expect(prepareManagedCodexHomeBeforeShellLaunchMock).not.toHaveBeenCalled() + }) + + it('fails open when WSL runtime preparation is unavailable', async () => { + vi.stubEnv('CODEX_HOME', '/home/jin/.local/share/orca/codex-runtime-home/home') + vi.stubEnv('ORCA_CODEX_HOME', '/home/jin/.local/share/orca/codex-runtime-home/home') + vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu') + callMock.mockRejectedValue(new Error('method_not_found')) + + await expect(main(['agent', 'hooks', 'prepare-codex'], userDataPath)).resolves.toBeUndefined() + expect(prepareManagedCodexHomeBeforeShellLaunchMock).not.toHaveBeenCalled() + }) + it('honors Codex-specific disablement when the runtime is unavailable', async () => { const state = getDefaultPersistedState(userDataPath) state.settings.disabledTuiAgents = ['codex'] diff --git a/src/cli/handlers/agent-hooks.ts b/src/cli/handlers/agent-hooks.ts index 57419518d4b..bf44211b4a7 100644 --- a/src/cli/handlers/agent-hooks.ts +++ b/src/cli/handlers/agent-hooks.ts @@ -28,6 +28,9 @@ type AgentHookCommandResult = { statuses: AgentHookInstallStatus[] } +// Covers managed-home verification, WSL identity, trust grant, and bounded app-server reap. +const WSL_CODEX_PREPARE_TIMEOUT_MS = 50_000 + function getDataPath(): string { const userDataPath = getDefaultUserDataPath() const indexPath = join(userDataPath, 'orca-profile-index.json') @@ -207,6 +210,22 @@ async function setAgentHooksEnabled( export const AGENT_HOOK_HANDLERS: Record = { 'agent hooks prepare-codex': async ({ client }) => { + if (process.env.WSL_DISTRO_NAME?.trim()) { + try { + await client.call( + 'agentHooks.prepareCodexForWslPane', + { + codexHome: process.env.CODEX_HOME ?? '', + orcaCodexHome: process.env.ORCA_CODEX_HOME ?? '', + wslDistro: process.env.WSL_DISTRO_NAME + }, + { timeoutMs: WSL_CODEX_PREPARE_TIMEOUT_MS } + ) + } catch { + // Best effort: old or unavailable runtimes must not block Codex launch. + } + return + } const settings = await readHookSettings(client) await prepareManagedCodexHomeBeforeShellLaunch({ userDataPath: getDefaultUserDataPath(), diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt index 079d23ec734..63748ee93af 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt @@ -72,7 +72,7 @@ fi # Why || : twice — zsh alone aborts inside the substitution, but every shell's # assignment adopts its exit status, so an absent codex trips set -e in bash too. __orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" -if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then +if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then # Why the function reserved word: it suppresses alias expansion of the name, # which otherwise rewrites this header at parse time and aborts the whole file. function codex { diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt index 9c8b9179a81..660c8f2d1f9 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt @@ -2,7 +2,7 @@ function __orca_shell_ready_marker --on-event fish_prompt builtin printf "\033]777;orca-shell-ready\007" functions -e __orca_shell_ready_marker end -if test -n "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file +if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file function codex command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true command codex $argv diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index f0ec07cfcfd..e734f8c1867 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -110,7 +110,7 @@ __orca_deferred_init() { # Why || : twice — zsh alone aborts inside the substitution, but every shell's # assignment adopts its exit status, so an absent codex trips set -e in bash too. __orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" - if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then + if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then # Why the function reserved word: it suppresses alias expansion of the name, # which otherwise rewrites this header at parse time and aborts the whole file. function codex { diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt index 895434cc688..9d1e2922b45 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt @@ -75,7 +75,7 @@ fi # Why || : twice — zsh alone aborts inside the substitution, but every shell's # assignment adopts its exit status, so an absent codex trips set -e in bash too. __orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" -if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then +if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then # Why the function reserved word: it suppresses alias expansion of the name, # which otherwise rewrites this header at parse time and aborts the whole file. function codex { diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt index 9c8b9179a81..660c8f2d1f9 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt @@ -2,7 +2,7 @@ function __orca_shell_ready_marker --on-event fish_prompt builtin printf "\033]777;orca-shell-ready\007" functions -e __orca_shell_ready_marker end -if test -n "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file +if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file function codex command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true command codex $argv diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index 92802c53dca..af7cab85587 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -110,7 +110,7 @@ __orca_deferred_init() { # Why || : twice — zsh alone aborts inside the substitution, but every shell's # assignment adopts its exit status, so an absent codex trips set -e in bash too. __orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" - if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then + if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then # Why the function reserved word: it suppresses alias expansion of the name, # which otherwise rewrites this header at parse time and aborts the whole file. function codex { diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index bcb446b10ee..a1168326ac2 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -284,7 +284,7 @@ describe('remote hook service installers', () => { hooks: Record } expect(hooks.hooks.Stop?.[0]?.hooks?.[0]?.command).toContain( - '/home/dev/.orca/agent-hooks/codex-hook.sh' + '/home/dev/.local/share/orca/codex-runtime-home/home/.orca/agent-hooks/codex-hook.sh' ) expect(fs.files.get(`${runtimeHome}/config.toml`)).toContain( `${runtimeHome}/hooks.json:stop:0:0` diff --git a/src/main/agent-hooks/wsl-hook-fs-adapter.ts b/src/main/agent-hooks/wsl-hook-fs-adapter.ts index 12563b65bd8..ce2e9dd75f7 100644 --- a/src/main/agent-hooks/wsl-hook-fs-adapter.ts +++ b/src/main/agent-hooks/wsl-hook-fs-adapter.ts @@ -12,19 +12,23 @@ import { type ManagedHookDetectionSettings } from './managed-hook-detection-commands' import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env' import { WSL_HOOK_FS_METHODS, type WslFsResult } from '../../shared/wsl-hook-relay-contract' +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' /** Run the shared remote hook installers against a WSL guest over the relay's fs bridge. */ export async function installWslGuestHooks(options: { mux: SshChannelMultiplexer guestHome: string + codexHomePath: string | null distro: string installHooks: typeof installRemoteManagedAgentHooks settings: ManagedHookDetectionSettings warn: (message: string) => void + /** Canonical runtime-host installer for redirected Codex homes. */ + installCodex: (runtimeHomePath: string, distro: string) => Promise }): Promise { - const { mux, guestHome, distro, installHooks, settings, warn } = options + const { mux, guestHome, codexHomePath, distro, installHooks, settings, warn, installCodex } = + options let agents try { const detected = (await mux.request('preflight.detectAgents', { @@ -42,12 +46,25 @@ export async function installWslGuestHooks(options: { if (agents.length === 0) { return } + if (agents.includes('codex') && codexHomePath) { + try { + const status = await installCodex(codexHomePath, distro) + if (status?.state === 'error') { + warn( + `[agent-hooks] WSL Codex hook install for '${distro}' failed: ${status.detail ?? 'unknown error'}` + ) + } + } catch (error) { + warn( + `[agent-hooks] WSL Codex hook install for '${distro}' failed: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + // Codex is redirected into the runtime home and must use the canonical + // runtime-host writer above; the relay adapter owns all other agents. + const remoteAgents = agents.filter((agent) => agent !== 'codex') const results = await installHooks(createWslHookSftpAdapter(mux), guestHome, { - agents, - // WSL Codex launches use Orca's managed runtime CODEX_HOME, not ~/.codex. - // Keep relay hooks in that active home so status callbacks are received. - codexHomeDir: wslCodexRuntimeHomeForGuestHome(guestHome), - deferTrustUntilConfigToml: true + agents: remoteAgents }) const failed = results.filter((r) => r.state === 'error').length if (failed > 0) { diff --git a/src/main/agent-hooks/wsl-hook-relay-deps.ts b/src/main/agent-hooks/wsl-hook-relay-deps.ts index a7f1ef54bb4..dcff332e57b 100644 --- a/src/main/agent-hooks/wsl-hook-relay-deps.ts +++ b/src/main/agent-hooks/wsl-hook-relay-deps.ts @@ -9,6 +9,8 @@ import { agentHookServer } from './server' import type { ManagedHookDetectionSettings } from './managed-hook-detection-commands' import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers' import { getOpenCodePluginSource } from '../opencode/hook-service' +import { codexHookService } from '../codex/hook-service' +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' import type { PluginSources } from '../../relay/plugin-overlay' import { isWslDistroRunning, @@ -61,6 +63,7 @@ export type WslHookRelayManagerDeps = { waitForSentinel: typeof waitForWslRelaySentinel ingest: (envelope: Record, connectionId: string) => void installHooks: typeof installRemoteManagedAgentHooks + installCodex: (runtimeHomePath: string, distro: string) => Promise managedHookSettings: () => ManagedHookDetectionSettings /** Plugin source strings shipped to the guest relay so an Orca update needn't redeploy the relay bundle. */ pluginSources: () => PluginSources @@ -102,6 +105,11 @@ export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = { connectionId ), installHooks: installRemoteManagedAgentHooks, + installCodex: (runtimeHomePath, distro) => + codexHookService.installForRuntimeHomeSerialized(runtimeHomePath, { + runtime: 'wsl', + wslDistro: distro + }), managedHookSettings: () => null, // Why: only OpenCode is in scope for WSL now; the payload shape stays identical to SSH so Pi/OMP are additive later. pluginSources: () => ({ opencodePluginSource: getOpenCodePluginSource() }), diff --git a/src/main/agent-hooks/wsl-hook-relay-guest-install.ts b/src/main/agent-hooks/wsl-hook-relay-guest-install.ts index d7e59651a52..84cf4bfa57a 100644 --- a/src/main/agent-hooks/wsl-hook-relay-guest-install.ts +++ b/src/main/agent-hooks/wsl-hook-relay-guest-install.ts @@ -6,13 +6,14 @@ import type { ManagedHookDetectionSettings } from './managed-hook-detection-comm import type { installRemoteManagedAgentHooks } from './remote-managed-hook-installers' import { requestGuestOpenCodeOverlayDir } from './wsl-guest-plugin-install' import { installWslGuestHooks } from './wsl-hook-fs-adapter' -import { REINSTALL_MIN_INTERVAL_MS } from './wsl-hook-relay-deps' +import { REINSTALL_MIN_INTERVAL_MS, type WslHookRelayManagerDeps } from './wsl-hook-relay-deps' import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import type { PluginSources } from '../../relay/plugin-overlay' /** Structural slice of WslHookRelayManagerDeps — only what an install pass uses. */ type GuestInstallDeps = { installHooks: typeof installRemoteManagedAgentHooks + installCodex: WslHookRelayManagerDeps['installCodex'] managedHookSettings: () => ManagedHookDetectionSettings pluginSources: () => PluginSources warn: (message: string) => void @@ -23,6 +24,7 @@ type GuestInstallState = { distro: string mux?: SshChannelMultiplexer guestHome?: string + codexHomePath?: string opencodeOverlayDir?: string lastInstallAt?: number } @@ -37,8 +39,10 @@ export async function runWslRelayGuestInstall( await installWslGuestHooks({ mux, guestHome, + codexHomePath: state.codexHomePath ?? null, distro: state.distro, installHooks: deps.installHooks, + installCodex: deps.installCodex, settings: deps.managedHookSettings(), warn: deps.warn }) diff --git a/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts b/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts index ea6856e9862..365e4d5fcd8 100644 --- a/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts @@ -13,7 +13,8 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import { AgentHookServer } from './server' import { WslHookRelayManager } from './wsl-hook-relay-manager' -import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env' +import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem' +import { codexHookService } from '../codex/hook-service' const BUNDLE_DIR = join(process.cwd(), 'out', 'relay', 'wsl') const BUNDLE_JS = join(BUNDLE_DIR, 'wsl-agent-hook-relay.js') @@ -75,6 +76,7 @@ describe.skipIf(process.platform === 'win32')( const server = orcaServer const warns: string[] = [] + const codexHome = join(fakeHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home') manager = new WslHookRelayManager({ platform: () => 'win32', remoteHooksEnabled: () => true, @@ -102,6 +104,11 @@ describe.skipIf(process.platform === 'win32')( envelope as Parameters[0], connectionId ), + installCodex: (runtimeHomePath) => + codexHookService.installRemote(createManagedHookLocalFilesystem(), fakeHome, { + codexHomeDir: runtimeHomePath, + deferTrustUntilConfigToml: true + }), managedHookSettings: () => ({ agentCmdOverrides: { claude: process.execPath, @@ -112,15 +119,17 @@ describe.skipIf(process.platform === 'win32')( transientRetryDelayMs: 1 }) - manager.ensureForDistro('LiveDistro') + manager.ensureForDistro('LiveDistro', codexHome) // Waiting on Codex's artifact (not Claude's, which is written first) keeps the // assertions behind the still-running 14-agent installer loop. - const codexHome = wslCodexRuntimeHomeForGuestHome(fakeHome) await vi.waitFor(() => expect(existsSync(join(codexHome, 'hooks.json'))).toBe(true), { timeout: 15_000 }) - expect(existsSync(join(fakeHome, '.claude', 'settings.json'))).toBe(true) + await vi.waitFor( + () => expect(existsSync(join(fakeHome, '.claude', 'settings.json'))).toBe(true), + { timeout: 15_000 } + ) const claudeScript = readFileSync( join(fakeHome, '.orca', 'agent-hooks', 'claude-hook.sh'), 'utf8' diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts index 797b461d133..9d92f5d131a 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts @@ -147,6 +147,8 @@ describe('WslHookRelayManager', () => { // hosts — installHooks is mocked here, so the fs bridge only ever serves // the wslfs.home request and never touches the real filesystem. const home = '/home/wsl-test-user' + const codexHome = + '\\\\wsl.localhost\\Ubuntu\\home\\wsl-test-user\\.local\\share\\orca\\codex-runtime-home\\home' const opencodeOverlayDir = `${home}/.orca-relay/opencode-overlays/deadbeefcafe` let harnesses: GuestHarness[] @@ -229,6 +231,13 @@ describe('WslHookRelayManager', () => { waitForSentinel: vi.fn(async () => guestTransport()), ingest: vi.fn(), installHooks: vi.fn(async () => []), + installCodex: vi.fn(async () => ({ + agent: 'codex' as const, + state: 'installed' as const, + configPath: `${home}/.local/share/orca/codex-runtime-home/home/hooks.json`, + managedHooksPresent: true, + detail: null + })), managedHookSettings: () => null, pluginSources: () => ({ opencodePluginSource: '// opencode plugin source' }), warn: vi.fn(), @@ -240,14 +249,14 @@ describe('WslHookRelayManager', () => { it('starts one relay per distro, installs hooks, exposes the guest endpoint path, and forwards envelopes', async () => { const { manager, deps } = createManager({}) - manager.ensureForDistro('Ubuntu') - manager.ensureForDistro('Ubuntu') + manager.ensureForDistro('Ubuntu', codexHome) + manager.ensureForDistro('Ubuntu', codexHome) await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1)) expect(deps.spawnRelay).toHaveBeenCalledTimes(1) + expect(deps.installCodex).toHaveBeenCalledWith(codexHome, 'Ubuntu') + // Codex is owned by the canonical runtime-host writer, not the relay adapter. expect(deps.installHooks).toHaveBeenCalledWith(expect.anything(), home, { - agents: ['codex'], - codexHomeDir: `${home}/.local/share/orca/codex-runtime-home/home`, - deferTrustUntilConfigToml: true + agents: [] }) expect(manager.getGuestEndpointFilePath('Ubuntu')).toBe( @@ -272,9 +281,23 @@ describe('WslHookRelayManager', () => { manager.disposeAll() }) + it('reinstalls into a newly resolved runtime home without restarting the relay', async () => { + const { manager, deps } = createManager({}) + manager.ensureForDistro('Ubuntu', codexHome) + await vi.waitFor(() => expect(deps.installCodex).toHaveBeenCalledTimes(1)) + const nextHome = codexHome.replace('codex-runtime-home', 'codex-accounts\\account-2') + + manager.ensureForDistro('ubuntu', nextHome) + await vi.waitFor(() => expect(deps.installCodex).toHaveBeenCalledTimes(2)) + + expect(deps.installCodex).toHaveBeenLastCalledWith(nextHome, 'Ubuntu') + expect(deps.spawnRelay).toHaveBeenCalledTimes(1) + manager.disposeAll() + }) + it('ships the OpenCode plugin to the guest and exposes the overlay dir', async () => { const { manager } = createManager({}) - manager.ensureForDistro('Ubuntu') + manager.ensureForDistro('Ubuntu', codexHome) await vi.waitFor(() => expect(manager.getOpenCodeOverlayDir('Ubuntu')).toBe(opencodeOverlayDir)) manager.disposeAll() }) @@ -397,7 +420,7 @@ describe('WslHookRelayManager', () => { it('stops live relays and refuses to revive them once agent status hooks are switched off', async () => { const settings = { agentStatusHooksEnabled: true } const { manager, deps } = createManager({ managedHookSettings: () => settings }) - manager.ensureForDistro('Ubuntu') + manager.ensureForDistro('Ubuntu', codexHome) await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1)) settings.agentStatusHooksEnabled = false @@ -414,6 +437,8 @@ describe('WslHookRelayManager', () => { settings.agentStatusHooksEnabled = true manager.resumeStoppedRelays() await vi.waitFor(() => expect(deps.spawnRelay).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(deps.installCodex).toHaveBeenCalledTimes(2)) + expect(deps.installCodex).toHaveBeenLastCalledWith(codexHome, 'Ubuntu') manager.disposeAll() }) diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.ts b/src/main/agent-hooks/wsl-hook-relay-manager.ts index 74d163ca42f..1a414df5898 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.ts @@ -29,6 +29,10 @@ import { WSL_HOOK_FS_METHODS, wslHookRelayEndpointFilePath } from '../../shared/wsl-hook-relay-contract' +import { + recordManagedWslCodexHome, + wslRuntimeHomePathsEqual +} from '../codex/managed-wsl-codex-home-registry' type DistroState = { /** Original casing for wsl.exe argv and breadcrumbs; map keys are lowercased. */ @@ -37,6 +41,7 @@ type DistroState = { child?: ChildProcessWithoutNullStreams mux?: SshChannelMultiplexer guestHome?: string + codexHomePath?: string guestEndpointFilePath?: string opencodeOverlayDir?: string failures: number @@ -52,7 +57,7 @@ export class WslHookRelayManager { private recovery: WslRelayRecovery private states = new Map() /** Distros a hooks-off teardown stopped, so re-enabling can put them back. */ - private stoppedByHooksOff = new Set() + private stoppedByHooksOff = new Map() private defaultDistro: string | null = null private disposed = false private warnedBundleMissing = false @@ -64,7 +69,7 @@ export class WslHookRelayManager { warn: (message) => this.deps.warn(message), isDisposed: () => this.disposed, isCurrent: (state) => this.states.get(wslHookRelayStateKey(state.distro)) === state, - restart: (distro) => this.ensureForDistro(distro), + restart: (distro) => this.ensureForDistro(distro, this.stateFor(distro)?.codexHomePath), dropState: (state) => { // Why: identity-guarded — a fresh ensure() may own this key by now; // deleting by key alone would orphan its live relay child. @@ -81,11 +86,11 @@ export class WslHookRelayManager { } /** Fire-and-forget from every WSL PTY spawn-env build; errors breadcrumb. */ - ensureForDistro(distro: string | null): void { + ensureForDistro(distro: string | null, codexHomePath?: string | null): void { if (this.disposed || !isWslHookRelayAllowed(this.deps)) { return } - void this.ensureInternal(distro).catch((err) => { + void this.ensureInternal(distro, codexHomePath ?? undefined).catch((err) => { const detail = err instanceof Error ? err.message : String(err) this.deps.warn(`[agent-hooks] WSL hook relay ensure failed: ${detail}`) }) @@ -118,7 +123,7 @@ export class WslHookRelayManager { state.mux?.dispose() state.child?.kill() if (!permanent) { - this.stoppedByHooksOff.add(state.distro) + this.stoppedByHooksOff.set(state.distro, state.codexHomePath) } } this.states.clear() @@ -129,26 +134,39 @@ export class WslHookRelayManager { resumeStoppedRelays(): void { const distros = [...this.stoppedByHooksOff] this.stoppedByHooksOff.clear() - for (const distro of distros) { + for (const [distro, codexHomePath] of distros) { void this.deps .isDistroRunning(distro) .then((running) => { if (running) { - this.ensureForDistro(distro) + this.ensureForDistro(distro, codexHomePath) } }) .catch(() => undefined) } } - private async ensureInternal(requestedDistro: string | null): Promise { + private async ensureInternal( + requestedDistro: string | null, + requestedCodexHomePath?: string + ): Promise { const distro = requestedDistro ?? (await this.resolveDefaultDistro()) if (!distro || this.disposed) { return } const key = wslHookRelayStateKey(distro) const existing = this.states.get(key) + if (requestedCodexHomePath) { + recordManagedWslCodexHome(distro, requestedCodexHomePath) + } if (existing) { + if ( + requestedCodexHomePath && + !wslRuntimeHomePathsEqual(existing.codexHomePath, requestedCodexHomePath) + ) { + existing.codexHomePath = requestedCodexHomePath + existing.lastInstallAt = 0 + } if (existing.phase === 'running') { void maybeRerunWslRelayGuestInstall(this.deps, existing) return @@ -184,6 +202,7 @@ export class WslHookRelayManager { // Why: instance-keyed and on the distro's persistent fs, so it outlives a relay // crash — dropping it would blank status on panes spawned mid-relaunch. opencodeOverlayDir: existing?.opencodeOverlayDir, + codexHomePath: requestedCodexHomePath ?? existing?.codexHomePath, cooldownUntil: 0 } this.states.set(key, state) diff --git a/src/main/codex/codex-trust-config-mutation-queue.test.ts b/src/main/codex/codex-trust-config-mutation-queue.test.ts index e2d775b4ed7..f07c047c203 100644 --- a/src/main/codex/codex-trust-config-mutation-queue.test.ts +++ b/src/main/codex/codex-trust-config-mutation-queue.test.ts @@ -108,4 +108,36 @@ describe('runExclusivelyForCodexTrustConfig', () => { await queued expect(secondStarted).toBe(true) }) + + it('coalesces WSL UNC aliases without folding the case-sensitive Linux path', async () => { + const aliasGate = deferred() + let aliasStarted = false + const blockedAlias = runExclusivelyForCodexTrustConfig( + String.raw`\\wsl.localhost\Ubuntu\home\Alice\.codex\config.toml`, + () => aliasGate.promise + ) + const queuedAlias = runExclusivelyForCodexTrustConfig( + String.raw`\\wsl$\ubuntu\home\Alice\.codex\config.toml`, + () => { + aliasStarted = true + return Promise.resolve() + } + ) + await Promise.resolve() + expect(aliasStarted).toBe(false) + + let distinctStarted = false + await runExclusivelyForCodexTrustConfig( + String.raw`\\wsl.localhost\Ubuntu\home\alice\.codex\config.toml`, + async () => { + distinctStarted = true + } + ) + expect(distinctStarted).toBe(true) + + aliasGate.resolve() + await blockedAlias + await queuedAlias + expect(aliasStarted).toBe(true) + }) }) diff --git a/src/main/codex/hook-service-wsl-runtime.test.ts b/src/main/codex/hook-service-wsl-runtime.test.ts index 5694e4fe9bb..05baab50912 100644 --- a/src/main/codex/hook-service-wsl-runtime.test.ts +++ b/src/main/codex/hook-service-wsl-runtime.test.ts @@ -16,6 +16,7 @@ import { type CodexTrustEntry } from './config-toml-trust' import { + CodexHookService, _internals, createCodexWslRuntimeHookInstallPlan, type CodexWslRuntimeHookInstallPlan @@ -82,7 +83,67 @@ function expectedManagedCommand(scriptPath: string): string { } describe('Codex WSL runtime hook install', () => { - it('plans WSL hook files with Linux command and trust paths', async () => { + it('coalesces aliases of one runtime home without blocking independent homes', async () => { + const service = new CodexHookService() + const releases: (() => void)[] = [] + const started: string[] = [] + vi.spyOn(service, 'installForRuntimeHome').mockImplementation(async (runtimeHomePath) => { + started.push(runtimeHomePath!) + await new Promise((resolve) => releases.push(resolve)) + return null + }) + const firstHome = '\\\\wsl$\\Ubuntu\\home\\Alice\\.local\\share\\orca\\codex-runtime-home\\home' + const alias = firstHome.replace('\\\\wsl$', '\\\\wsl.localhost') + const independent = firstHome.replace('\\Alice\\', '\\Bob\\') + + const first = service.installForRuntimeHomeSerialized(firstHome) + const second = service.installForRuntimeHomeSerialized(alias) + const third = service.installForRuntimeHomeSerialized(independent) + await vi.waitFor(() => expect(started).toEqual([firstHome, independent])) + expect(second).toBe(first) + + releases.splice(0).forEach((release) => release()) + await Promise.all([first, second, third]) + expect(started).toEqual([firstHome, independent]) + }) + + it('does not coalesce one drive-backed home across WSL distros', async () => { + const service = new CodexHookService() + const started: string[] = [] + vi.spyOn(service, 'installForRuntimeHome').mockImplementation(async (_runtimeHome, target) => { + started.push(target?.wslDistro ?? '') + return null + }) + const home = 'D:\\wsl-home\\.local\\share\\orca\\codex-runtime-home\\home' + + await Promise.all([ + service.installForRuntimeHomeSerialized(home, { runtime: 'wsl', wslDistro: 'Ubuntu' }), + service.installForRuntimeHomeSerialized(home, { runtime: 'wsl', wslDistro: 'Debian' }) + ]) + + expect(started).toEqual(['Ubuntu', 'Debian']) + }) + + it('keeps case-distinct Linux runtime homes on separate queues', async () => { + const service = new CodexHookService() + const started: string[] = [] + vi.spyOn(service, 'installForRuntimeHome').mockImplementation(async (runtimeHomePath) => { + started.push(runtimeHomePath!) + return null + }) + const upper = + '\\\\wsl.localhost\\Ubuntu\\home\\Alice\\.local\\share\\orca\\codex-runtime-home\\home' + const lower = upper.replace('\\Alice\\', '\\alice\\') + + await Promise.all([ + service.installForRuntimeHomeSerialized(upper), + service.installForRuntimeHomeSerialized(lower) + ]) + + expect(started).toEqual([upper, lower]) + }) + + it('plans WSL hook files with Linux command and trust paths', () => { const runtimeHome = '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home' diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 0ea7837f154..4e58f947dee 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -1075,6 +1075,7 @@ export class CodexHookService { } private readonly wslReconciliationGeneration = new Map() + private readonly wslInstallsInFlight = new Map>() private supersedeWslReconciliation(runtimeHomePath: string | null | undefined): number { if (!runtimeHomePath) { @@ -1171,6 +1172,30 @@ export class CodexHookService { } } + installForRuntimeHomeSerialized( + runtimeHomePath: string | null | undefined, + target?: CodexWslRuntimeHookTarget + ): Promise { + if (!runtimeHomePath) { + return Promise.resolve(null) + } + const targetKey = target?.runtime === 'wsl' ? target.wslDistro?.trim().toLowerCase() : '' + const key = `${getWslReconciliationKey(runtimeHomePath)}\0${targetKey ?? ''}` + const active = this.wslInstallsInFlight.get(key) + if (active) { + return active + } + const install = this.installForRuntimeHome(runtimeHomePath, target) + this.wslInstallsInFlight.set(key, install) + const clear = (): void => { + if (this.wslInstallsInFlight.get(key) === install) { + this.wslInstallsInFlight.delete(key) + } + } + void install.then(clear, clear) + return install + } + refreshRuntimeUserHooksForRuntimeHome( runtimeHomePath: string | null | undefined, target?: CodexWslRuntimeHookTarget @@ -1486,7 +1511,13 @@ export class CodexHookService { options?.codexHomeDir?.replace(/\/$/, '') ?? `${remoteHome.replace(/\/$/, '')}/.codex` const remoteConfigPath = `${codexHomeBase}/hooks.json` const remoteTomlPath = `${codexHomeBase}/config.toml` - const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/codex-hook.sh` + // Redirected WSL homes must use the same script location and command shape + // as the runtime installer; two representations of one hooks.json race + // into stale trust keys. Plain SSH keeps its guest-home script contract. + const redirectedCodexHome = options?.codexHomeDir?.replace(/\/$/, '') + const remoteScriptPath = redirectedCodexHome + ? `${redirectedCodexHome}/.orca/agent-hooks/codex-hook.sh` + : `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/codex-hook.sh` try { const config = await readHooksJsonRemote(sftp, remoteConfigPath) if (!config) { @@ -1499,7 +1530,9 @@ export class CodexHookService { } } - const command = wrapPosixHookCommand(remoteScriptPath) + const command = redirectedCodexHome + ? wrapReadablePosixHookCommand(remoteScriptPath) + : wrapPosixHookCommand(remoteScriptPath) const nextHooks = { ...config.hooks } const managedEvents = new Set(CODEX_EVENTS) const isManagedCommand = createManagedCommandMatcher('codex-hook.sh') @@ -1523,11 +1556,13 @@ export class CodexHookService { const definition: HookDefinition = { hooks: [buildManagedCommandHook(command)] } - nextHooks[eventName] = [...cleaned, definition] + nextHooks[eventName] = redirectedCodexHome + ? [definition, ...cleaned] + : [...cleaned, definition] trustEntries.push({ sourcePath: remoteConfigPath, eventLabel: CODEX_EVENT_LABEL[eventName], - groupIndex: cleaned.length, + groupIndex: redirectedCodexHome ? 0 : cleaned.length, handlerIndex: 0, command, timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS diff --git a/src/main/codex/managed-home-shell-preflight.test.ts b/src/main/codex/managed-home-shell-preflight.test.ts index 8cc519d3c79..31a98a30b2f 100644 --- a/src/main/codex/managed-home-shell-preflight.test.ts +++ b/src/main/codex/managed-home-shell-preflight.test.ts @@ -2,10 +2,25 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' + +const { wslRealpathMock } = vi.hoisted(() => ({ + wslRealpathMock: vi.fn(async (path: string) => path) +})) + +vi.mock('node:fs/promises', () => ({ realpath: wslRealpathMock })) + import { prepareManagedCodexHomeBeforeShellLaunch, resolveManagedCodexShellPreflightHome } from './managed-home-shell-preflight' +import { + prepareManagedWslCodexHomeBeforeShellLaunch, + resolveManagedWslCodexShellPreflightTarget +} from './managed-wsl-home-shell-preflight' +import { + _internals as managedWslHomeRegistryInternals, + recordManagedWslCodexHome +} from './managed-wsl-codex-home-registry' const roots: string[] = [] @@ -16,6 +31,8 @@ function makeRoot(): string { } afterEach(() => { + wslRealpathMock.mockClear() + managedWslHomeRegistryInternals.clearRecordedManagedWslCodexHomes() for (const root of roots.splice(0)) { rmSync(root, { recursive: true, force: true }) } @@ -160,3 +177,123 @@ describe('managed Codex shell preflight', () => { ).toBeNull() }) }) + +describe('managed WSL Codex shell preflight', () => { + const home = '/home/jin/.local/share/orca/codex-runtime-home/home' + const runtimeHome = + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.local\\share\\orca\\codex-runtime-home\\home' + const env = { + CODEX_HOME: home, + ORCA_CODEX_HOME: home, + WSL_DISTRO_NAME: 'Ubuntu-24.04' + } + + it('targets the pane-selected managed home through its UNC twin', () => { + recordManagedWslCodexHome('Ubuntu-24.04', runtimeHome) + expect(resolveManagedWslCodexShellPreflightTarget(env)).toEqual({ + runtimeHomePath: runtimeHome, + wslDistro: 'Ubuntu-24.04' + }) + }) + + it('reconstructs a validated managed home when a restarted runtime has no record', () => { + expect(resolveManagedWslCodexShellPreflightTarget(env)).toEqual({ + runtimeHomePath: runtimeHome, + wslDistro: 'Ubuntu-24.04' + }) + }) + + it('installs once through the WSL runtime-home lane while hooks are enabled', async () => { + recordManagedWslCodexHome('Ubuntu-24.04', runtimeHome) + const status = { + agent: 'codex' as const, + state: 'installed' as const, + configPath: 'config.toml', + managedHooksPresent: true, + detail: null + } + const install = vi.fn(() => status) + + await expect( + prepareManagedWslCodexHomeBeforeShellLaunch({ env, hooksEnabled: true, install }) + ).resolves.toBe(status) + expect(install).toHaveBeenCalledExactlyOnceWith(runtimeHome, { + runtime: 'wsl', + wslDistro: 'Ubuntu-24.04' + }) + + await expect( + prepareManagedWslCodexHomeBeforeShellLaunch({ env, hooksEnabled: false, install }) + ).resolves.toBeNull() + expect(install).toHaveBeenCalledTimes(1) + }) + + it.each([ + [ + 'a user home', + { ...env, CODEX_HOME: '/home/jin/.codex', ORCA_CODEX_HOME: '/home/jin/.codex' } + ], + ['unequal routing markers', { ...env, ORCA_CODEX_HOME: `${home}-other` }], + [ + 'a parent traversal', + { + ...env, + CODEX_HOME: `/home/jin/../jin${home.slice('/home/jin'.length)}`, + ORCA_CODEX_HOME: `/home/jin/../jin${home.slice('/home/jin'.length)}` + } + ], + [ + 'a dot segment', + { + ...env, + CODEX_HOME: `/home/./jin${home.slice('/home/jin'.length)}`, + ORCA_CODEX_HOME: `/home/./jin${home.slice('/home/jin'.length)}` + } + ], + [ + 'a host path', + { ...env, CODEX_HOME: 'C:\\Users\\jin\\.codex', ORCA_CODEX_HOME: 'C:\\Users\\jin\\.codex' } + ], + ['a missing distro', { ...env, WSL_DISTRO_NAME: '' }], + ['a distro path escape', { ...env, WSL_DISTRO_NAME: 'Ubuntu\\..\\host' }], + [ + 'a repeated separator', + { + ...env, + CODEX_HOME: home.replace('/jin/', '/jin//'), + ORCA_CODEX_HOME: home.replace('/jin/', '/jin//') + } + ] + ])('rejects %s', (_label, candidate) => { + recordManagedWslCodexHome('Ubuntu-24.04', runtimeHome) + expect(resolveManagedWslCodexShellPreflightTarget(candidate)).toBeNull() + }) + + it('preserves a recorded runtime spelling for a managed account home', () => { + const directHome = '/home/jin/.local/share/orca/codex-accounts/account-1/home' + const directRuntimeHome = + '\\\\wsl$\\Ubuntu-24.04\\home\\jin\\.local\\share\\orca\\codex-accounts\\account-1\\home' + recordManagedWslCodexHome('Ubuntu-24.04', directRuntimeHome) + + expect( + resolveManagedWslCodexShellPreflightTarget({ + CODEX_HOME: directHome, + ORCA_CODEX_HOME: directHome, + WSL_DISTRO_NAME: 'ubuntu-24.04' + }) + ).toEqual({ runtimeHomePath: directRuntimeHome, wslDistro: 'ubuntu-24.04' }) + }) + + it('never authorizes a WSL system home even when it was offered by a PTY lane', () => { + const systemHome = '/home/jin/.codex' + recordManagedWslCodexHome('Ubuntu-24.04', '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.codex') + + expect( + resolveManagedWslCodexShellPreflightTarget({ + CODEX_HOME: systemHome, + ORCA_CODEX_HOME: systemHome, + WSL_DISTRO_NAME: 'Ubuntu-24.04' + }) + ).toBeNull() + }) +}) diff --git a/src/main/codex/managed-wsl-codex-home-registry.ts b/src/main/codex/managed-wsl-codex-home-registry.ts new file mode 100644 index 00000000000..3641f991d24 --- /dev/null +++ b/src/main/codex/managed-wsl-codex-home-registry.ts @@ -0,0 +1,115 @@ +import { win32 as pathWin32 } from 'node:path' +import { + foldWslUncPathCaseInsensitiveParts, + parseWslUncPath, + toWindowsWslPath +} from '../../shared/wsl-paths' + +type ManagedWslCodexHome = { + runtimeHomePath: string + linuxHomePath: string +} + +const managedHomesByDistro = new Map>() + +function distroKey(distro: string): string { + return distro.trim().toLowerCase() +} + +export function isAbsolutePosixPathWithoutDotSegments(value: string): boolean { + const segments = value.split('/').slice(1) + return ( + value.startsWith('/') && + !value.startsWith('//') && + !value.includes('\\') && + segments.every((segment) => segment && segment !== '.' && segment !== '..') + ) +} + +function isOrcaManagedWslCodexHome(linuxHomePath: string): boolean { + const segments = linuxHomePath.split('/').filter(Boolean) + const orcaIndex = segments.findIndex( + (segment, index) => + segment === 'orca' && segments[index - 1] === 'share' && segments[index - 2] === '.local' + ) + if (orcaIndex === -1) { + return false + } + const tail = segments.slice(orcaIndex + 1) + return ( + (tail.length === 2 && tail[0] === 'codex-runtime-home' && tail[1] === 'home') || + (tail.length === 3 && tail[0] === 'codex-accounts' && Boolean(tail[1]) && tail[2] === 'home') + ) +} + +function linuxHomeForRuntimePath(runtimeHomePath: string, distro: string): string | null { + const wsl = parseWslUncPath(runtimeHomePath) + if (wsl) { + return wsl.distro.toLowerCase() === distroKey(distro) ? wsl.linuxPath : null + } + const drive = runtimeHomePath.match(/^([A-Za-z]):[/\\](.*)$/) + return drive ? `/mnt/${drive[1].toLowerCase()}/${drive[2].replace(/\\/g, '/')}` : null +} + +export function recordManagedWslCodexHome(distro: string, runtimeHomePath: string): void { + const normalizedDistro = distro.trim() + const linuxHomePath = linuxHomeForRuntimePath(runtimeHomePath, normalizedDistro) + if ( + !normalizedDistro || + /[\\/\r\n]/.test(normalizedDistro) || + !linuxHomePath || + !isAbsolutePosixPathWithoutDotSegments(linuxHomePath) || + !isOrcaManagedWslCodexHome(linuxHomePath) + ) { + return + } + const homes = managedHomesByDistro.get(distroKey(normalizedDistro)) ?? new Map() + homes.set(linuxHomePath, { runtimeHomePath, linuxHomePath }) + managedHomesByDistro.set(distroKey(normalizedDistro), homes) +} + +export function resolveRecordedManagedWslCodexHome( + distro: string, + linuxHomePath: string +): string | null { + if ( + !distro.trim() || + /[\\/\r\n]/.test(distro) || + !isAbsolutePosixPathWithoutDotSegments(linuxHomePath) + ) { + return null + } + return managedHomesByDistro.get(distroKey(distro))?.get(linuxHomePath)?.runtimeHomePath ?? null +} + +export function resolveManagedWslCodexHome(distro: string, linuxHomePath: string): string | null { + if ( + !distro.trim() || + /[\\/\r\n]/.test(distro) || + !isAbsolutePosixPathWithoutDotSegments(linuxHomePath) || + !isOrcaManagedWslCodexHome(linuxHomePath) + ) { + return null + } + return ( + resolveRecordedManagedWslCodexHome(distro, linuxHomePath) ?? + toWindowsWslPath(linuxHomePath, distro) + ) +} + +export function wslRuntimeHomePathsEqual(left: string | undefined, right: string): boolean { + if (!left) { + return false + } + const leftWsl = foldWslUncPathCaseInsensitiveParts(left) + const rightWsl = foldWslUncPathCaseInsensitiveParts(right) + if (leftWsl || rightWsl) { + return leftWsl !== null && leftWsl === rightWsl + } + return pathWin32.normalize(left).toLowerCase() === pathWin32.normalize(right).toLowerCase() +} + +export const _internals = { + clearRecordedManagedWslCodexHomes: () => managedHomesByDistro.clear(), + isOrcaManagedWslCodexHome +} diff --git a/src/main/codex/managed-wsl-home-shell-preflight.ts b/src/main/codex/managed-wsl-home-shell-preflight.ts new file mode 100644 index 00000000000..854869ab05a --- /dev/null +++ b/src/main/codex/managed-wsl-home-shell-preflight.ts @@ -0,0 +1,75 @@ +import { realpath } from 'node:fs/promises' +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { withTimeout } from '../../shared/promise-timeout-fallback' +import type { CodexWslRuntimeHookTarget } from './codex-wsl-hook-install-plan' +import { codexHookService } from './hook-service' +import { + isAbsolutePosixPathWithoutDotSegments, + resolveManagedWslCodexHome, + wslRuntimeHomePathsEqual +} from './managed-wsl-codex-home-registry' + +type WslShellPreflightEnvironment = { + CODEX_HOME?: string + ORCA_CODEX_HOME?: string + WSL_DISTRO_NAME?: string +} + +const WSL_MANAGED_HOME_REALPATH_TIMEOUT_MS = 5_000 + +export type ManagedWslCodexShellPreflightTarget = { + runtimeHomePath: string + wslDistro: string +} + +export function resolveManagedWslCodexShellPreflightTarget( + env: WslShellPreflightEnvironment +): ManagedWslCodexShellPreflightTarget | null { + const codexHome = env.CODEX_HOME?.trim() + const orcaCodexHome = env.ORCA_CODEX_HOME?.trim() + const wslDistro = env.WSL_DISTRO_NAME?.trim() + if ( + !codexHome || + codexHome !== orcaCodexHome || + !wslDistro || + /[\\/\r\n]/.test(wslDistro) || + !isAbsolutePosixPathWithoutDotSegments(codexHome) + ) { + return null + } + const runtimeHomePath = resolveManagedWslCodexHome(wslDistro, codexHome) + return runtimeHomePath ? { runtimeHomePath, wslDistro } : null +} + +export async function prepareManagedWslCodexHomeBeforeShellLaunch(args: { + env: WslShellPreflightEnvironment + hooksEnabled: boolean + install?: ( + runtimeHomePath: string, + target: CodexWslRuntimeHookTarget + ) => AgentHookInstallStatus | Promise | null +}): Promise { + if (!args.hooksEnabled) { + return null + } + const target = resolveManagedWslCodexShellPreflightTarget(args.env) + if (!target) { + return null + } + const realHome = await withTimeout( + realpath(target.runtimeHomePath), + WSL_MANAGED_HOME_REALPATH_TIMEOUT_MS, + null + ) + if (!realHome || !wslRuntimeHomePathsEqual(realHome, target.runtimeHomePath)) { + return null + } + const install = + args.install ?? + ((home: string, hookTarget: CodexWslRuntimeHookTarget) => + codexHookService.installForRuntimeHomeSerialized(home, hookTarget)) + return await install(target.runtimeHomePath, { + runtime: 'wsl', + wslDistro: target.wslDistro + }) +} diff --git a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts index 18fbf7019f2..54ec3e7663c 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -7,6 +7,7 @@ import { LocalPtyProvider } from '../providers/local-pty-provider' import { __resetPersistedWindowsPathCacheForTests } from '../pty/windows-environment-path' import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-registry-reader' import { hasLiveClaudePtys, markClaudePtySpawned } from '../claude-accounts/live-pty-gate' +import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' import { registerPtyHandlers, buildPtyHostEnv, clearProviderPtyState } from './pty' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) @@ -57,6 +58,32 @@ describe('registerPtyHandlers', () => { const { handlers, mainWindow, spawnAndGetEnv, withBundledCli } = setupPtyIpcSuite() describe('spawn environment', () => { + it('passes the PTY-resolved Codex home to the WSL relay lane', () => { + const runtimeHome = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-runtime-home\\home' + const ensureForDistro = vi + .spyOn(wslHookRelayManager, 'ensureForDistro') + .mockImplementation(() => {}) + + try { + buildPtyHostEnv( + 'pty-wsl', + {}, + { + isPackaged: true, + userDataPath: '/tmp/orca-user-data', + selectedCodexHomePath: runtimeHome, + isWsl: true, + wslDistro: 'Ubuntu', + agentStatusHooksEnabled: true + } + ) + expect(ensureForDistro).toHaveBeenCalledExactlyOnceWith('Ubuntu', runtimeHome) + } finally { + ensureForDistro.mockRestore() + } + }) + it('refreshes the outer Windows PATH for a WSL spawn without forwarding it', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index 569b40052df..cee3bf5c50b 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -122,7 +122,7 @@ export function buildPtyHostEnv( if (opts.isWsl === true) { // Why: hook POSTs to 127.0.0.1 die inside WSL's NAT namespace; use the guest-resident relay's endpoint instead of the Windows one. const distro = opts.wslDistro ?? null - wslHookRelayManager.ensureForDistro(distro) + wslHookRelayManager.ensureForDistro(distro, opts.selectedCodexHomePath) const guestEndpoint = wslHookRelayManager.getGuestEndpointFilePath(distro) if (guestEndpoint) { baseEnv.ORCA_AGENT_HOOK_ENDPOINT = guestEndpoint diff --git a/src/main/pty/codex-preflight-profile-path-rewrite.test.ts b/src/main/pty/codex-preflight-profile-path-rewrite.test.ts index abf189de356..4649b5636a3 100644 --- a/src/main/pty/codex-preflight-profile-path-rewrite.test.ts +++ b/src/main/pty/codex-preflight-profile-path-rewrite.test.ts @@ -162,13 +162,13 @@ describe.skipIf(!bashAvailable)('Codex preflight under a profile-rewritten PATH' expect(readFileSync(fixture.codexMarker, 'utf-8').trim()).toBe('--alias-flag --version') }) - // Why: pins the defect itself, so a regression back to an unqualified name fails here. - it('would run the impostor if the preflight carried an unqualified command name', () => { + // Why: an unqualified preflight is rejected so profile PATH entries cannot hijack it. + it('skips an unqualified preflight value while still launching codex', () => { const fixture = buildFixture() launchCodexThroughRcfile(fixture, 'orca') - expect(existsSync(fixture.hijackMarker)).toBe(true) + expect(existsSync(fixture.hijackMarker)).toBe(false) expect(existsSync(fixture.intendedMarker)).toBe(false) expect(existsSync(fixture.codexMarker)).toBe(true) }) diff --git a/src/main/pty/codex-shell-launch-preflight.test.ts b/src/main/pty/codex-shell-launch-preflight.test.ts index 2aff785446b..be8d84537c9 100644 --- a/src/main/pty/codex-shell-launch-preflight.test.ts +++ b/src/main/pty/codex-shell-launch-preflight.test.ts @@ -170,6 +170,30 @@ describe.skipIf(process.platform === 'win32')('Codex shell launch preflight', () ) }) + it('does not trigger a preflight outside an Orca terminal', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-plain-shell-')) + roots.push(root) + const bin = join(root, 'bin') + const marker = join(root, 'preflight-ran') + mkdirSync(bin) + writeExecutable(join(bin, 'codex'), '#!/bin/sh\nprintf launched\n') + writeExecutable(join(bin, 'orca-test'), `#!/bin/sh\nprintf ran > ${JSON.stringify(marker)}\n`) + + const output = execFileSync( + '/bin/bash', + ['--noprofile', '--norc', '-c', `${getPosixCodexShellLaunchPreflight()}\ncodex`], + { + encoding: 'utf-8', + env: { + PATH: `${bin}${delimiter}${process.env.PATH ?? ''}` + } + } + ) + + expect(output.trim()).toBe('launched') + expect(existsSync(marker)).toBe(false) + }) + // Why a loop over it.each: an early `return` reported green on hosts without the // shell, so the zsh half silently never ran on Linux. skipIf reports it as a skip. for (const [shell, strict] of [ @@ -319,6 +343,24 @@ describe('Codex shell launch preflight command', () => { ).toBe(launcherPath) }) + it('carries the packaged Windows launcher for WSLENV path translation', () => { + const { userDataPath, resourcesPath } = makeCliRoot() + const launcherPath = join(resourcesPath, 'bin', 'orca.exe') + writeExecutable(launcherPath, '#!/bin/sh\nexit 0\n') + + expect( + resolveCodexShellLaunchPreflightCommand({ + hooksEnabled: true, + isPackaged: true, + isWsl: true, + managedHomePath: '/home/jin/.local/share/orca/codex-runtime-home/home', + userDataPath, + resourcesPath, + platform: 'win32' + }) + ).toBe(launcherPath) + }) + it('never returns an unqualified command name that a profile-rewritten PATH could hijack', () => { const { userDataPath, resourcesPath } = makeCliRoot() writeExecutable(join(resourcesPath, 'bin', 'orca'), '#!/bin/sh\nexit 0\n') @@ -340,16 +382,12 @@ describe('Codex shell launch preflight command', () => { it.each([ { label: 'the launcher file is missing', create: null }, - { label: 'the launcher is not executable', create: 0o644 }, { label: 'the launcher path is a directory', create: 'directory' as const } ])('skips the preflight when $label', (config) => { const { userDataPath, resourcesPath } = makeCliRoot() const launcherPath = join(resourcesPath, 'bin', 'orca') if (config.create === 'directory') { mkdirSync(launcherPath) - } else if (config.create !== null) { - writeFileSync(launcherPath, '#!/bin/sh\nexit 0\n') - chmodSync(launcherPath, config.create) } expect( @@ -364,6 +402,27 @@ describe('Codex shell launch preflight command', () => { ).toBeNull() }) + it.skipIf(process.platform === 'win32')( + 'skips the preflight when the launcher is not executable', + () => { + const { userDataPath, resourcesPath } = makeCliRoot() + const launcherPath = join(resourcesPath, 'bin', 'orca') + writeFileSync(launcherPath, '#!/bin/sh\nexit 0\n') + chmodSync(launcherPath, 0o644) + + expect( + resolveCodexShellLaunchPreflightCommand({ + hooksEnabled: true, + isPackaged: true, + managedHomePath: '/managed/home', + userDataPath, + resourcesPath, + platform: 'darwin' + }) + ).toBeNull() + } + ) + it('skips the preflight when the packaged build exposes no resources root', () => { const { userDataPath } = makeCliRoot() @@ -381,7 +440,7 @@ describe('Codex shell launch preflight command', () => { it.each([ { hooksEnabled: false, isWsl: false, managedHomePath: '/managed/home' }, - { hooksEnabled: true, isWsl: true, managedHomePath: '/managed/home' }, + { hooksEnabled: true, isWsl: true, managedHomePath: '/managed/home', isPackaged: false }, { hooksEnabled: true, isWsl: false, managedHomePath: null } ])('does not enable an unsupported preflight for %o', (options) => { const { userDataPath, resourcesPath } = makeCliRoot() @@ -390,7 +449,7 @@ describe('Codex shell launch preflight command', () => { expect( resolveCodexShellLaunchPreflightCommand({ ...options, - isPackaged: true, + isPackaged: options.isPackaged ?? true, userDataPath, resourcesPath, platform: 'darwin' diff --git a/src/main/pty/codex-shell-launch-preflight.ts b/src/main/pty/codex-shell-launch-preflight.ts index 4b2429f50f1..537933fcdba 100644 --- a/src/main/pty/codex-shell-launch-preflight.ts +++ b/src/main/pty/codex-shell-launch-preflight.ts @@ -29,7 +29,7 @@ export type CodexShellLaunchPreflightCommandOptions = { export function resolveCodexShellLaunchPreflightCommand( options: CodexShellLaunchPreflightCommandOptions ): string | null { - if (!options.hooksEnabled || options.isWsl || !options.managedHomePath) { + if (!options.hooksEnabled || !options.managedHomePath) { return null } const platform = options.platform ?? process.platform @@ -42,7 +42,14 @@ export function resolveCodexShellLaunchPreflightCommand( ...DEV_LAUNCHER_DIR, platform === 'win32' ? `${DEV_COMMAND_NAME}.cmd` : DEV_COMMAND_NAME ) - return candidate && isExecutableFileOnDisk(candidate, platform) ? candidate : null + if (!candidate || !isExecutableFileOnDisk(candidate, platform)) { + return null + } + if (!options.isWsl) { + return candidate + } + // Why: WSLENV /p translates the verified Windows launcher with the distro's configured automount root. + return platform === 'win32' && options.isPackaged ? candidate : null } function isExecutableFileOnDisk(path: string, platform: NodeJS.Platform): boolean { @@ -65,7 +72,7 @@ export function getPosixCodexShellLaunchPreflight(): string { # Why || : twice — zsh alone aborts inside the substitution, but every shell's # assignment adopts its exit status, so an absent codex trips set -e in bash too. __orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" -if [[ -n "\${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "\${__orca_codex_binary:-}" && -x "\${__orca_codex_binary}" ]]; then +if [[ -n "\${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "\${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "\${__orca_codex_binary:-}" && -x "\${__orca_codex_binary}" ]]; then # Why the function reserved word: it suppresses alias expansion of the name, # which otherwise rewrites this header at parse time and aborts the whole file. function codex { @@ -78,7 +85,7 @@ unset __orca_codex_binary } export function getFishCodexShellLaunchPreflight(): string { - return `if test -n "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file + return `if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file function codex command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true command codex $argv diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index 133844dec78..cc79e2bb054 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -85,7 +85,7 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV).toContain('ORCA_TERMINAL_HANDLE/u') expect(env.WSLENV).toContain('ORCA_USER_DATA_PATH/p') expect(env.WSLENV).toContain('ORCA_CLI_COMMAND/u') - expect(env.WSLENV).not.toContain('ORCA_CODEX_LAUNCH_PREFLIGHT') + expect(env.WSLENV).toContain('ORCA_CODEX_LAUNCH_PREFLIGHT/p') expect(env.WSLENV).toContain('ORCA_OMP_STATUS_EXTENSION/p') expect(env.WSLENV).not.toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION') expect(env.WSLENV).toContain('ORCA_PANE_KEY/u') diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index 0fe855ba3b0..262df20b3b2 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -77,6 +77,7 @@ export function addOrcaWslInteropEnv(env: Record): void { // and it cannot derive the hash segment from ORCA_USER_DATA_PATH alone. 'ORCA_SHELL_READY_ROOT/p', 'ORCA_CLI_COMMAND/u', + 'ORCA_CODEX_LAUNCH_PREFLIGHT/p', 'ORCA_PANE_KEY/u', 'ORCA_TAB_ID/u', 'ORCA_WORKTREE_ID/u', diff --git a/src/main/runtime/rpc/methods/agent-hooks.test.ts b/src/main/runtime/rpc/methods/agent-hooks.test.ts new file mode 100644 index 00000000000..5781045a1f7 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-hooks.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { isStreamingMethod, type RpcContext } from '../core' + +const { installForRuntimeHomeSerializedMock, realpathMock } = vi.hoisted(() => ({ + installForRuntimeHomeSerializedMock: vi.fn(), + realpathMock: vi.fn() +})) + +vi.mock('../../../codex/hook-service', () => ({ + codexHookService: { installForRuntimeHomeSerialized: installForRuntimeHomeSerializedMock } +})) +vi.mock('node:fs/promises', () => ({ realpath: realpathMock })) + +import { AGENT_HOOK_METHODS } from './agent-hooks' +import { + _internals as managedWslHomeRegistryInternals, + recordManagedWslCodexHome +} from '../../../codex/managed-wsl-codex-home-registry' + +const LINUX_HOME = '/home/jin/.local/share/orca/codex-runtime-home/home' +const RUNTIME_HOME = + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.local\\share\\orca\\codex-runtime-home\\home' + +function prepareMethod() { + const method = AGENT_HOOK_METHODS.find( + (candidate) => candidate.name === 'agentHooks.prepareCodexForWslPane' + ) + if (!method || isStreamingMethod(method)) { + throw new Error('Missing agentHooks.prepareCodexForWslPane request method') + } + return method +} + +function runtimeWithSettings(enabled = true, disabledTuiAgents: string[] = []): OrcaRuntimeService { + return { + getClientSettings: vi.fn(() => ({ + agentStatusHooksEnabled: enabled, + disabledTuiAgents + })) + } as unknown as OrcaRuntimeService +} + +describe('agent hook RPC methods', () => { + beforeEach(() => { + installForRuntimeHomeSerializedMock.mockReset() + realpathMock.mockReset() + realpathMock.mockImplementation(async (path: string) => path) + managedWslHomeRegistryInternals.clearRecordedManagedWslCodexHomes() + recordManagedWslCodexHome('Ubuntu-24.04', RUNTIME_HOME) + }) + + it('installs the pane-selected WSL home once and returns its status', async () => { + const status = { agent: 'codex', state: 'installed' } + installForRuntimeHomeSerializedMock.mockResolvedValue(status) + const method = prepareMethod() + const params = method.params!.parse({ + codexHome: LINUX_HOME, + orcaCodexHome: LINUX_HOME, + wslDistro: 'Ubuntu-24.04' + }) + + await expect(method.handler(params, { runtime: runtimeWithSettings() })).resolves.toBe(status) + expect(installForRuntimeHomeSerializedMock).toHaveBeenCalledExactlyOnceWith(RUNTIME_HOME, { + runtime: 'wsl', + wslDistro: 'Ubuntu-24.04' + }) + }) + + it.each([ + [false, []], + [true, ['codex']] + ])('does not install when hooks are disabled (%s, %j)', async (enabled, disabledTuiAgents) => { + const method = prepareMethod() + const params = method.params!.parse({ + codexHome: '/home/jin/.local/share/orca/codex-runtime-home/home', + orcaCodexHome: '/home/jin/.local/share/orca/codex-runtime-home/home', + wslDistro: 'Ubuntu-24.04' + }) + + await expect( + method.handler(params, { runtime: runtimeWithSettings(enabled, disabledTuiAgents) }) + ).resolves.toBeNull() + expect(installForRuntimeHomeSerializedMock).not.toHaveBeenCalled() + }) + + it.each(['runtime', 'mobile'] as const)('rejects non-local %s callers', async (clientKind) => { + const method = prepareMethod() + const params = method.params!.parse({ + codexHome: '/home/jin/.local/share/orca/codex-runtime-home/home', + orcaCodexHome: '/home/jin/.local/share/orca/codex-runtime-home/home', + wslDistro: 'Ubuntu-24.04' + }) + + await expect( + method.handler(params, { + runtime: runtimeWithSettings(), + clientKind + } as RpcContext) + ).rejects.toThrow(/only available to the local Orca CLI/) + expect(installForRuntimeHomeSerializedMock).not.toHaveBeenCalled() + }) + + it('propagates an attempted installer failure', async () => { + installForRuntimeHomeSerializedMock.mockRejectedValue(new Error('install failed')) + const method = prepareMethod() + const params = method.params!.parse({ + codexHome: '/home/jin/.local/share/orca/codex-runtime-home/home', + orcaCodexHome: '/home/jin/.local/share/orca/codex-runtime-home/home', + wslDistro: 'Ubuntu-24.04' + }) + + await expect(method.handler(params, { runtime: runtimeWithSettings() })).rejects.toThrow( + 'install failed' + ) + expect(installForRuntimeHomeSerializedMock).toHaveBeenCalledOnce() + }) + + it('rejects a managed-looking home that resolves through a symlink', async () => { + realpathMock.mockResolvedValue( + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\outside-managed-home' + ) + const method = prepareMethod() + const params = method.params!.parse({ + codexHome: LINUX_HOME, + orcaCodexHome: LINUX_HOME, + wslDistro: 'Ubuntu-24.04' + }) + + await expect(method.handler(params, { runtime: runtimeWithSettings() })).resolves.toBeNull() + expect(installForRuntimeHomeSerializedMock).not.toHaveBeenCalled() + }) + + it('rejects malformed distro names at the RPC schema', () => { + const method = prepareMethod() + + expect(() => + method.params!.parse({ + codexHome: '/home/jin/.local/share/orca/codex-runtime-home/home', + orcaCodexHome: '/home/jin/.local/share/orca/codex-runtime-home/home', + wslDistro: 'Ubuntu\\..\\host' + }) + ).toThrow() + }) +}) diff --git a/src/main/runtime/rpc/methods/agent-hooks.ts b/src/main/runtime/rpc/methods/agent-hooks.ts new file mode 100644 index 00000000000..b26b117bd32 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-hooks.ts @@ -0,0 +1,38 @@ +import { z } from 'zod' +import { prepareManagedWslCodexHomeBeforeShellLaunch } from '../../../codex/managed-wsl-home-shell-preflight' +import { defineMethod, type RpcMethod } from '../core' + +const PrepareCodexForWslPaneParams = z + .object({ + codexHome: z.string().max(4_096), + orcaCodexHome: z.string().max(4_096), + wslDistro: z + .string() + .trim() + .min(1) + .max(255) + .regex(/^[^\\/\r\n]+$/) + }) + .strict() + +export const AGENT_HOOK_METHODS: readonly RpcMethod[] = [ + defineMethod({ + name: 'agentHooks.prepareCodexForWslPane', + params: PrepareCodexForWslPaneParams, + handler: async (params, { runtime, clientKind }) => { + if (clientKind !== undefined) { + throw new Error('Codex hook preparation is only available to the local Orca CLI.') + } + const settings = runtime.getClientSettings() + return await prepareManagedWslCodexHomeBeforeShellLaunch({ + env: { + CODEX_HOME: params.codexHome, + ORCA_CODEX_HOME: params.orcaCodexHome, + WSL_DISTRO_NAME: params.wslDistro + }, + hooksEnabled: + settings.agentStatusHooksEnabled && !settings.disabledTuiAgents.includes('codex') + }) + } + }) +] diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 94b5f236562..d919a62c289 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -43,12 +43,14 @@ import { PAIRING_METHODS } from './pairing' import { UPDATER_METHODS } from './updater' import { AGENT_SESSION_METHODS } from './agent-session' import { ARTIFACT_METHODS } from './artifacts' +import { AGENT_HOOK_METHODS } from './agent-hooks' // Why: a flat manifest keeps registration order explicit and provides one // grep-point for "what methods does the RPC server expose?" — useful when // auditing the security boundary or wiring new CLI commands. export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...STATUS_METHODS, + ...AGENT_HOOK_METHODS, ...AI_VAULT_METHODS, ...ARTIFACT_METHODS, ...AUTOMATION_METHODS, From 61c8f85d52df27503ca7add3a248537190ec0e19 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:28:52 -0700 Subject: [PATCH 063/152] Always enable Tasks button and provider shortcuts (#16838) * Always enable Tasks button and provider shortcuts Allows the task page to show an empty state explanation when no git repos are available, instead of disabling the button entirely. * Make task provider shortcuts keyboard-accessible sibling buttons Convert task provider shortcuts from non-semantic spans to proper button elements and position them as siblings of the Tasks button rather than children. This keeps them in the keyboard tab order while using opacity instead of display for visibility, ensuring they remain discoverable by keyboard navigation alongside the main Tasks button. --- .../components/sidebar/SidebarNav.test.tsx | 29 +++--- .../sidebar/SidebarTaskNavButton.tsx | 92 ++++++++----------- 2 files changed, 54 insertions(+), 67 deletions(-) diff --git a/src/renderer/src/components/sidebar/SidebarNav.test.tsx b/src/renderer/src/components/sidebar/SidebarNav.test.tsx index ac4208547a7..19c93a2d1c4 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.test.tsx @@ -464,22 +464,27 @@ describe('SidebarNav', () => { expect(searchButton?.querySelector('kbd')).toBeNull() }) - it('hides task source shortcuts until the Tasks row is hovered or focused', async () => { + it('keeps task source shortcuts keyboard-reachable and revealed on Tasks row hover or focus', async () => { const container = await renderSidebarNav() const tasksButton = getButtonByText(container, 'Tasks') - const shortcuts = tasksButton.querySelector('[aria-label="Open GitHub tasks"]')?.parentElement + const githubShortcut = tasksButton.parentElement?.querySelector( + 'button[aria-label="Open GitHub tasks"]' + ) + expect(githubShortcut).not.toBeNull() + expect(githubShortcut?.tabIndex).toBe(0) + expect(tasksButton.contains(githubShortcut ?? null)).toBe(false) - expect(shortcuts?.className).toContain('hidden') - expect(shortcuts?.className).toContain('group-hover:flex') - expect(shortcuts?.className).toContain('group-focus-within:flex') + const shortcuts = githubShortcut?.parentElement + expect(shortcuts?.className).toContain('can-hover:opacity-0') + expect(shortcuts?.className).toContain('can-hover:group-hover:opacity-100') + expect(shortcuts?.className).toContain('can-hover:group-focus-within:opacity-100') }) it('hides available Tasks from its sidebar context menu', async () => { const container = await renderSidebarNav() const tasksButton = getButtonByText(container, 'Tasks') - expect(tasksButton.getAttribute('aria-disabled')).toBe('false') const tasksMenu = tasksButton.closest('[data-testid="context-menu"]') expect(tasksMenu).not.toBeNull() @@ -488,18 +493,20 @@ describe('SidebarNav', () => { expect(mocks.updateSettings).toHaveBeenCalledWith({ showTasksButton: false }) }) - it('keeps unavailable Tasks context-menu-capable while left click remains inert', async () => { + it('keeps Tasks enabled with no git repos so the page can explain the empty state', async () => { setSidebarState({ repos: [folderRepo()] }) const container = await renderSidebarNav() const tasksButton = getButtonByText(container, 'Tasks') - expect(tasksButton.getAttribute('aria-disabled')).toBe('true') + expect(tasksButton.getAttribute('aria-disabled')).toBeNull() expect(tasksButton.disabled).toBe(false) - expect(tasksButton.querySelectorAll('[role="button"]')).toHaveLength(0) - expect(tasksButton.querySelector('[aria-label="Open GitHub tasks"]')).toBeNull() + expect(tasksButton.className).not.toContain('opacity-50') + expect( + tasksButton.parentElement?.querySelector('button[aria-label="Open GitHub tasks"]') + ).not.toBeNull() await clickButton(tasksButton) - expect(mocks.openTaskPage).not.toHaveBeenCalled() + expect(mocks.openTaskPage).toHaveBeenCalled() const tasksMenu = tasksButton.closest('[data-testid="context-menu"]') expect(tasksMenu).not.toBeNull() diff --git a/src/renderer/src/components/sidebar/SidebarTaskNavButton.tsx b/src/renderer/src/components/sidebar/SidebarTaskNavButton.tsx index 7a29ba2eb12..426d80de67f 100644 --- a/src/renderer/src/components/sidebar/SidebarTaskNavButton.tsx +++ b/src/renderer/src/components/sidebar/SidebarTaskNavButton.tsx @@ -34,36 +34,23 @@ function HideTaskSidebarMenu({ onHide }: { onHide: () => void }): React.JSX.Elem } function TaskProviderShortcut({ - canBrowseTasks, label, onOpen, children }: { - canBrowseTasks: boolean label: string onOpen: () => void children: React.ReactNode }): React.JSX.Element { return ( - { - e.stopPropagation() - if (!canBrowseTasks) { - return - } - onOpen() - }} - className={cn( - 'rounded p-0.5 text-muted-foreground/70', - canBrowseTasks ? 'transition-colors hover:text-foreground' : 'cursor-default' - )} - aria-label={canBrowseTasks ? label : undefined} - aria-hidden={canBrowseTasks ? undefined : true} + ) } @@ -73,7 +60,6 @@ export function SidebarTaskNavButton(): React.JSX.Element | null { const activeView = useAppStore((s) => s.activeView) const repos = useAppStore((s) => s.repos) const repoMap = useRepoMap() - const canBrowseTasks = repos.some((repo) => isGitRepoKind(repo)) const showTasksButton = useAppStore((s) => s.settings?.showTasksButton !== false) const rawVisibleTaskProviders = useAppStore((s) => s.settings?.visibleTaskProviders) const defaultTaskSource = useAppStore((s) => s.settings?.defaultTaskSource ?? 'github') @@ -134,7 +120,7 @@ export function SidebarTaskNavButton(): React.JSX.Element | null { ]) const handlePrefetch = React.useCallback(() => { - if (!canBrowseTasks || resolvedDefaultTaskSource !== 'github') { + if (resolvedDefaultTaskSource !== 'github') { return } const activeRepo = activeRepoId ? (repoMap.get(activeRepoId) ?? null) : null @@ -150,7 +136,6 @@ export function SidebarTaskNavButton(): React.JSX.Element | null { } }, [ activeRepoId, - canBrowseTasks, defaultTaskViewPreset, prefetchWorkItems, repoMap, @@ -171,38 +156,36 @@ export function SidebarTaskNavButton(): React.JSX.Element | null { return ( - + {visibleTaskProviders.includes('github') ? ( ) : null} - + From 3179a5761f3262e676197086067d6b6e894e0d45 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:32:09 -0700 Subject: [PATCH 064/152] Allow select-all shortcuts in quick command dialogs (#16851) Add isSelectAllShortcut() utility to detect Cmd+A (Mac) or Ctrl+A (Linux/Windows). Use it to preserve native select-all behavior in editable fields instead of being intercepted by dialog keyboard handlers. --- ...tab-bar-quick-command-search-input.test.ts | 34 +++++++++++--- .../use-tab-bar-quick-command-search-input.ts | 5 +++ .../TerminalQuickCommandDialog.test.tsx | 44 ++++++++++++++++++- .../TerminalQuickCommandDialog.tsx | 11 +++++ src/renderer/src/lib/editable-target.ts | 14 ++++++ 5 files changed, 102 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.test.ts b/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.test.ts index 25058e3f44a..1883cadde2c 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.test.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.test.ts @@ -30,18 +30,23 @@ function keyEvent(init: { key: string keyCode: number ctrlKey?: boolean + metaKey?: boolean + shiftKey?: boolean + altKey?: boolean isComposing?: boolean + preventDefault?: () => void + stopPropagation?: () => void }): never { return { key: init.key, keyCode: init.keyCode, ctrlKey: init.ctrlKey ?? false, - altKey: false, - metaKey: false, - shiftKey: false, + altKey: init.altKey ?? false, + metaKey: init.metaKey ?? false, + shiftKey: init.shiftKey ?? false, nativeEvent: { isComposing: init.isComposing ?? false }, - preventDefault: () => {}, - stopPropagation: () => {} + preventDefault: init.preventDefault ?? (() => {}), + stopPropagation: init.stopPropagation ?? (() => {}) } as never } @@ -67,6 +72,25 @@ describe('useTabBarQuickCommandSearchInput IME Enter ownership', () => { expect(onCommandValueChange).toHaveBeenCalledWith(entries[1].key) }) + it('lets the native select-all behavior run in the search input', () => { + const { result } = setup() + const stopPropagation = vi.fn() + const preventDefault = vi.fn() + + result.result.current.onKeyDown( + keyEvent({ + key: 'a', + keyCode: 65, + ctrlKey: true, + stopPropagation, + preventDefault + }) + ) + + expect(stopPropagation).toHaveBeenCalledOnce() + expect(preventDefault).not.toHaveBeenCalled() + }) + it('does not run the command on the bare redispatch after a confirm', () => { const { onRun, result } = setup() result.result.current.onCompositionStart() diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.ts b/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.ts index cd10dab214a..e0beabace4f 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.ts @@ -1,6 +1,7 @@ import { useCallback, type KeyboardEvent, type RefObject } from 'react' import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event' +import { isSelectAllShortcut } from '@/lib/editable-target' type SearchInputOptions = { commandListRef: RefObject @@ -33,6 +34,10 @@ export function useTabBarQuickCommandSearchInput({ if (imeEnter.ownsKeyDown(event)) { return } + if (isSelectAllShortcut(event)) { + event.stopPropagation() + return + } if (event.key === 'Enter' && selectedCommand) { event.preventDefault() event.stopPropagation() diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx index e25152d0c32..a23a3367b12 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx @@ -2,7 +2,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types' import { TerminalQuickCommandDialog } from './TerminalQuickCommandDialog' @@ -43,6 +43,10 @@ function findAnimatedRowContaining(text: string): HTMLElement { } describe('TerminalQuickCommandDialog animation structure', () => { + beforeEach(() => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + }) + afterEach(async () => { await act(async () => { for (const root of mountedRoots.splice(0)) { @@ -50,6 +54,44 @@ describe('TerminalQuickCommandDialog animation structure', () => { } }) document.body.innerHTML = '' + vi.unstubAllGlobals() + }) + + it('selects all text in editable fields with Cmd+A', async () => { + await renderDialog({ + id: 'qc-select-all', + label: 'Start dev server', + action: 'terminal-command', + command: 'npm run dev', + appendEnter: true, + scope: { type: 'global' } + }) + + const fields = [ + document.body.querySelector('input'), + document.body.querySelector('textarea[aria-label="Command"]') + ] + + for (const field of fields) { + expect(field).not.toBeNull() + if (!field) { + continue + } + field.setSelectionRange(field.value.length, field.value.length) + await act(async () => { + field.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'a', + code: 'KeyA', + metaKey: true, + bubbles: true, + cancelable: true + }) + ) + }) + + expect([field.selectionStart, field.selectionEnd]).toEqual([0, field.value.length]) + } }) it('keeps agent-only fields mounted as collapsed animated rows in terminal mode', async () => { diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx index 784469d779a..c0d5a26c2e1 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx @@ -22,6 +22,7 @@ import { import { Label } from '@/components/ui/label' import { getAgentCatalog } from '@/lib/agent-catalog' import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' +import { isSelectAllShortcut } from '@/lib/editable-target' import { TerminalQuickCommandActionToggle } from './TerminalQuickCommandActionToggle' import { TerminalQuickCommandAdvancedSection } from './TerminalQuickCommandAdvancedSection' import { TerminalQuickCommandContentSection } from './TerminalQuickCommandContentSection' @@ -196,6 +197,16 @@ export function TerminalQuickCommandDialog({
{ + if ( + isSelectAllShortcut(event) && + (event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement) + ) { + event.preventDefault() + event.stopPropagation() + event.target.select() + return + } if (isScreenSubmitShortcut(event) && canSave) { event.preventDefault() saveDraft() diff --git a/src/renderer/src/lib/editable-target.ts b/src/renderer/src/lib/editable-target.ts index b55a1e1a54a..24e4eeaf11b 100644 --- a/src/renderer/src/lib/editable-target.ts +++ b/src/renderer/src/lib/editable-target.ts @@ -1,3 +1,5 @@ +import { getShortcutPlatform } from './shortcut-platform' + // Why: shared across global keyboard listeners (App-level shortcuts and the // onboarding flow) so an in-progress text edit never gets hijacked by a // capture-phase keydown handler. @@ -21,3 +23,15 @@ export function isEditableTarget(target: EventTarget | null): boolean { null ) } + +export function isSelectAllShortcut( + event: Pick +): boolean { + if (event.key.toLowerCase() !== 'a' || event.altKey || event.shiftKey) { + return false + } + const platform = getShortcutPlatform() + return platform === 'darwin' + ? Boolean(event.metaKey) && !event.ctrlKey + : Boolean(event.ctrlKey) && !event.metaKey +} From 58232cd7974620dbe7347f8a93291e8d5a0ad937 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:41:01 -0700 Subject: [PATCH 065/152] Allow drag to reorder tabs on floating workspace (#16828) * Enable drag-to-reorder for floating workspace tabs - Wrap tab bar with FloatingWorkspaceTabDragContext to reuse workspace tab-drag-split logic and gesture model - Disable sensors while panel is closed to avoid DndContext conflicts with main workspace - Extract isFloatingTerminalDragTarget to separate module for clarity - Add tests for reorder behavior and titlebar drag-target detection * Add client-hosted row support to floating panel drag-to-reorder - Include `[data-client-hosted-browser-row-id]` in no-drag selectors alongside other tab types - Broaden type check from HTMLElement to Element for better SVG support - Add test coverage for client-hosted rows and SVG icon interactions * Improve floating terminal drag target detection with Element safety chec Handle undefined Element in non-DOM contexts by explicitly checking for its existence before type-checking. Clarify the logic by replacing double negatives with explicit null comparison, making the intent clearer and the code more resilient. --- ...oatingTerminalPanel.tab-lifecycle.test.tsx | 26 +++ .../FloatingTerminalPanel.tsx | 12 +- .../FloatingWorkspaceTabDragContext.tsx | 49 +++++ .../floating-terminal-titlebar-drag-target.ts | 13 ++ .../floating-workspace-tab-reorder.test.ts | 204 ++++++++++++++++++ 5 files changed, 296 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/components/floating-terminal/FloatingWorkspaceTabDragContext.tsx create mode 100644 src/renderer/src/components/floating-terminal/floating-terminal-titlebar-drag-target.ts create mode 100644 src/renderer/src/components/floating-terminal/floating-workspace-tab-reorder.test.ts diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx index 2bb123cc19f..b67dba62e06 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx @@ -499,3 +499,29 @@ describe('FloatingTerminalPanel close behavior', () => { expect(mocks.closeTab).not.toHaveBeenCalledWith('tab-c') }) }) + +describe('FloatingTerminalPanel tab drag wiring', () => { + beforeEach(setupFloatingTerminalPanelTest) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('hosts the tab strip in a drag context so floating tabs can be reordered', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })]) + + const element = await renderPanel(true) + const dragContext = findByTypeName(element, 'FloatingWorkspaceTabDragContext') + + expect(dragContext.props.enabled).toBe(true) + expect(findByTypeName(dragContext.props.children, 'TabBar')).toBeDefined() + }) + + it('leaves the drag context inactive while the closed panel stays mounted', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + + const element = await renderPanel(false) + + expect(findByTypeName(element, 'FloatingWorkspaceTabDragContext').props.enabled).toBe(false) + }) +}) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index ca6b25f6e07..94bdc23151a 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -90,6 +90,8 @@ import type { Tab } from '../../../../shared/tab-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { resolveUnifiedTabLabel } from '../../../../shared/tab-title-resolution' import { FloatingBrowserSlot } from './FloatingBrowserSlot' +import { FloatingWorkspaceTabDragContext } from './FloatingWorkspaceTabDragContext' +import { isFloatingTerminalDragTarget } from './floating-terminal-titlebar-drag-target' import { FloatingTerminalOrchestrationDialog } from './FloatingTerminalOrchestrationDialog' import { FloatingTerminalResizeHandles } from './FloatingTerminalResizeHandles' import { FloatingTerminalWindowControls } from './FloatingTerminalWindowControls' @@ -153,8 +155,6 @@ type FloatingPanelShortcutResolution = | { kind: 'index'; index: number } | { kind: 'chrome'; action: KeybindingActionId } -const FLOATING_TERMINAL_NO_DRAG_SELECTOR = - 'button,input,textarea,select,[role="menuitem"],[data-testid="sortable-tab"],[data-floating-terminal-no-drag]' const FLOATING_TERMINAL_SHORTCUT_SURFACE_SELECTOR = '[data-floating-terminal-shortcut-surface]' type FloatingTerminalPanelBoundsState = { @@ -163,10 +163,6 @@ type FloatingTerminalPanelBoundsState = { source: FloatingTerminalPanelBoundsSource } -function isFloatingTerminalDragTarget(target: EventTarget): boolean { - return !(target instanceof HTMLElement && target.closest(FLOATING_TERMINAL_NO_DRAG_SELECTOR)) -} - function readInitialPanelBounds(): FloatingTerminalPanelBoundsState { const defaultCommittedBounds = getDefaultFloatingTerminalCommittedBounds() const defaultRenderedBounds = getDefaultFloatingTerminalBounds() @@ -1825,7 +1821,7 @@ export function FloatingTerminalPanel({ onPointerCancel={handleDragEnd} onDoubleClick={handleTitlebarDoubleClick} > -
+ -
+ +}): React.JSX.Element { + const dragSplit = useTabDragSplit({ worktreeId: FLOATING_TERMINAL_WORKTREE_ID, enabled }) + + return ( + + +
+ {/* Why cloneElement over a render prop: the tab bar stays a plain child element, which keeps the panel's tree shallow-walkable. */} + {cloneElement(children, { hoveredTabInsertion: dragSplit.hoveredTabInsertion })} +
+ {/* Why: the source tab stays anchored in the overflow-hidden strip; this ghost is what follows the cursor. */} + + {dragSplit.activeDrag ? : null} + +
+
+ ) +} diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-titlebar-drag-target.ts b/src/renderer/src/components/floating-terminal/floating-terminal-titlebar-drag-target.ts new file mode 100644 index 00000000000..371dea745d5 --- /dev/null +++ b/src/renderer/src/components/floating-terminal/floating-terminal-titlebar-drag-target.ts @@ -0,0 +1,13 @@ +/** Interactive chrome living inside the draggable titlebar. `[data-tab-id]` and the + * client-hosted row marker cover every tab kind: a press + * on a tab starts a dnd-kit reorder, so it must never also move the panel. */ +const FLOATING_TERMINAL_NO_DRAG_SELECTOR = + 'button,input,textarea,select,[role="menuitem"],[data-tab-id],[data-client-hosted-browser-row-id],[data-floating-terminal-no-drag]' + +export function isFloatingTerminalDragTarget(target: EventTarget): boolean { + if (typeof Element === 'undefined' || !(target instanceof Element)) { + return true + } + + return target.closest(FLOATING_TERMINAL_NO_DRAG_SELECTOR) === null +} diff --git a/src/renderer/src/components/floating-terminal/floating-workspace-tab-reorder.test.ts b/src/renderer/src/components/floating-terminal/floating-workspace-tab-reorder.test.ts new file mode 100644 index 00000000000..f52e08fdfbc --- /dev/null +++ b/src/renderer/src/components/floating-terminal/floating-workspace-tab-reorder.test.ts @@ -0,0 +1,204 @@ +/** + * @vitest-environment happy-dom + */ +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import type { Tab, TabGroup } from '../../../../shared/tab-types' +import { useAppStore } from '../../store' +import { useTabDragSplit, type TabDragItemData } from '../tab-group/useTabDragSplit' +import { isFloatingTerminalDragTarget } from './floating-terminal-titlebar-drag-target' + +vi.mock('../browser-pane/host-guest/webview-registry', () => ({ + acquireWebviewsDragPassthrough: vi.fn(() => vi.fn()) +})) + +vi.mock('../../runtime/web-runtime-session', () => ({ + isWebRuntimeSessionActive: vi.fn(() => false), + moveWebRuntimeSessionTab: vi.fn() +})) + +const GROUP_ID = 'floating-group' +const mounted: { container: HTMLDivElement; root: Root }[] = [] + +function makeTab(id: string, sortOrder: number): Tab { + return { + id, + groupId: GROUP_ID, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'terminal', + entityId: `term-${id}`, + label: id, + customLabel: null, + color: null, + sortOrder, + createdAt: sortOrder + } +} + +function makeDragData(unifiedTabId: string): TabDragItemData { + return { + kind: 'tab', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + groupId: GROUP_ID, + unifiedTabId, + visibleTabId: `term-${unifiedTabId}`, + tabType: 'terminal', + label: unifiedTabId + } +} + +function makeDropEvent( + activeData: TabDragItemData, + overData: TabDragItemData | null, + pointerX: number +) { + return { + active: { data: { current: activeData }, rect: { current: { initial: null } } }, + over: overData + ? { + id: overData.visibleTabId, + data: { current: overData }, + // Tabs sit in a 100px-wide strip slot starting at x=200; midpoint is 250. + rect: { left: 200, top: 0, width: 100, height: 32 } + } + : null, + delta: { x: 0, y: 0 }, + activatorEvent: { clientX: pointerX, clientY: 16 } + } +} + +function renderFloatingDragHook(): ReturnType { + let result: ReturnType | null = null + function Probe(): null { + result = useTabDragSplit({ worktreeId: FLOATING_TERMINAL_WORKTREE_ID, enabled: true }) + return null + } + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + act(() => root.render(createElement(Probe))) + mounted.push({ container, root }) + if (!result) { + throw new Error('useTabDragSplit did not render') + } + return result +} + +function dropTab(from: string, over: string, pointerX: number): void { + const drag = renderFloatingDragHook() + const activeData = makeDragData(from) + act(() => { + drag.onDragStart( + makeDropEvent(activeData, null, pointerX) as unknown as Parameters[0] + ) + }) + act(() => { + drag.onDragEnd( + makeDropEvent(activeData, makeDragData(over), pointerX) as unknown as Parameters< + typeof drag.onDragEnd + >[0] + ) + }) +} + +function floatingTabOrder(): string[] { + const groups: TabGroup[] = + useAppStore.getState().groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [] + return groups.find((group) => group.id === GROUP_ID)?.tabOrder ?? [] +} + +beforeEach(() => { + useAppStore.setState({ + // The floating workspace is never the active worktree; its tabs still reorder. + activeWorktreeId: 'wt-main', + activeGroupIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: GROUP_ID }, + groupsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { + id: GROUP_ID, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + activeTabId: 'tab-a', + tabOrder: ['tab-a', 'tab-b', 'tab-c'] + } + ] + }, + unifiedTabsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + makeTab('tab-a', 0), + makeTab('tab-b', 1), + makeTab('tab-c', 2) + ] + }, + layoutByWorktree: {} + }) +}) + +afterEach(() => { + for (const { container, root } of mounted.splice(0)) { + act(() => root.unmount()) + container.remove() + } + document.body.replaceChildren() + vi.clearAllMocks() +}) + +describe('floating workspace tab reorder', () => { + it('moves a tab after the hovered tab when released on its right half', () => { + dropTab('tab-a', 'tab-c', 290) + expect(floatingTabOrder()).toEqual(['tab-b', 'tab-c', 'tab-a']) + }) + + it('moves a tab before the hovered tab when released on its left half', () => { + dropTab('tab-c', 'tab-a', 210) + expect(floatingTabOrder()).toEqual(['tab-c', 'tab-a', 'tab-b']) + }) + + it('leaves the order alone when a tab is dropped on itself', () => { + dropTab('tab-b', 'tab-b', 290) + expect(floatingTabOrder()).toEqual(['tab-a', 'tab-b', 'tab-c']) + }) + + it('republishes sortOrder so the panel renders the committed order', () => { + dropTab('tab-a', 'tab-c', 290) + const tabs = useAppStore.getState().unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [] + expect(tabs.map((tab) => [tab.id, tab.sortOrder])).toEqual([ + ['tab-a', 2], + ['tab-b', 0], + ['tab-c', 1] + ]) + }) +}) + +describe('floating panel titlebar drag target', () => { + it.each(['sortable-tab', 'browser-tab', 'editor-tab', 'client-hosted-browser-row-id'])( + 'does not move the panel when the press lands on a %s', + (kind) => { + const tab = document.createElement('div') + tab.dataset.tabId = kind + const label = document.createElement('span') + tab.appendChild(label) + document.body.appendChild(tab) + + expect(isFloatingTerminalDragTarget(tab)).toBe(false) + expect(isFloatingTerminalDragTarget(label)).toBe(false) + } + ) + + it('does not move the panel when a tab SVG icon receives the press', () => { + const tab = document.createElement('div') + tab.dataset.tabId = 'terminal-tab' + const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + tab.appendChild(icon) + document.body.appendChild(tab) + + expect(isFloatingTerminalDragTarget(icon)).toBe(false) + }) + + it('still moves the panel from bare titlebar chrome', () => { + const titlebar = document.createElement('div') + document.body.appendChild(titlebar) + expect(isFloatingTerminalDragTarget(titlebar)).toBe(true) + }) +}) From f400f8fd5f35f0f27ad4697af8789f99fd9a4fa4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:44:03 -0700 Subject: [PATCH 066/152] fix(macos): opt out of press-and-hold so held keys repeat (#14746) (#15589) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(macos): opt out of press-and-hold so held keys repeat (#14746) macOS routes press-and-hold to the accent picker unless an app sets ApplePressAndHoldEnabled=false for its own bundle, so holding j in vim inserted one character instead of repeating. Orca never set it. Written at most once, and never over an explicit value: `defaults read` is domain-scoped and exits 1 when the key is absent, which is the only way to tell "unset" from a deliberate false — Electron's systemPreferences.getUserDefault reports false for both. A recorded decision in userData keeps a later launch from re-clobbering a user who deletes the key to get the accent picker back. * docs(macos): record the revert hazard and CI's macOS test gap Two things a reader of this module cannot otherwise know. A revert leaves the key written in every user's domain forever. AppKit reads the plist, not this file, so removing the code alone keeps press-and-hold disabled for everyone who ran an affected build. The sibling period-substitution module carries the same warning because that fix was already lost once this way. And the real-binary test file that pins the defaults(1) exit-code semantics this design rests on never runs in CI: the e2e workflow and both unit-test jobs are ubuntu and windows, and the only macOS runners in the repo are build and packaging jobs that run no tests. Those six tests plus the real-bundle e2e case pass on a developer Mac and execute zero times in a green PR, so the comment should not imply enforcement that is not there. Refs #14746 * feat(macos): let users turn the accent menu back on (#14746) Orca disables press-and-hold for its own preferences domain so held keys repeat. That is the right default, but the way back was a `defaults write` buried in a source comment: nothing in docs/ or the README mentioned it, and the preference is per-application, so it silently takes the accent picker away from the Markdown editor and every other text field too. Terminal -> Advanced now carries a "Character Accent Menu" switch, macOS and desktop only. A web client cannot write a macOS preference for the machine the user is looking at, so the control and its search-index entry are both gated on that, not on the client's platform alone. Precedence, which is the part that is easy to get wrong: the setting is `undefined` until the user touches it, which is what keeps a hand-run `defaults write` in charge for everyone who never opens the toggle. Once used, Orca owns the key and writes exactly what the switch asks for -- `ApplePressAndHoldEnabled` *is* the accent-menu switch, so it maps straight through with no inversion. The choice is compared against `appliedSetting` in the existing decision record rather than against the domain, so a `defaults write` made *after* using the toggle is still the newer choice and survives the next launch. Re-asserting the value every launch would have reintroduced the clobbering the record exists to prevent. The write lands for the next launch, since AppKit reads the preference as the process starts, so the toggle shows the same restart banner the window-blur setting uses. That banner is now a shared component, keeping its original translation keys. docs/reference/macos-press-and-hold.md records the precedence rules, the `defaults read` rationale, the revert hazard, and the fact that none of this executes in CI: every macOS job builds or packages and runs no tests, so the real-binary and e2e coverage here passes only on a developer Mac. * docs(macos): stop asserting when AppKit re-reads the press-and-hold key Five places stated "AppKit reads the preference as the process starts" as fact. That is the reason given for requiring a relaunch, and it is not something this change ever measured. Evidence points the other way: terminal emulators that register this key after their process has started get key repeat in that same launch, which a read-once-at-startup model cannot explain. The relaunch requirement itself still looks right, but for a different and verifiable reason: the write goes out through a separate `defaults` process, so this app's own cached copy need not observe it. That is what the comments now say, with the AppKit question left open rather than answered. Refs #14746 * docs(macos): correct the startup comment's launch-timing claim The comment said this call site is "the last point that can still matter for this launch", which contradicts the rest of the module: the write is assumed to land for the next launch because it goes out through a separate `defaults` process. Reported on the PR by @innocarpe, who also supplied the replacement wording. Co-authored-by: innocarpe * refactor(macos): probe press-and-hold through the shared spawn chokepoint `src/shared/child-process/child-process-import-boundary.test.ts` forbids a direct `node:child_process` import outside its allowlist, and the allowlist only shrinks — so this module moves to `runProcessSync`, which exists for callers that genuinely cannot await. This one runs before `app.whenReady()`. `runProcessSync` returns a non-zero exit instead of throwing it, so the three-way read decision is re-expressed against `ProcessResult`: exit 0 is an explicit value, exit 1 is a missing key, and a timeout, a signal kill, any other exit, or a child that never started all stay 'unknown'. The throw path is now inside `interpretDefaultsRead` so a spawn failure is reachable from a test rather than hidden in an untested catch, and the write checks the exit code — a refused `defaults write` no longer looks like success. Both boundary-test failures were the same import: with it gone the offender count returns to 155, so no ratchet baseline is bumped. * Revert "feat(macos): let users turn the accent menu back on (#14746)" This reverts commit cc5669f3068fd736a1fcf6017f1ea36b18f9ee04. --------- Co-authored-by: innocarpe --- .gitignore | 1 + docs/reference/macos-press-and-hold.md | 58 ++++ src/main/index.ts | 4 + ...s-and-hold-default.defaults-domain.test.ts | 173 +++++++++++ src/main/macos-press-and-hold-default.test.ts | 286 ++++++++++++++++++ src/main/macos-press-and-hold-default.ts | 257 ++++++++++++++++ .../e2e/macos-press-and-hold-startup.spec.ts | 96 ++++++ 7 files changed, 875 insertions(+) create mode 100644 docs/reference/macos-press-and-hold.md create mode 100644 src/main/macos-press-and-hold-default.defaults-domain.test.ts create mode 100644 src/main/macos-press-and-hold-default.test.ts create mode 100644 src/main/macos-press-and-hold-default.ts create mode 100644 tests/e2e/macos-press-and-hold-startup.spec.ts diff --git a/.gitignore b/.gitignore index a08e1058eeb..176206bacfb 100644 --- a/.gitignore +++ b/.gitignore @@ -103,6 +103,7 @@ docs/** !docs/reference/git-compatibility.md !docs/reference/headless-linux-server.md !docs/reference/linux-glibc-compatibility.md +!docs/reference/macos-press-and-hold.md !docs/reference/orcad-operations.md !docs/reference/relay-grace-time-reconfiguration.md !docs/reference/windows-process-enumeration.md diff --git a/docs/reference/macos-press-and-hold.md b/docs/reference/macos-press-and-hold.md new file mode 100644 index 00000000000..946a1ff7125 --- /dev/null +++ b/docs/reference/macos-press-and-hold.md @@ -0,0 +1,58 @@ +# macOS press-and-hold and key repeat + +macOS opens the accent picker when a key is held unless an application opts out in its preferences +domain. That prevents held keys from repeating in terminal applications such as vim. On the first +eligible launch, Orca writes: + +```sh +defaults write com.stablyai.orca ApplePressAndHoldEnabled -bool false +``` + +The write is scoped to Orca's packaged bundle domain. Bare Electron development bundles and +non-macOS platforms are left untouched. A fresh write is conservatively treated as taking effect +on the next launch. + +## Precedence and decision record + +Orca checks for an explicit domain value before writing. Either `true` or `false` is treated as a +user choice and preserved. Only an unset key receives the `false` default. + +The decision is stored once in +`/macos-press-and-hold-default.json`. An `applied` or +`kept-user-preference` decision prevents future launches from touching the domain again. +Probe and write failures remain retryable so a transient failure does not permanently disable the +fix. + +`defaults read ` is used instead of +`systemPreferences.getUserDefault`: the Electron API cannot distinguish an unset key from an +explicit `false`. Only the missing-key exit status is interpreted as unset; spawn failures, +timeouts, and other exit statuses leave the preference alone. + +## Restoring the accent picker + +Set the preference explicitly, then restart Orca: + +```sh +defaults write com.stablyai.orca ApplePressAndHoldEnabled -bool true +``` + +After Orca has recorded its one-time decision, deleting the key also restores the macOS default +without Orca recreating it: + +```sh +defaults delete com.stablyai.orca ApplePressAndHoldEnabled +``` + +Development and prerelease channels may use a channel-suffixed Orca bundle identifier; use that +domain instead when applicable. + +## Reverting + +Deleting the startup code is not enough. AppKit reads the persisted preference, so a code revert +must also arrange to delete the key for users who ran an affected build. + +## Test coverage + +Unit tests cover platform guards, explicit-value preservation, retry behavior, domain ownership, +record persistence, and subprocess exit interpretation on CI. A macOS-only test additionally pins +the real `defaults(1)` behavior, but current PR CI does not execute tests on macOS. diff --git a/src/main/index.ts b/src/main/index.ts index a142fc6cf7d..c21adc53642 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -13,6 +13,7 @@ import { type Tray, session } from 'electron' +import { applyMacPressAndHoldDefaultAtStartup } from './macos-press-and-hold-default' import { initTccPromptNotice, stopTccPromptNotice } from './macos-tcc-prompt-notice' import { electronApp, is } from '@electron-toolkit/utils' import { @@ -938,6 +939,9 @@ if (hasSingleInstanceLock) { installDevParentSignalQuit(shouldCoupleToDevParent) // Why: run after configureDevUserDataPath but before app.setName('Orca') (whenReady), which changes the resolved path on case-sensitive filesystems. initDataPath() + // Why here: initDataPath above gives the canonical userData path for the record file; the write + // itself lands for the next launch (see macos-press-and-hold-default.ts). + applyMacPressAndHoldDefaultAtStartup(getCanonicalUserDataPath()) // Why: use the canonical userData path — late app.getPath('userData') can resolve differently across restarts, defeating persistence. initSessionParseCachePersistence({ filePath: join(getCanonicalUserDataPath(), 'ai-vault', 'session-parse-cache.json'), diff --git a/src/main/macos-press-and-hold-default.defaults-domain.test.ts b/src/main/macos-press-and-hold-default.defaults-domain.test.ts new file mode 100644 index 00000000000..18dab052450 --- /dev/null +++ b/src/main/macos-press-and-hold-default.defaults-domain.test.ts @@ -0,0 +1,173 @@ +import { execFileSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { chmodSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { runProcessSync } from '../shared/child-process/run-process' +import { + PRESS_AND_HOLD_KEY, + ensureMacPressAndHoldDefault, + interpretDefaultsRead, + readDomainPressAndHoldPreference, + writeDomainPressAndHoldPreference, + type PressAndHoldHost, + type PressAndHoldRecord +} from './macos-press-and-hold-default' + +/** + * Runs against the real `/usr/bin/defaults` on a throwaway Orca-owned domain. + * + * The whole design rests on one claim the mocks cannot make: a domain that has never been written + * is distinguishable from one explicitly set to `false`. Electron's `systemPreferences` cannot tell + * them apart, so if `defaults` could not either, "only write when unset" would be unimplementable. + * + * Every domain used here is a throwaway UUID under Orca's own prefix, deleted along with its plist + * in `afterEach`; the real `com.stablyai.orca` domain is never read or written. + */ + +// Why a real Orca-owned domain shape: the ownership guard rejects anything else, so a fake prefix +// would exercise a different branch than production. +const domains: string[] = [] + +/** Pinned against the real binary below, then reused by the platform-agnostic unit tests. */ +const DEFAULTS_MISSING_EXIT_CODE = 1 + +function throwawayDomain(): string { + const domain = `com.stablyai.orca.defaults-domain-test.${randomUUID()}` + domains.push(domain) + return domain +} + +function write(domain: string, value: 'true' | 'false'): void { + execFileSync('/usr/bin/defaults', ['write', domain, PRESS_AND_HOLD_KEY, '-bool', value], { + stdio: 'ignore' + }) +} + +function rawRead(domain: string): string { + return execFileSync('/usr/bin/defaults', ['read', domain, PRESS_AND_HOLD_KEY], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + }).trim() +} + +function hostFor(domain: string, record: PressAndHoldRecord | null = null): PressAndHoldHost { + return { + platform: 'darwin', + resolveBundleIdentifier: () => domain, + readRecord: () => record, + writeRecord: () => undefined, + readDomainPreference: readDomainPressAndHoldPreference, + writeDomainPreference: writeDomainPressAndHoldPreference, + now: () => new Date().toISOString() + } +} + +describe.skipIf(process.platform !== 'darwin')( + 'press-and-hold against the real defaults binary', + () => { + afterEach(() => { + for (const domain of domains.splice(0)) { + try { + execFileSync('/usr/bin/defaults', ['delete', domain], { stdio: 'ignore' }) + } catch { + // The domain may never have been created; nothing to clean up. + } + // Why the file too: `defaults delete` empties the domain but leaves its plist behind, so + // without this every run litters the developer's ~/Library/Preferences. + rmSync(join(homedir(), 'Library', 'Preferences', `${domain}.plist`), { force: true }) + } + }) + + it('tells an unset key apart from an explicit false', () => { + const unset = throwawayDomain() + const explicitlyFalse = throwawayDomain() + write(explicitlyFalse, 'false') + + expect(readDomainPressAndHoldPreference(unset)).toBe('unset') + expect(readDomainPressAndHoldPreference(explicitlyFalse)).toBe('set') + expect(rawRead(explicitlyFalse)).toBe('0') + }) + + it('reports an explicit true as set', () => { + const domain = throwawayDomain() + write(domain, 'true') + + expect(readDomainPressAndHoldPreference(domain)).toBe('set') + expect(rawRead(domain)).toBe('1') + }) + + it('writes false into a previously unset domain', () => { + const domain = throwawayDomain() + + expect(ensureMacPressAndHoldDefault(hostFor(domain))).toBe('applied') + expect(rawRead(domain)).toBe('0') + }) + + it('leaves an explicit value untouched, in either direction', () => { + for (const value of ['true', 'false'] as const) { + const domain = throwawayDomain() + write(domain, value) + + expect(ensureMacPressAndHoldDefault(hostFor(domain))).toBe('kept-user-preference') + expect(rawRead(domain)).toBe(value === 'true' ? '1' : '0') + } + }) + + it('refuses to call a failed probe "unset"', () => { + // Real runs, not hand-built results: a binary that is not there must come back 'unknown' + // through the same path production takes, not be mistaken for "the key does not exist". + const spawnFailure = (): ReturnType => + runProcessSync({ + program: '/usr/bin/defaults-does-not-exist', + args: ['read'], + stdio: ['ignore', 'ignore', 'ignore'] + }) + expect(spawnFailure).toThrow() + expect(interpretDefaultsRead(spawnFailure)).toBe('unknown') + + const missingKey = runProcessSync({ + program: '/usr/bin/defaults', + args: ['read', throwawayDomain(), PRESS_AND_HOLD_KEY], + stdio: ['ignore', 'ignore', 'ignore'] + }) + // The one exit code the whole design reads as unset; every other outcome is 'unknown'. + expect(missingKey.code).toBe(DEFAULTS_MISSING_EXIT_CODE) + expect(missingKey.timedOut).toBe(false) + expect(interpretDefaultsRead(() => missingKey)).toBe('unset') + }) + + // Skipped as root, where the read-only directory below would still be writable. + it.skipIf(process.getuid?.() === 0)('reports a write the binary refused', () => { + // Why a real refusal: `defaults write` exits non-zero instead of throwing, so a caller that + // only caught exceptions would record 'applied' for a value that never reached the plist. + const readOnly = join(mkdtempSync(join(tmpdir(), 'orca-press-hold-ro-')), 'locked') + mkdirSync(readOnly) + chmodSync(readOnly, 0o500) + try { + expect(writeDomainPressAndHoldPreference(join(readOnly, 'domain'), false)).toBe(false) + } finally { + chmodSync(readOnly, 0o700) + rmSync(readOnly, { recursive: true, force: true }) + } + }) + + it('sees the key disappear again after the user deletes it', () => { + const domain = throwawayDomain() + write(domain, 'false') + execFileSync('/usr/bin/defaults', ['delete', domain, PRESS_AND_HOLD_KEY], { stdio: 'ignore' }) + + expect(readDomainPressAndHoldPreference(domain)).toBe('unset') + // A launch that already decided must not rewrite it, which is what makes the delete stick. + const decided: PressAndHoldRecord = { + version: 1, + decision: 'applied', + domain, + decidedAt: '2026-01-01T00:00:00.000Z' + } + expect(ensureMacPressAndHoldDefault(hostFor(domain, decided))).toBe('already-decided') + expect(readDomainPressAndHoldPreference(domain)).toBe('unset') + }) + } +) diff --git a/src/main/macos-press-and-hold-default.test.ts b/src/main/macos-press-and-hold-default.test.ts new file mode 100644 index 00000000000..c30aed82d10 --- /dev/null +++ b/src/main/macos-press-and-hold-default.test.ts @@ -0,0 +1,286 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ProcessResult } from '../shared/child-process/run-process' +import { + ensureMacPressAndHoldDefault, + interpretDefaultsRead, + isOrcaPreferencesDomain, + readBundleIdentifierFromExecutablePath, + type PressAndHoldDecision, + type PressAndHoldHost, + type PressAndHoldRecord +} from './macos-press-and-hold-default' + +const ORCA_DOMAIN = 'com.stablyai.orca' + +type HostOverrides = Partial & { record?: PressAndHoldRecord | null } + +/** A macOS host whose domain has no explicit value — the state every affected user is in. */ +function createHost(overrides: HostOverrides = {}): { + host: PressAndHoldHost + writes: { domain: string; value: boolean }[] + records: PressAndHoldRecord[] +} { + const writes: { domain: string; value: boolean }[] = [] + const records: PressAndHoldRecord[] = [] + let stored = overrides.record ?? null + const host: PressAndHoldHost = { + platform: 'darwin', + resolveBundleIdentifier: () => ORCA_DOMAIN, + readRecord: () => stored, + writeRecord: (record) => { + stored = record + records.push(record) + }, + readDomainPreference: () => 'unset', + writeDomainPreference: (domain, value) => { + writes.push({ domain, value }) + return true + }, + now: () => '2026-08-20T00:00:00.000Z', + ...overrides + } + return { host, writes, records } +} + +function terminalRecord(decision: PressAndHoldDecision): PressAndHoldRecord { + return { version: 1, decision, domain: ORCA_DOMAIN, decidedAt: '2026-01-01T00:00:00.000Z' } +} + +describe('ensureMacPressAndHoldDefault', () => { + it('writes the opt-out when the domain has no explicit value', () => { + const { host, writes, records } = createHost() + + expect(ensureMacPressAndHoldDefault(host)).toBe('applied') + expect(writes).toEqual([{ domain: ORCA_DOMAIN, value: false }]) + expect(records.at(-1)?.decision).toBe('applied') + }) + + describe('platform guard', () => { + for (const platform of ['win32', 'linux'] as const) { + it(`does nothing at all on ${platform}`, () => { + const probe = vi.fn(() => 'unset' as const) + const readRecord = vi.fn(() => null) + const { host, writes, records } = createHost({ + platform, + readDomainPreference: probe, + readRecord + }) + + expect(ensureMacPressAndHoldDefault(host)).toBe('not-macos') + // Why assert the reads too: "inert" means no `defaults` subprocess and no userData I/O, + // not merely no write. + expect(probe).not.toHaveBeenCalled() + expect(readRecord).not.toHaveBeenCalled() + expect(writes).toEqual([]) + expect(records).toEqual([]) + }) + } + }) + + describe('respects an explicit user setting', () => { + it('leaves the domain alone when the user already set a value', () => { + // One arm covers both `true` and `false`: the probe deliberately reports only presence, so + // whichever way the user's value points it is theirs. That the probe really does report + // presence for both is pinned against the real binary in the .defaults-domain test. + const { host, writes, records } = createHost({ readDomainPreference: () => 'set' }) + + expect(ensureMacPressAndHoldDefault(host)).toBe('kept-user-preference') + expect(writes).toEqual([]) + expect(records.at(-1)?.decision).toBe('kept-user-preference') + }) + + it('never re-applies after a launch already decided', () => { + for (const decision of ['applied', 'kept-user-preference'] as const) { + const probe = vi.fn(() => 'unset' as const) + const { host, writes } = createHost({ + record: terminalRecord(decision), + readDomainPreference: probe + }) + + expect(ensureMacPressAndHoldDefault(host)).toBe('already-decided') + expect(probe).not.toHaveBeenCalled() + expect(writes).toEqual([]) + } + }) + + it('stays out of the way after the user deletes the key we wrote', () => { + // The documented way back to the accent picker is `defaults delete`, which returns the + // domain to "unset". Without the recorded decision the next launch would rewrite it. + const { host, writes } = createHost({ + record: terminalRecord('applied'), + readDomainPreference: () => 'unset' + }) + + expect(ensureMacPressAndHoldDefault(host)).toBe('already-decided') + expect(writes).toEqual([]) + }) + }) + + describe('domains we do not own', () => { + it('skips a bare Electron bundle rather than writing into a shared domain', () => { + const probe = vi.fn(() => 'unset' as const) + const { host, writes, records } = createHost({ + resolveBundleIdentifier: () => 'com.github.Electron', + readDomainPreference: probe + }) + + expect(ensureMacPressAndHoldDefault(host)).toBe('foreign-bundle') + expect(probe).not.toHaveBeenCalled() + expect(writes).toEqual([]) + expect(records.at(-1)?.domain).toBe('com.github.Electron') + }) + + it('skips when the bundle identifier cannot be resolved', () => { + const { host, writes } = createHost({ resolveBundleIdentifier: () => null }) + + expect(ensureMacPressAndHoldDefault(host)).toBe('foreign-bundle') + expect(writes).toEqual([]) + }) + + it('accepts Orca and its channel-scoped bundles, and nothing else', () => { + expect(isOrcaPreferencesDomain('com.stablyai.orca')).toBe(true) + expect(isOrcaPreferencesDomain('com.stablyai.orca.dev')).toBe(true) + expect(isOrcaPreferencesDomain('com.github.Electron')).toBe(false) + // Why: a prefix test without the dot would accept a lookalike bundle id. + expect(isOrcaPreferencesDomain('com.stablyai.orcafake')).toBe(false) + }) + }) + + describe('failures leave the decision open', () => { + it('does not write when the probe cannot tell unset from user-set', () => { + const { host, writes, records } = createHost({ readDomainPreference: () => 'unknown' }) + + expect(ensureMacPressAndHoldDefault(host)).toBe('probe-failed') + expect(writes).toEqual([]) + expect(records.at(-1)?.decision).toBe('probe-failed') + }) + + it('retries on the next launch after a failed write', () => { + const { host, records } = createHost({ writeDomainPreference: () => false }) + expect(ensureMacPressAndHoldDefault(host)).toBe('write-failed') + + const retry = createHost({ record: records.at(-1) }) + expect(ensureMacPressAndHoldDefault(retry.host)).toBe('applied') + expect(retry.writes).toEqual([{ domain: ORCA_DOMAIN, value: false }]) + }) + }) + + it('does not rewrite an unchanged non-terminal record', () => { + const first = createHost({ resolveBundleIdentifier: () => 'com.github.Electron' }) + expect(ensureMacPressAndHoldDefault(first.host)).toBe('foreign-bundle') + + const second = createHost({ + record: first.records.at(-1), + resolveBundleIdentifier: () => 'com.github.Electron' + }) + expect(ensureMacPressAndHoldDefault(second.host)).toBe('foreign-bundle') + expect(second.records).toEqual([]) + }) +}) + +/** + * Why here and not only in the .defaults-domain file: that file is macOS-only and CI has no macOS + * runner, so this three-way rule would otherwise be enforced nowhere in CI. These cases need no + * `defaults` binary — the sibling file pins that the real binary produces them. + */ +describe('interpretDefaultsRead', () => { + function probe(overrides: Partial): () => ProcessResult { + return () => ({ code: 0, signal: null, stdout: '', stderr: '', timedOut: false, ...overrides }) + } + + it('reads a clean exit as an explicit value', () => { + expect(interpretDefaultsRead(probe({ code: 0 }))).toBe('set') + }) + + it('reads only "does not exist" as unset', () => { + expect(interpretDefaultsRead(probe({ code: 1 }))).toBe('unset') + }) + + describe('refuses to call a failed probe unset', () => { + // Each of these would otherwise overwrite a value the user deliberately set. + const failures: [string, () => ProcessResult][] = [ + ['killed before it exited', probe({ code: null, signal: 'SIGTERM' })], + ['timed out', probe({ code: null, timedOut: true })], + [ + 'timed out after somehow reporting the missing-key code', + probe({ code: 1, timedOut: true }) + ], + ['exited with some other error', probe({ code: 2 })], + ['exited with a negative status', probe({ code: -1 })], + [ + 'never started at all', + () => { + throw Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }) + } + ] + ] + for (const [name, failing] of failures) { + it(name, () => { + expect(interpretDefaultsRead(failing)).toBe('unknown') + }) + } + }) +}) + +describe('readBundleIdentifierFromExecutablePath', () => { + const roots: string[] = [] + + afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } + }) + + function bundleWithPlist(body: string): string { + const root = mkdtempSync(join(tmpdir(), 'orca-press-hold-')) + roots.push(root) + mkdirSync(join(root, 'Orca.app', 'Contents', 'MacOS'), { recursive: true }) + writeFileSync(join(root, 'Orca.app', 'Contents', 'Info.plist'), body) + return join(root, 'Orca.app', 'Contents', 'MacOS', 'Orca') + } + + it('reads CFBundleIdentifier from the plist beside the executable', () => { + const exe = bundleWithPlist( + '\nCFBundleName\nOrca\n' + + 'CFBundleIdentifier\n\tcom.stablyai.orca\n' + ) + + expect(readBundleIdentifierFromExecutablePath(exe)).toBe('com.stablyai.orca') + }) + + it('returns null when the plist is missing or carries no identifier', () => { + expect(readBundleIdentifierFromExecutablePath('/nonexistent/App.app/Contents/MacOS/App')).toBe( + null + ) + expect(readBundleIdentifierFromExecutablePath(bundleWithPlist(''))).toBe( + null + ) + expect( + readBundleIdentifierFromExecutablePath( + bundleWithPlist('CFBundleIdentifier') + ) + ).toBe(null) + }) +}) + +describe('startup wiring', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + + it('runs before app.whenReady(), which is the last point AppKit could still see it', () => { + const callIndex = source.indexOf( + 'applyMacPressAndHoldDefaultAtStartup(getCanonicalUserDataPath())' + ) + const initDataPathIndex = source.indexOf('initDataPath()') + const readyIndex = source.indexOf('app.whenReady().then(') + + expect(callIndex).toBeGreaterThanOrEqual(0) + expect(readyIndex).toBeGreaterThanOrEqual(0) + // Why after initDataPath: the record lives beside orca-data.json, and the canonical userData + // path is only captured there. + expect(callIndex).toBeGreaterThan(initDataPathIndex) + expect(callIndex).toBeLessThan(readyIndex) + }) +}) diff --git a/src/main/macos-press-and-hold-default.ts b/src/main/macos-press-and-hold-default.ts new file mode 100644 index 00000000000..41b7819f2b2 --- /dev/null +++ b/src/main/macos-press-and-hold-default.ts @@ -0,0 +1,257 @@ +import { mkdirSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { runProcessSync, type ProcessResult } from '../shared/child-process/run-process' +import { writeFileAtomically } from './codex-accounts/fs-utils' + +/** + * Turns off the macOS accent picker for Orca's own preferences domain (#14746). + * + * macOS routes press-and-hold to the accent popup unless an app opts out via + * `ApplePressAndHoldEnabled`, so holding `j` in vim inserts one character instead of repeating. + * The key is unset by default, which is why every terminal-hosting Mac app ships this opt-out. + * + * Written once and never again: a user who wants the accent picker back sets + * `defaults write com.stablyai.orca ApplePressAndHoldEnabled -bool true` (or deletes the key), and + * the recorded decision below keeps a later launch from overwriting that choice. + * + * A fresh write is assumed to land for the *next* launch, not the current one: it goes out through + * a separate `defaults` process, so this app's own cached copy may not observe it. Whether AppKit + * re-reads sooner is untested, so the conservative assumption is the one encoded here. + * + * If this is ever reverted, delete the key as well — AppKit reads the plist, not this file, so + * removing the code alone leaves press-and-hold disabled forever for everyone who ran an affected + * build, with nothing left in the tree to explain it. + * + * The `defaults`-binary behaviour this relies on is pinned by the sibling test file, which runs + * only on a developer Mac: CI has no macOS test runner, so those tests never execute there. + */ + +export const PRESS_AND_HOLD_KEY = 'ApplePressAndHoldEnabled' +export const PRESS_AND_HOLD_RECORD_FILE = 'macos-press-and-hold-default.json' +export const PRESS_AND_HOLD_RECORD_VERSION = 1 + +/** Why not `systemPreferences.getUserDefault`: it reads through the whole NSUserDefaults search + * list and is typed non-nullable, so an unset key and an explicit `false` both come back `false` — + * and the system default is unset, so it reports `false` on a Mac where press-and-hold is on. + * `defaults read ` is domain-scoped and exits 1 when the key is absent. */ +const DEFAULTS_BINARY = '/usr/bin/defaults' +/** A hang cutoff, not a latency budget: the probe measures ~6ms, and cutting a slow-but-working + * `defaults` short costs the user the feature for that launch. The shared 30s default is what + * must not apply — this runs before `ready`, so a 30s block is a hung app. */ +const DEFAULTS_TIMEOUT_MS = 5_000 +/** Why: `defaults` exits 1 for "does not exist"; anything else means the probe itself failed. */ +const DEFAULTS_MISSING_STATUS = 1 + +const ORCA_BUNDLE_ID = 'com.stablyai.orca' + +export type PressAndHoldDecision = + /** Not macOS — nothing is read or written. */ + | 'not-macos' + /** A previous launch already decided; the domain is never touched again. */ + | 'already-decided' + /** The running bundle is not Orca's (e.g. a bare `Electron.app`), whose domain we do not own. */ + | 'foreign-bundle' + /** `defaults read` could not answer, so we cannot tell an unset key from a user's choice. */ + | 'probe-failed' + /** `defaults write` failed. */ + | 'write-failed' + /** The domain already carries an explicit value; the user's choice stands. */ + | 'kept-user-preference' + /** The key was unset and we wrote `false`. */ + | 'applied' + +/** Decisions about the domain itself. Anything else is a condition that can change by next launch. */ +const TERMINAL_DECISIONS = new Set(['applied', 'kept-user-preference']) + +export type PressAndHoldRecord = { + version: number + decision: PressAndHoldDecision + domain: string | null + decidedAt: string +} + +export type PressAndHoldHost = { + platform: NodeJS.Platform + resolveBundleIdentifier: () => string | null + readRecord: () => PressAndHoldRecord | null + writeRecord: (record: PressAndHoldRecord) => void + readDomainPreference: (domain: string) => 'set' | 'unset' | 'unknown' + writeDomainPreference: (domain: string, value: boolean) => boolean + now: () => string +} + +/** Only Orca's own bundle: an unpackaged run is `com.github.Electron`, shared with every other + * unpackaged Electron app on the machine. */ +export function isOrcaPreferencesDomain(domain: string): boolean { + return domain === ORCA_BUNDLE_ID || domain.startsWith(`${ORCA_BUNDLE_ID}.`) +} + +/** `/Contents/MacOS/` → `/Contents/Info.plist`. */ +export function readBundleIdentifierFromExecutablePath(execPath: string): string | null { + try { + const plist = readFileSync(join(dirname(dirname(execPath)), 'Info.plist'), 'utf8') + const match = /CFBundleIdentifier<\/key>\s*([^<]*)<\/string>/.exec(plist) + const identifier = match?.[1]?.trim() + return identifier ? identifier : null + } catch { + return null + } +} + +/** + * Why this distinction carries the whole design: only a real "does not exist" answer may be read as + * unset. A killed or timed-out probe never answered, and calling that unset would let a broken + * probe overwrite a value the user chose. + */ +export function interpretDefaultsRead(probe: () => ProcessResult): 'set' | 'unset' | 'unknown' { + let result: ProcessResult + try { + result = probe() + } catch { + // `runProcessSync` throws only when the child never started, which answers nothing at all. + return 'unknown' + } + // Why before the code: a timeout kills mid-run, so any exit it leaves behind is not an answer. + if (result.timedOut) { + return 'unknown' + } + if (result.code === 0) { + return 'set' + } + return result.code === DEFAULTS_MISSING_STATUS ? 'unset' : 'unknown' +} + +export function readDomainPressAndHoldPreference(domain: string): 'set' | 'unset' | 'unknown' { + // Why no captured output: the exit code is the whole answer, and reading only presence is what + // makes an explicit `true` and an explicit `false` both count as the user's. + return interpretDefaultsRead(() => + runProcessSync({ + program: DEFAULTS_BINARY, + args: ['read', domain, PRESS_AND_HOLD_KEY], + timeoutMs: DEFAULTS_TIMEOUT_MS, + stdio: ['ignore', 'ignore', 'ignore'] + }) + ) +} + +export function writeDomainPressAndHoldPreference(domain: string, value: boolean): boolean { + try { + // Why the code check: a non-zero `defaults write` returns rather than throwing, so treating a + // completed run as success would record 'applied' for a write that never landed. + return ( + runProcessSync({ + program: DEFAULTS_BINARY, + args: ['write', domain, PRESS_AND_HOLD_KEY, '-bool', value ? 'true' : 'false'], + timeoutMs: DEFAULTS_TIMEOUT_MS, + stdio: ['ignore', 'ignore', 'ignore'] + }).code === 0 + ) + } catch { + return false + } +} + +export function pressAndHoldRecordPath(userDataPath: string): string { + return join(userDataPath, PRESS_AND_HOLD_RECORD_FILE) +} + +function parseRecord(raw: string): PressAndHoldRecord | null { + const parsed = JSON.parse(raw) as Partial + if (parsed.version !== PRESS_AND_HOLD_RECORD_VERSION || typeof parsed.decision !== 'string') { + return null + } + return { + version: PRESS_AND_HOLD_RECORD_VERSION, + decision: parsed.decision as PressAndHoldDecision, + domain: typeof parsed.domain === 'string' ? parsed.domain : null, + decidedAt: typeof parsed.decidedAt === 'string' ? parsed.decidedAt : '' + } +} + +/** + * Apply Orca's press-and-hold default at most once, leaving any explicit user value alone. + * + * Returns the decision so startup can log it; the same value is persisted for support triage. + */ +export function ensureMacPressAndHoldDefault(host: PressAndHoldHost): PressAndHoldDecision { + // Why first and without touching disk: Windows and Linux must do no work at all here. + if (host.platform !== 'darwin') { + return 'not-macos' + } + + const previous = host.readRecord() + if (previous && TERMINAL_DECISIONS.has(previous.decision)) { + return 'already-decided' + } + + const record = (decision: PressAndHoldDecision, domain: string | null): PressAndHoldDecision => { + // Why skip an unchanged rewrite: the non-terminal decisions are re-evaluated every launch. + if (!previous || previous.decision !== decision || previous.domain !== domain) { + host.writeRecord({ + version: PRESS_AND_HOLD_RECORD_VERSION, + decision, + domain, + decidedAt: host.now() + }) + } + return decision + } + + const domain = host.resolveBundleIdentifier() + if (!domain || !isOrcaPreferencesDomain(domain)) { + return record('foreign-bundle', domain) + } + + const existing = host.readDomainPreference(domain) + if (existing === 'unknown') { + return record('probe-failed', domain) + } + if (existing === 'set') { + return record('kept-user-preference', domain) + } + if (!host.writeDomainPreference(domain, false)) { + return record('write-failed', domain) + } + return record('applied', domain) +} + +/** Why log: silently rewriting a macOS default for the user's login account should leave a trace, + * and the failure paths are otherwise invisible. The quiet decisions are the steady state. */ +const REPORTED_DECISIONS: Partial> = { + applied: `set ${PRESS_AND_HOLD_KEY}=false so held keys repeat; takes effect next launch`, + 'probe-failed': `could not read ${PRESS_AND_HOLD_KEY}; leaving it alone`, + 'write-failed': `could not write ${PRESS_AND_HOLD_KEY}; held keys will not repeat` +} + +/** Wires {@link ensureMacPressAndHoldDefault} to the real bundle, `defaults`, and userData. */ +export function applyMacPressAndHoldDefaultAtStartup(userDataPath: string): PressAndHoldDecision { + const recordPath = pressAndHoldRecordPath(userDataPath) + const decision = ensureMacPressAndHoldDefault({ + platform: process.platform, + resolveBundleIdentifier: () => readBundleIdentifierFromExecutablePath(process.execPath), + readRecord: () => { + try { + return parseRecord(readFileSync(recordPath, 'utf8')) + } catch { + return null + } + }, + writeRecord: (record) => { + try { + // Why: this runs before `ready`, and Electron has not necessarily created userData yet. + mkdirSync(userDataPath, { recursive: true }) + writeFileAtomically(recordPath, `${JSON.stringify(record, null, 2)}\n`) + } catch { + // Best-effort: an unwritten record only costs one repeated probe next launch. + } + }, + readDomainPreference: readDomainPressAndHoldPreference, + writeDomainPreference: writeDomainPressAndHoldPreference, + now: () => new Date().toISOString() + }) + const reported = REPORTED_DECISIONS[decision] + if (reported) { + console.log(`[press-and-hold] ${reported}`) + } + return decision +} diff --git a/tests/e2e/macos-press-and-hold-startup.spec.ts b/tests/e2e/macos-press-and-hold-startup.spec.ts new file mode 100644 index 00000000000..5eb0fba388d --- /dev/null +++ b/tests/e2e/macos-press-and-hold-startup.spec.ts @@ -0,0 +1,96 @@ +/** + * Startup wiring for the macOS press-and-hold opt-out (#14746). + * + * What this can prove: the routine runs during startup, resolves the *real* running bundle's + * identifier from its Info.plist, records a decision, and stays completely inert off macOS. + * + * What it cannot prove: that held keys repeat. Key repeat versus the accent picker is decided by + * AppKit from a preference this process does not re-read after writing, so observing it needs a relaunch and a + * physically held key — neither of which Playwright's synthesized CDP key events go through. + * The preference semantics the fix depends on are pinned against the real `/usr/bin/defaults` in + * src/main/macos-press-and-hold-default.defaults-domain.test.ts. + * + * The E2E app runs unpackaged, so its bundle identifier is Electron's own rather than Orca's. That + * is the ownership guard's case, and asserting it here is the point: `defaults` resolves the user's + * real home regardless of the harness's HOME isolation, so a run that wrote anything would be + * writing into a domain shared with every other unpackaged Electron app on the machine. + */ +import type { ElectronApplication } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' + +test.use({ seedTestRepo: false }) + +type StartupState = { + recordRaw: string | null + bundleIdentifier: string | null + execPath: string +} + +async function readPressAndHoldStartupState( + electronApp: ElectronApplication +): Promise { + return electronApp.evaluate(({ app }) => { + const nodeFs = process.getBuiltinModule('node:fs') + const nodePath = process.getBuiltinModule('node:path') + const recordPath = nodePath.join(app.getPath('userData'), 'macos-press-and-hold-default.json') + const read = (file: string): string | null => { + try { + return nodeFs.readFileSync(file, 'utf8') + } catch { + return null + } + } + // Why resolved here rather than hardcoded: this is the independent half of the cross-check — + // the spec derives the identifier from the launched bundle without reusing product code. + const plist = read( + nodePath.join(nodePath.dirname(nodePath.dirname(process.execPath)), 'Info.plist') + ) + const match = plist + ? /CFBundleIdentifier<\/key>\s*([^<]*)<\/string>/.exec(plist) + : null + return { + recordRaw: read(recordPath), + bundleIdentifier: match ? match[1].trim() : null, + execPath: process.execPath + } + }) +} + +test.describe('macOS press-and-hold default', () => { + test.skip(process.platform !== 'darwin', 'macOS-only startup routine') + + test('records a decision for the bundle it is actually running from', async ({ electronApp }) => { + const state = await readPressAndHoldStartupState(electronApp) + + expect(state.recordRaw, 'startup must leave a decision record').not.toBeNull() + const record = JSON.parse(state.recordRaw!) as { + version: number + decision: string + domain: string | null + } + + expect(record.version).toBe(1) + expect(state.bundleIdentifier).not.toBeNull() + expect(record.domain).toBe(state.bundleIdentifier) + + const ownsDomain = + record.domain === 'com.stablyai.orca' || record.domain!.startsWith('com.stablyai.orca.') + if (ownsDomain) { + // A packaged or dev-identity bundle: the write path is live and must have settled. + expect(['applied', 'kept-user-preference']).toContain(record.decision) + } else { + // The unpackaged harness. Anything but a refusal means Orca wrote into a foreign domain. + expect(record.decision).toBe('foreign-bundle') + } + }) +}) + +test.describe('off macOS', () => { + test.skip(process.platform === 'darwin', 'covers the non-macOS branch') + + test('writes nothing at all', async ({ electronApp }) => { + const state = await readPressAndHoldStartupState(electronApp) + + expect(state.recordRaw).toBeNull() + }) +}) From 808fc3563dbfb5d4a369d1d874cf2c94e86236bb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:27:52 +0000 Subject: [PATCH 067/152] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index d604b83bf97..49201fa15cc 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 30m + + downloads: 31m @@ -15,7 +15,7 @@ downloads downloads - 30m - 30m + 31m + 31m From 249d93bc5d2fd3b04581aae9916afc84bc787c8b Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:34:21 -0700 Subject: [PATCH 068/152] feat(browser-preview): render remote HTML docs locally over an orca-preview scheme (STA-5557) (#16679) --- config/reliability-gates.jsonc | 615 ++++++++++++--- electron.vite.config.ts | 1 + .../browser-manager-annotation-bridge.test.ts | 210 +++++ .../browser-manager-guest-lifecycle.test.ts | 98 +++ ...owser-manager-guest-policy-profile.test.ts | 250 ++++++ .../browser-manager-viewport-override.test.ts | 29 - src/main/browser/browser-manager.ts | 106 ++- ...browser-session-partition-policies.test.ts | 185 +++++ .../browser-session-partition-policies.ts | 33 +- .../doc-preview-download-block-notice.test.ts | 107 +++ .../doc-preview-download-block-notice.ts | 34 + .../doc-preview-failure-notice.test.ts | 70 ++ .../browser/doc-preview-failure-notice.ts | 34 + .../browser/doc-preview-file-reader.test.ts | 333 ++++++++ src/main/browser/doc-preview-file-reader.ts | 198 +++++ .../doc-preview-grant-registry.test.ts | 164 ++++ .../browser/doc-preview-grant-registry.ts | 208 +++++ .../browser/doc-preview-guest-policy.test.ts | 550 +++++++++++++ src/main/browser/doc-preview-guest-policy.ts | 247 ++++++ src/main/browser/doc-preview-protocol.test.ts | 336 ++++++++ src/main/browser/doc-preview-protocol.ts | 144 ++++ ...ffscreen-browser-backend-lifecycle.test.ts | 85 +- src/main/browser/offscreen-browser-backend.ts | 44 +- src/main/index.ts | 11 + src/main/ipc/browser-grab-ipc.ts | 19 + src/main/ipc/browser-guest-view-ipc.ts | 26 +- ...browser-preview-tool-authorization.test.ts | 510 ++++++++++++ .../ipc/browser-tab-registration-wait.test.ts | 108 +++ src/main/ipc/browser-tab-registration-wait.ts | 13 + src/main/ipc/browser.test.ts | 54 +- src/main/ipc/browser.ts | 8 + src/main/ipc/doc-preview-grant-ipc.test.ts | 159 ++++ src/main/ipc/doc-preview-grant-ipc.ts | 95 +++ .../main-window-webview-security.test.ts | 261 ++++++- .../window/main-window-webview-security.ts | 61 +- src/preload/api-types.ts | 2 + src/preload/api/doc-preview-api.ts | 29 + .../doc-preview-link-interception.test.ts | 269 +++++++ src/preload/doc-preview-link-interception.ts | 152 ++++ src/preload/doc-preview-link.ts | 15 + src/preload/index.ts | 27 + .../ClientHostedBrowserPagePane.tsx | 28 +- .../browser-guest-annotate-overlays.tsx | 189 +++++ .../annotate/browser-page-grab-action.ts | 8 +- .../guest-annotation-viewport-bridge.ts | 43 ++ .../use-browser-page-grab-annotations.ts | 16 +- .../assemble-chrome/BrowserAddressBar.tsx | 4 +- .../browser-chrome-address-slot.ts | 13 + .../browser-chrome-toolbar.tsx | 206 +++++ .../browser-navigation-control-row.test.tsx | 66 +- .../browser-navigation-control-row.tsx | 61 +- .../assemble-chrome/browser-page-toolbar.tsx | 282 ++----- .../browser-page-viewport-overlays.tsx | 163 +--- .../browser-reload-control.tsx | 95 +++ .../browser-workspace-pane.tsx | 49 +- .../use-browser-page-webview-lifecycle.ts | 29 +- .../host-guest/use-guest-drag-passthrough.ts | 32 + .../browser-address-bar-navigation.test.ts | 27 + .../browser-address-bar-navigation.ts | 24 +- .../remote-browser-page-toolbar.tsx | 24 +- .../use-remote-browser-page-navigation.ts | 2 +- .../HtmlDocPreview.failure-message.test.tsx | 342 ++++++++ .../HtmlDocPreview.toolbar.test.tsx | 582 ++++++++++++++ .../workspace-doc/HtmlDocPreview.tsx | 413 ++++++++++ .../doc-preview-document-actions.test.ts | 206 +++++ .../doc-preview-document-actions.ts | 97 +++ .../doc-preview-document-chip.tsx | 68 ++ .../doc-preview-document-identity.test.ts | 67 ++ .../doc-preview-document-identity.ts | 38 + .../doc-preview-failure-messages.ts | 70 ++ .../doc-preview-overflow-menu.tsx | 76 ++ .../workspace-doc/doc-preview-toolbar.tsx | 118 +++ .../doc-preview-webview-history.ts | 80 ++ .../use-doc-preview-guest-tools.test.ts | 134 ++++ .../use-doc-preview-guest-tools.ts | 103 +++ .../workspace-doc-page-pane.test.tsx | 109 +++ .../workspace-doc/workspace-doc-page-pane.tsx | 60 ++ .../editor/EditorPanelShell.header.test.tsx | 124 +++ .../components/editor/EditorPanelShell.tsx | 3 +- .../src/components/editor/editor-header.ts | 5 + .../tab-strip-pointer-activation.test.tsx | 38 + .../tab-bar/tab-strip-pointer-activation.ts | 20 +- .../TerminalLinkActionPopover.tsx | 6 +- .../terminal-file-link-actions.test.ts | 62 ++ .../terminal-file-link-actions.ts | 66 +- .../terminal-file-open-routing.ts | 48 +- ...minal-link-remote-runtime-ssh-open.test.ts | 64 +- .../terminal-remote-file-download-open.ts | 37 + .../ipc-events/browser-state-ipc-bridge.ts | 29 + .../doc-preview-external-link-bridge.test.ts | 92 +++ .../src/hooks/useIpcEvents-lifecycle.test.ts | 2 + src/renderer/src/i18n/locales/en.json | 39 +- src/renderer/src/i18n/locales/ko.json | 77 +- src/renderer/src/i18n/locales/zh.json | 85 +- .../lib/browser-page-palette-activation.ts | 21 +- .../lib/browser-workspace-tab-activation.ts | 30 + .../src/lib/doc-preview-grants.test.ts | 169 ++++ src/renderer/src/lib/doc-preview-grants.ts | 86 +++ .../src/lib/execution-host-display-label.ts | 61 ++ .../file-preview-action-visibility.test.tsx | 85 ++ .../file-preview-capability-cleanup.test.ts | 91 --- src/renderer/src/lib/file-preview.test.ts | 409 ++++++---- src/renderer/src/lib/file-preview.ts | 255 +++--- .../src/lib/workspace-file-host-routing.ts | 50 ++ ...ync-runtime-graph-editor-diff-tabs.test.ts | 180 +++++ .../src/runtime/sync-runtime-graph.ts | 18 +- .../browser-workspace-doc-location.test.ts | 412 ++++++++++ src/renderer/src/store/slices/browser.ts | 125 ++- .../src/store/slices/tabs-hydration.test.ts | 63 ++ .../src/store/slices/tabs-hydration.ts | 9 + src/shared/browser-page-doc-location.ts | 27 + src/shared/browser-workspace-types.ts | 17 + src/shared/doc-preview-scheme.test.ts | 45 ++ src/shared/doc-preview-scheme.ts | 105 +++ .../workspace-session-browser-schema.ts | 20 +- .../helpers/paired-client-window-reveal.ts | 31 + .../helpers/paired-html-preview-inventory.ts | 341 +++++--- .../paired-remote-html-browser-focus.spec.ts | 459 ----------- ...d-remote-html-preview-local-render.spec.ts | 731 ++++++++++++++++++ 119 files changed, 12735 insertions(+), 1726 deletions(-) create mode 100644 src/main/browser/browser-manager-annotation-bridge.test.ts create mode 100644 src/main/browser/browser-manager-guest-policy-profile.test.ts create mode 100644 src/main/browser/browser-session-partition-policies.test.ts create mode 100644 src/main/browser/doc-preview-download-block-notice.test.ts create mode 100644 src/main/browser/doc-preview-download-block-notice.ts create mode 100644 src/main/browser/doc-preview-failure-notice.test.ts create mode 100644 src/main/browser/doc-preview-failure-notice.ts create mode 100644 src/main/browser/doc-preview-file-reader.test.ts create mode 100644 src/main/browser/doc-preview-file-reader.ts create mode 100644 src/main/browser/doc-preview-grant-registry.test.ts create mode 100644 src/main/browser/doc-preview-grant-registry.ts create mode 100644 src/main/browser/doc-preview-guest-policy.test.ts create mode 100644 src/main/browser/doc-preview-guest-policy.ts create mode 100644 src/main/browser/doc-preview-protocol.test.ts create mode 100644 src/main/browser/doc-preview-protocol.ts create mode 100644 src/main/ipc/browser-preview-tool-authorization.test.ts create mode 100644 src/main/ipc/browser-tab-registration-wait.test.ts create mode 100644 src/main/ipc/doc-preview-grant-ipc.test.ts create mode 100644 src/main/ipc/doc-preview-grant-ipc.ts create mode 100644 src/preload/api/doc-preview-api.ts create mode 100644 src/preload/doc-preview-link-interception.test.ts create mode 100644 src/preload/doc-preview-link-interception.ts create mode 100644 src/preload/doc-preview-link.ts create mode 100644 src/renderer/src/components/browser-pane/annotate/browser-guest-annotate-overlays.tsx create mode 100644 src/renderer/src/components/browser-pane/annotate/guest-annotation-viewport-bridge.ts create mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-address-slot.ts create mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-toolbar.tsx create mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/browser-reload-control.tsx create mode 100644 src/renderer/src/components/browser-pane/host-guest/use-guest-drag-passthrough.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.tsx create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-chip.tsx create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-identity.test.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-identity.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-failure-messages.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-overflow-menu.tsx create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-toolbar.tsx create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-webview-history.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.tsx create mode 100644 src/renderer/src/components/editor/EditorPanelShell.header.test.tsx create mode 100644 src/renderer/src/components/terminal-pane/terminal-remote-file-download-open.ts create mode 100644 src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts create mode 100644 src/renderer/src/lib/browser-workspace-tab-activation.ts create mode 100644 src/renderer/src/lib/doc-preview-grants.test.ts create mode 100644 src/renderer/src/lib/doc-preview-grants.ts create mode 100644 src/renderer/src/lib/execution-host-display-label.ts create mode 100644 src/renderer/src/lib/file-preview-action-visibility.test.tsx delete mode 100644 src/renderer/src/lib/file-preview-capability-cleanup.test.ts create mode 100644 src/renderer/src/lib/workspace-file-host-routing.ts create mode 100644 src/renderer/src/store/slices/browser-workspace-doc-location.test.ts create mode 100644 src/shared/browser-page-doc-location.ts create mode 100644 src/shared/doc-preview-scheme.test.ts create mode 100644 src/shared/doc-preview-scheme.ts delete mode 100644 tests/e2e/paired-remote-html-browser-focus.spec.ts create mode 100644 tests/e2e/paired-remote-html-preview-local-render.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index ac37e3760b9..68dce578bc0 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -2817,16 +2817,16 @@ }, { "id": "browser-session.remote-html-preview-ownership", - "title": "Paired HTML previews keep one host page and one stable client split", + "title": "Paired HTML previews render locally as document browser tabs and create no host browser page", "maturity": "experimental", "protection": "partial", "owner": "browser-runtime", "layer": "renderer-paired-runtime-reconciliation", "surfaces": [ - "paired remote HTML file preview", - "background preview creation and explicit browser activation", - "client-only split placement", - "capability-unknown and mixed-version preview rejection cleanup", + "paired HTML file preview served locally over the orca-preview scheme", + "preview split placement that leaves editor focus alone", + "grant-scoped document reads, including truncation and out-of-worktree refusal", + "capability-unknown and mixed-version browser-creation rejection cleanup", "post-create reconciliation failure and compensating rollback", "remote browser close convergence" ], @@ -2834,8 +2834,9 @@ "providers": ["remote-runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["remote-runtime"], - "coverageNotes": "A headed macOS desktop host and separate paired client exercise the Explorer eye-button path with a real remote file URL, host browser DOM, renderer split layout, background preview creation, explicit browser activation, browser close, forced capability rejection after caller preflight, a forced post-create reconciliation timeout, and navigation held beyond the client create deadline. The same reconciliation fault oracles pass against a live headless server. Unit contracts cover absent, unknown, and mixed-version capabilities, visible rejection, empty-split cleanup, owning-runtime routing, canonical preallocated page identity, navigation-independent acknowledgement, exact client materialization, confirmed rollback, ambiguous cleanup, and teardown. Linux and Windows live journeys remain gaps.", + "coverageNotes": "A macOS desktop host and a separate paired Electron client exercise the Explorer preview action end to end: the document renders inside the client-local orca-preview guest, the client gains exactly one browser workspace and it is the document one, the host's own page registry and tab snapshot gain nothing, the split does not take focus from the source editor, an explicit click activates the preview, and closing it with its X leaves the editor and terminal intact. Both host oracles are read in a run where an ordinary URL browser tab from the same client does arrive in them, because a host that cannot project browser pages at all would otherwise answer the absence question the same way. A second journey quits that client with a document tab open and relaunches it on the same profile, which is the restore the user's original report turned on: the same workspace row comes back blank and named by the document, renders again, and does so over a grant URL that differs from the one that was quit. The same journey pins the guest URL to the orca-preview scheme, so a regression back to file:// or to a host browser page is visible. The rewritten journey caught a live gap the old one could not see: since previews became browser pages they had no chrome to hand focus to their guest, so main refused every reported link click as coming from an unfocused guest and the one route out of a preview was dead. Main's own log answered it — registered true, grant live, focused false — after the embedder reported the anchor really taking six real presses. The pane now hands focus over when it is the reader's surface, and the journey routes. The preview's own components moved out of the editor folder into browser-pane/workspace-doc/ with this change, since a preview is a browser pane and no longer an editor one; their i18n keys keep the historical auto.components.editor.HtmlDocPreview prefix rather than dropping thirty translated strings across four locales for a tidier key path. Seven gate commands named those files by their old paths and were re-issued at the new ones, and the nine dated runs behind them were retired rather than left naming files that are no longer there, on the same principle this gate already applied twice: vitest treats a missing named file as a filter and exits 0, so such a command reports green while silently running only the half that still exists. Each retired record's finding is carried into the replacement run recorded below it, including the red half of the entry/subresource pair, which was re-measured by re-applying its mutant at the new path rather than being dropped. The fence over the page-management channels was decided channel by channel before it was built, and that decision record is: of the 23 channels that can name a page, 6 are allowed for a workspace-document page — the tool channels the reader drives, which reach it through the one authority that reads both halves of the registry — 9 refuse (download accept and cancel, devtools, certificate proceed, client page metadata publish, viewport override, WebAuthn account response, registration probe, registration repair), 3 cannot name a page at all because they take a profile or a worktree instead, and the rest are the registration and activation doors, refused on membership. What shipped is not the per-channel guard that classification implies: eleven call sites read the browsing map directly, so a guard per channel is exactly the caller-flag seam that has to be remembered at every future call site. The registry was split in two instead, which makes the classification structural — a document guest is absent from the browsing map, so every refusing channel refuses by absence rather than by a guard, and a channel added later inherits the refusal without being classified at all. Unit contracts cover grant scoping and path containment, per-owner read routing, refusal of a truncated or over-cap read, the old-server binary degradation, scheme admission on will-attach-webview, the guest navigation policy, the plan layer's local/SSH/paired/out-of-worktree routing, and the distinct sentence the reader sees for each failure reason. The paired browser-creation surfaces this gate still owns — capability rejection cleanup, canonical page identity, reconciliation rollback, close convergence — keep their existing headed and headless journeys. Linux and Windows live journeys remain gaps. A refused subresource is a notice on a rendered document rather than a failure panel, since only the entry document's failure leaves the reader with nothing; the entry document is served as text by every owner and cannot be refused for its format at all. Grants die with the renderer document that minted them, at window creation and on every reload, because the renderer holds the only record of which preview owns which grant. The mint and revoke channels answer only the trusted renderer, and a preview tab is never republished to mobile clients, which have no grant to read it with. External URLs leave the preview only via a trusted user click on an anchor while the preview is focused; script-driven navigation and popups reach nothing. The guest's navigation sinks are deny-only and route no URL at all, and the guest carries a preview-only preload, pinned by will-attach-webview and denied to every other attach path, whose capture-phase click handler reports an anchor's URL to main; main answers only a report from a preview guest with a live bound grant that is the focused contents, and only for http(s). Two earlier reader-facing records from 2026-08-26, which measured a shape where a subresource failure was filtered out and the panel still carried an unsupported-asset sentence, were retired with that shape rather than left contradicting the 10/10 runs of the command they share. Two more were retired on the same principle when the external-link design changed: a record showing that removing a recent-input gate let the unattended document open two browser tabs, and a record showing that dropping the webview's allowpopups attribute left a target=_blank press with nothing to route. Both measured machinery that no longer exists — the input gate could not tell a script's navigation from the click it followed, which is the case now recorded below, and the preload intercepts the press before Chromium considers a popup at all, so allowpopups was removed. WebRTC is fenced by the guest's IP-handling policy alone: this Chromium answers a webrtc content-security-policy directive as unrecognized and gathers candidates anyway, so the directive is deliberately absent from the served policy and a unit assertion keeps it out. A standalone Electron probe on the same version gathered 7 candidates including a STUN reflexive one without the policy and 0 with it. Two command paths this gate used to carry no longer exist, and their structured evidence was retired with them rather than left naming files that are no longer there: vitest treats a missing named file as a filter and exits 0, so such a command reports green while silently running only the half that still exists. The first is src/renderer/src/lib/file-preview-capability-cleanup.test.ts, deleted in STA-5557 when HTML previews stopped creating paired browser tabs: on 2026-08-12 its five-assertion STA-4063 oracle ran red 5/5 on scan e4c278eb52 and on main bc2e30000b, partially green 2/5 on pre-fix PR #14100, and green 5/5 on candidate 14b5877a75; the surviving half of that oracle is web-runtime-browser-capability-cleanup.test.ts, still commanded above. The second is tests/e2e/paired-remote-html-browser-focus.spec.ts, renamed in STA-5557 to paired-remote-html-preview-local-render.spec.ts because the journey no longer proves anything about browser focus: its 2026-08-12 runs established the owning-runtime routing and no-focus-steal behavior of the then-current browser-tab preview (red on main 346e59c879 and a90a18d43d, green on candidates d05217c40d and b7de9c0961, red again with only the side-preview focus or routing seam disabled), and its 2026-08-26 runs are recorded below under the current path. An adversarial pass after the journey landed found four coverage gaps rather than shipping defects, and all four are pinned here. The page url had a third door: the navigation commit wrote whatever url was reported, unfenced, and while nothing reaches it for a document page today it sat one report away from giving a document page a URL that persistence, the publish boundary and the address bar all read. The group projection was the load-bearing half of the mobile publish boundary that no test had: removing it alone left the whole suite green, because no test in it had tab groups, while the phone payload gained a group whose order and active tab named a document tab it was never sent. The focus handoff was covered only in the narrowing direction, so widening it — dropping the page-activity check, or either half of the reader-surface check — survived both suites. And the handoff itself was offered once and never again, so another app taking the front left the preview's one route out shut until the pane remounted, which is a desktop gap rather than a test-only one.", "motivatingLinks": [ + "https://linear.app/stably/issue/STA-5557/remote-html-preview-render-workspace-files-locally-over-the-file", "https://linear.app/stably/issue/STA-3872/unable-to-open-html-files-in-remote-server", "https://linear.app/stably/issue/STA-4025/p1-browser-create-reports-success-when-tab-reconcile-fails-after-pr", "https://linear.app/stably/issue/STA-4063/p1-paired-html-preview-rejects-outside-cleanup-after-pr-13909", @@ -2843,19 +2844,35 @@ "https://github.com/stablyai/orca/pull/14363", "https://github.com/stablyai/orca/pull/13876" ], - "invariant": "Opening one HTML preview from a paired client succeeds only after exactly one browser page on the worktree-owning runtime materializes as exactly one client browser workspace and unified tab in the requested group. Owner-pinned creation returns the canonical host page identity before navigation readiness; delayed navigation cannot turn a created page into an unidentifiable failure or a duplicate retry. Capability rejection before host mutation must preserve the original error, issue no RPC, surface a failure toast, and remove only a caller-declared newly-created empty split. Post-create reconciliation failure requires exact rollback; ambiguous rollback rejects without local fallback. Preview focus and close preserve the source editor and terminal.", - "oracle": "Run one unchanged contract oracle for direct create and side-preview callers with absent status, unknown capabilities, and a mixed-version host. Require the original unsupported error or visible toast, zero RPCs, and no retained new split. Run it on the scan target, latest main, candidate, and candidate with the cleanup boundary disabled. In headed paired Electron, arm capability rejection after the caller preflight, invoke the visible HTML side-preview action, require the failure toast, and compare exact host/client browser, editor, terminal, and group inventories to baseline. Hold a real navigation response beyond the 15-second client deadline after host creation; require the first RPC to return the exact host inventory page ID, one host page, and no retry. Repeat reconciliation faults against headless serve. Retain the separate exact-page reconciliation rollback oracle.", + "invariant": "Opening one HTML preview from a paired client renders the workspace document in exactly one client-local browser tab, located by that document and served over the orca-preview scheme. The client gains exactly that one browser workspace and it is the document one — blank where a URL page carries a URL, named by the document, with the chip naming the file — while the host gains no browser page at all, neither in its own page registry nor in the tab snapshot its clients publish into. The preview occupies its own split without taking focus from the source editor; an explicit click activates it, and closing it removes only the preview. Following a document from a file link is the other half of that switch and does move the reader to it, tab group included, whether the preview is new or already open, because opening a file is a request to look at it. A document tab quit with the client comes back as the same row on a grant the relaunched client mints afresh. A preview is named by the browser page it is open in, not by a namespace of its own, and the page registry has two halves: a workspace-document guest is registered in its own map and is absent from the browsing one entirely. That absence is the fence. Page, session and profile management, agent tab enumeration and command targeting, download routing and certificate attribution all read the browsing map directly, in more places than a per-channel guard could be remembered in, so none of them can name a document page and none of them carries a guard. Browser tools the reader drives (element grab, hover describe, selection capture, the annotation viewport bridge) are the one operation that legitimately spans the halves, and they go through the single authority that reads both, keyed by the page and its hosting renderer. The halves are disjoint in both directions: browsing registration refuses a page the document half already holds, and minting a grant refuses a page the browsing half already holds, so one id can never name a surface in both. The headless backend acts on that refusal by destroying the window it had already opened rather than leaving a policy-less page behind an id nothing can drive, keeping nothing under that id for its own shutdown to hand back. Registration refuses on the same terms when the guest it was asked about is already gone. The exit door is guarded in both its halves: a preview withdraws by revoking its grant and never through the unregister channel, so a page the document half holds arriving there is refused before either the registration teardown or the grab-state disposal beside it, which would otherwise drop the intent an in-flight preview grab compares by identity and leave that grab answering ok without ever arming its guest. A bridge request whose guest does not resolve is refused without tearing down the page it named, so a misaddressed request cannot cancel a healthy page's in-flight downloads and grabs. The annotation viewport bridge resolves its guest when its serialized op actually runs rather than when the request arrived, so a cross-process navigation while it waited cannot leave the bridge installed in a retired guest while the reader looks at a new one. State main keys by a preview's page is disposed when that page's grant is revoked, which is the only signal a preview's surface is gone. A tool asking for a page whose guest has not attached yet waits for that registration and arms when it arrives, rather than answering not-ready at the reader; that wait resolves only the request already naming this page, never the worktree-wide or any-tab waits the CLI and agents use to ask for a browser tab to drive. Handing the previewed document to the reader's own machine routes on the owners its grant was minted against — the file's own connection owner and the worktree's own runtime owner, neither read from the tab's stored fields. Only a document proven to live on this machine reaches the client OS; one with a resolved remote owner is downloaded first; and one whose owner cannot be resolved at all, workspace root included, is refused with a message naming that, because the download route would otherwise read the same absolute path on the client and hand back a same-named local file under the remote document's name. A runtime-owned path that falls outside its worktree root is refused by that route itself and surfaces as a failure toast rather than a download. Nothing the document does writes a file to this machine either: the preview partition denies downloads outright instead of routing them through the browser download flow, which has no page to attribute a preview's bytes to and would otherwise reserve a name in this desktop's Downloads folder and write them there unprompted. That refusal is visible to the reader and invisible to the document: the preview's shell carries a fixed sentence saying downloads are off, published at most once per preview per interval so a document asking in a loop cannot fill Orca's chrome, while the page itself gets back exactly what it got before, which is nothing. The sentence names no file, because the document chooses the name it offers; and a refusal never takes the document away the way an entry document's own failure does, whatever it names. A preview is a browser tab, not an editor tab in a preview mode: it is named the way a browser tab is named — by the document it shows when that document declares a title, and by the file it shows when it does not — while the chip goes on naming the file and the host whatever the document calls itself. A title is refused on the same terms the url is: a document that declares none has Chromium report the grant URL as its title, and that title is stored, mirrored onto the tab and written to disk, so anything carrying the scheme falls back to the file instead. It is created by the preview action as a page located by its document, it carries the workspace-relative path copy the editor's path header owned, and closing it revokes the grant that made the document readable while a URL tab closing beside it revokes nothing. Chrome persisted by builds that made previews editor tabs is dropped on restore rather than coming back naming a surface no restore can produce, and the ordinary editor tab for the same document is left alone. A document tab is held back at the mobile publish boundary — no client holds its grant, and the wire has no tab kind for it — while an ordinary browser tab beside it still publishes. It is held back from the group projection that publishes tab order, recency and group activity as well as from the tab list itself, so no published group names a tab the phone is never sent. A browser page can be located by a workspace document instead of a URL, and the document is the whole of its stored identity. The grant and the orca-preview URL that document is served over are minted when the page mounts and replaced by a hard reload, so neither is ever written to the page's url, mirrored onto its tab, persisted or published: such a page's url is the blank URL from creation through restore, including when a session written elsewhere carries a grant URL in, and what the session carries is the worktree and path a restored page mints afresh against today's owners. Every door onto a page's url holds that line — creation, the title update, and the navigation commit alike — so a report about a document page cannot give it a URL it never had, and the title fallback and the loading affordance follow the url each door actually wrote. The mirror carries the document too, so a tab entry cannot go on naming a document its active page has left. Every guest in the app is policy-attached through one door: a workspace document takes a restricted profile there rather than a separate installer beside it, so the attachment bookkeeping that door owns — what registration refuses, and what teardown frees — covers a preview on the same terms as a browsing page, and a preview takes none of the browsing machinery that door installs. That authority answers from the moment the embedder hands the guest over rather than only after a later navigation: the guest binds to the grant it is already showing, so the tools reach the document the reader opened and not just one they navigated to. A read the host reports as truncated or over-cap is refused rather than served partially, and a document outside the paired worktree is refused with a message naming that boundary instead of a bare read failure. The rendered document reaches nothing off-machine on its own: every served response carries a self-only content security policy, the preview session cancels any request that is not in-document, subframes cannot navigate outside the grant, a guest no document has yet bound to a grant may not navigate at all, the guest gathers no ICE candidates, and an SSH path that canonicalizes outside the grant root is refused before it is read. The one route out is a link the reader presses: a trusted click on an anchor, reported by the preview's own preload from a guest that holds focus and is still bound to a live grant, leaves as an Orca browser tab rather than a native window or a dead click. The preview hands its guest that focus itself whenever it is the surface the reader is in — a browsing page gets it from the chrome around it, and a preview has no chrome to get it from — and it does so only then, so a preview mounted behind a terminal or an editor never takes the keyboard from what the reader is actually in. It offers again when the window itself takes focus back and nothing in the embedder has claimed that focus, because another app coming to the front lands focus on the embedder rather than the guest and the route out would otherwise stay shut until something remounted the pane — while the same window focus also arrives when the reader presses a tab, that being the guest's own blur returning, and taking focus back from there would fight the reader for their own click. Nothing else does. A navigation or popup the document starts by itself is swallowed whatever else is happening, including immediately after a genuine press elsewhere in the document, so a page that can read its grant cannot hand it to a browser tab; a middle click opens nothing; and a fragment link is answered inside the document. A preview attach carries the preview preload and no renderer-supplied one, and no other attach path can acquire it. A subresource the workspace will not send degrades the document to a notice naming that file, never to a failure panel over a page that rendered. A grant outlives neither the tab that owns it nor the renderer document that minted it, and only the trusted renderer can mint or revoke one. For the browser creations this gate still owns, owner-pinned creation returns the canonical host page identity before navigation readiness; delayed navigation cannot turn a created page into an unidentifiable failure or a duplicate retry. Capability rejection before host mutation must preserve the original error, issue no RPC, surface a failure toast, and remove only a caller-declared newly-created empty split. Post-create reconciliation failure requires exact rollback; ambiguous rollback rejects without local fallback.", + "oracle": "In paired Electron, write an HTML fixture that declares its own title on the host, invoke the Explorer preview action on the client, and require the document text to be readable out of the orca-preview guest before judging any absence. With that presence established, require the client to hold exactly one browser workspace more than its baseline and that workspace to be the document one: page and tab url blank, the document path mirrored onto both, the tab named by the document's title, the chip naming the file, no editor row of the retired preview species anywhere, and the guest URL carrying the orca-preview scheme. Ask the host through its own page registry as well as through the tab snapshot, and in the same run open an ordinary URL browser tab from the same client and require that one to arrive in both — the presence precondition without which “the host gained nothing” is satisfied just as well by an oracle that cannot see browser pages at all. Require the preview to sit in a group other than the source editor's while the active group and tab remain the source editor's. Then click away to the terminal, click the preview tab, require it to reactivate and still render, close it with its own X, and require the document tab to be gone while the host still holds only the URL tab and the source group, source editor and terminal survive. Quit the client with a document tab open and relaunch it on the same profile: require the same workspace row to come back, blank and named by the document, rendering the document again over a grant URL that differs from the one that was quit, with no preview-scheme or document-named page anywhere in what the host holds. Prove the halves are live by flipping one product property at a time and requiring the run to fail: publish document workspaces to the host like ordinary ones, and stop mirroring the document onto the workspace row. Have the fixture document attempt its own egress on every load — an unattended window.open and location.href to an off-machine URL, plus an inline ICE gathering probe — and require the same baseline counts and a candidate count of zero, so the document's own attempts are measured rather than assumed. Then, as a separate phase after the close oracle has already run, bring the client window to the front, press the document's heading with a real mouse event, and require the document to report that the same press drove it to attempt a second window.open and location.href while both browser counts stay at that phase's baseline and nothing routes — the case a recent-input gate cannot distinguish from the press's own effect. Only then press the target=_blank link with a real mouse event and require both a recorded routing call that returned success and a browser count above that baseline, with the preview tab still open. Drive the preload's click policy as a unit oracle over a real document: a dispatched click, a trusted press on an external anchor, an anchor reached through what it wraps, an SVG animated href, a sibling preview link, fragment and percent-encoded fragment targets, a bare hash, and a middle click. Create a browser page located by a workspace document, handing creation a live grant URL, and require its stored url, its mirrored tab url and the written session payload all to be blank with no orca-preview string anywhere in what was written, while an ordinary page created the same way keeps the URL it was given and asks for the address bar the document page never does. Parse the written page and tab through the session schema and require the document to survive both halves. Hydrate them back and require the document page to return blank and still named — including when its page row was salvaged away and only the tab's own copy remains, and when a foreign session carried a grant URL into both rows. Drive the mirror across a page switch out of the document and back, and across a repair in which the document is the only mirrored field that differs. Name a document page from its document and require the tab to take that name, name it with an empty title and require the file, name it with a live grant URL and require the file again with no preview scheme anywhere in the written session, and require an ordinary blank browser tab beside it to still be called New Tab. Dispatch a title update out of a rendered preview's own guest and require it to reach the page state while the identity chip still reads the document's workspace-relative path. Attach a browsing guest and a workspace-document guest through the same method in one run and require the browsing one to take clicked-link routing, popup handling and anti-detection while the document guest takes none of them, stays inside the grant it is showing, denies every window it asks for, and is dropped from the page-keyed document registry by the same teardown that frees its id for a later attach. Register a browsing guest and attach a workspace-document guest in one run against the real manager, require the one door to answer each page with the guest of its own half, and require the document page to be absent from the browsing map and from its enumeration. Drive both browsing registration entry points with a page the document half already holds and require them to register nothing, and drive the mint channel with a page the browsing half already holds and require it to refuse; and drive the offscreen one with a guest that is missing and with one already destroyed, requiring the same refusal. Arm a grab on a live preview target, drive the unregister channel at that same target in the window before the queued operation runs, and require the grab to reach the guest anyway — then drive the same sequence for an ordinary browser page and require its grab state to be disposed after all. Hold one viewport-bridge op open, queue a second behind it, swap the page's guest while that second op waits, and require the injection to land in the guest the page has then. Revoke a grant after a tool has run against its page and require that page's grab state to be cancelled and disposed. Ask a tool for a document page whose guest has not attached, require the request to park in the registration wait, attach the guest, and require the same request to arm on it; require a page nothing ever renders to answer not-ready once that wait elapses. With a document open, ask a tool for a browsing page id and require it to be answered by the browsing half or not at all, with the same channel reaching the document guest under the page it really renders. Drive open-externally for a document whose per-file owner is remote while the workspace-scoped owner is unresolved, for a runtime-owned worktree whose preview tab carries no runtime id of its own, and for a worktree that resolves no runtime owner while the tab still carries one, requiring the download route in each; and for an owner that cannot be resolved at all, and for an unknown workspace root while nothing names another host, requiring a refusal that neither opens nor downloads. Drive the headless backend with a page the document half already holds and require it to reject, destroy the window, and unregister nothing — then shut the backend down and require it still to have unregistered nothing. Navigate a bound preview guest at a second grant through both latch events and require it to stay on the grant it bound to. Mount a preview while a renderer drag is already in flight and require its guest to be click-through at the moment it is appended, not a turn later. Render the editor panel shell in each remaining tab mode and require the path header exactly where the surface does not already name itself. Drive the preview action and require a browser tab located by the document rather than an editor tab, require a second open of the same document to activate the tab it is already in, and require closing that tab to revoke its grant while a URL tab closed beside it revokes none. Hydrate a session carrying preview chrome from a build that made previews editor tabs and require it dropped while the ordinary editor tab for the same document survives. Publish a worktree holding a document tab and a URL tab and require only the URL tab to reach the mobile snapshot. Install the shared partition policies for a preview partition and for an ordinary browsing partition in the same run, fire each one's own will-download listener, and require the preview's to cancel while the browsing one still reaches the download router — then require the preview protocol installer to be what asks for that deny. In the same run, require the cancelled download to raise a reader-facing notice and the routed one to raise none. Drive that notice directly for a guest bound to a live grant, for repeated attempts inside and outside its interval, for two previews at once, and for a contents no preview is bound to; require the guest registry to name the bound grant for a live preview guest and nothing for a contents that is not one, has committed no document, or is gone. Drive the shell with a refusal and require one fixed sentence, still one row after three more refusals, standing beside an asset failure rather than being counted with it, gone behind the failure panel, and ignored when it names another preview's grant. Drive the main-side report gate directly for an unfocused guest, a sender that is no preview guest, a guest with no bound or a revoked grant, and non-web URLs, and drive will-attach-webview in both preload directions. Run the per-owner reader, grant-containment, scheme-admission, guest-policy, and plan-routing contracts as unit oracles, including a host-reported truncation, an over-cap binary, and an out-of-worktree paired path. Drive the reader-facing component with the payloads the reader can actually produce — the entry document fails only as truncated or unreadable, a subresource additionally as a refused format — and require the asset case to leave the guest mounted. Drive the closed-tab cleanup hook, the window installer, and the grant IPC handlers directly, requiring the grant to be released when the preview tab closes, cleared at window creation and on a cross-document main-frame navigation, and refused to any sender that is not the trusted renderer. For the browser creations this gate still owns, run the unchanged contract oracle for direct create and side-preview callers with absent status, unknown capabilities, and a mixed-version host, requiring the original unsupported error or visible toast, zero RPCs, and no retained new split; hold a real navigation response beyond the 15-second client deadline after host creation and require the first RPC to return the exact host inventory page ID, one host page, and no retry; repeat reconciliation faults against headless serve and retain the separate exact-page reconciliation rollback oracle.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-browser.test.ts src/main/runtime/rpc/methods/browser.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/runtime/web-session-browser-placement.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/runtime/remote-server-parity.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-materialization.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts src/renderer/src/lib/file-preview-capability-cleanup.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/doc-preview-grant-registry.test.ts src/main/browser/doc-preview-file-reader.test.ts src/main/browser/doc-preview-protocol.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/browser/doc-preview-failure-notice.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/lib/doc-preview-grants.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/lib/file-preview-action-visibility.test.tsx src/main/window/main-window-webview-security.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-session-partition-policies.test.ts src/main/browser/doc-preview-protocol.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/doc-preview-download-block-notice.test.ts src/main/browser/browser-session-partition-policies.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", + "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-manager-guest-policy-profile.test.ts src/main/window/main-window-webview-security.test.ts src/main/browser/doc-preview-guest-policy.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/lib/file-preview.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx src/shared/doc-preview-scheme.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts", + // The hardening round: the third url door, the group projection, and both directions of the focus handoff. + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", + "pnpm exec vitest run --config config/vitest.config.ts src/preload/doc-preview-link-interception.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/window/main-window-webview-security.test.ts src/main/ipc/doc-preview-grant-ipc.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/ipc/browser.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/editor/EditorPanelShell.header.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/editor/EditorPanelShell.header.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/doc-preview-grant-ipc.test.ts src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/store/slices/tabs-hydration.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/ipc/browser-tab-registration-wait.test.ts src/main/ipc/doc-preview-grant-ipc.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/main/browser/browser-manager-guest-policy-profile.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line --repeat-each=3", + // The changed-spec e2e job's own command, which is the only Linux run this gate has. + "npx playwright test --config tests/playwright.config.ts --project=electron-headless tests/e2e/paired-remote-html-preview-local-render.spec.ts --workers=1", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-browser.test.ts src/renderer/src/runtime/web-runtime-session.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/orca-runtime-browser.test.ts src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts src/renderer/src/hooks/useIpcEvents-close-routing-session-tabs.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/runtime/web-runtime-session.test.ts", "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-browser-create-navigation-deadline.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", - "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=3", - "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line", "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line" ], @@ -2863,7 +2880,6 @@ "src/main/runtime/orca-runtime-browser.test.ts", "src/main/runtime/rpc/methods/browser.test.ts", "src/renderer/src/lib/file-preview.test.ts", - "src/renderer/src/lib/file-preview-capability-cleanup.test.ts", "src/renderer/src/runtime/web-session-browser-placement.test.ts", "src/renderer/src/runtime/web-runtime-browser-materialization.test.ts", "src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts", @@ -2874,7 +2890,38 @@ "src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts", "src/renderer/src/hooks/useIpcEvents-close-routing-session-tabs.test.ts", "src/renderer/src/store/slices/browser.test.ts", - "tests/e2e/paired-remote-html-browser-focus.spec.ts", + "src/main/browser/doc-preview-grant-registry.test.ts", + "src/main/browser/doc-preview-file-reader.test.ts", + "src/main/browser/doc-preview-protocol.test.ts", + "src/main/browser/browser-session-partition-policies.test.ts", + "src/main/browser/doc-preview-download-block-notice.test.ts", + "src/main/browser/browser-manager-guest-policy-profile.test.ts", + "src/main/browser/doc-preview-guest-policy.test.ts", + "src/main/ipc/browser-tab-registration-wait.test.ts", + "src/main/browser/doc-preview-failure-notice.test.ts", + "src/renderer/src/store/slices/browser-workspace-doc-location.test.ts", + "src/preload/doc-preview-link-interception.test.ts", + "src/main/window/main-window-webview-security.test.ts", + "src/main/ipc/browser-preview-tool-authorization.test.ts", + "src/main/browser/browser-manager-annotation-bridge.test.ts", + "src/main/browser/browser-manager-guest-lifecycle.test.ts", + "src/main/browser/offscreen-browser-backend-lifecycle.test.ts", + "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts", + "src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts", + "src/renderer/src/components/editor/EditorPanelShell.header.test.tsx", + "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx", + "src/shared/doc-preview-scheme.test.ts", + "src/renderer/src/lib/doc-preview-grants.test.ts", + "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", + "src/renderer/src/lib/file-preview-action-visibility.test.tsx", + "src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts", + "src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts", + "src/main/ipc/doc-preview-grant-ipc.test.ts", + "src/renderer/src/store/slices/tabs-hydration.test.ts", + "src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx", + "src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts", + "tests/e2e/paired-remote-html-preview-local-render.spec.ts", "tests/e2e/paired-browser-create-navigation-deadline.spec.ts", "tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts" ], @@ -2886,18 +2933,13 @@ "direct creation removes the caller-declared new split without issuing an RPC" ] }, - { - "file": "src/renderer/src/lib/file-preview-capability-cleanup.test.ts", - "assertions": [ - "known unavailability reports visibly without creating a split", - "capability loss after caller preflight reports visibly without duplicating transaction cleanup" - ] - }, { "file": "src/renderer/src/lib/file-preview.test.ts", "assertions": [ - "a failed side preview reports visibly without caller-owned split cleanup", - "overlapping side previews reuse the split without the first failure deleting it" + "a paired-runtime HTML preview opens a local doc preview instead of a runtime browser tab", + "a local workspace still opens the embedded browser tab at a file URL", + "a paired document outside the worktree names that boundary and creates no split", + "an SSH document outside the worktree root still previews" ] }, { @@ -2973,11 +3015,152 @@ ] }, { - "file": "tests/e2e/paired-remote-html-browser-focus.spec.ts", + "file": "tests/e2e/paired-remote-html-preview-local-render.spec.ts", "assertions": [ - "one HTML open produces one client workspace, one client unified tab, and one host page", - "the source editor and remote frame remain visible in separate groups", - "preview creation leaves the editor active, an explicit browser click is authoritative, and close preserves the editor and terminal" + "the host's HTML document renders inside the client-local orca-preview guest", + "neither the client browser-workspace count nor the host browser-tab count moves off baseline", + "the preview opens in its own split with mode html-preview and leaves the source editor active", + "an explicit click reactivates the preview, and closing it preserves the source editor, its group, and the terminal", + "the document reports having attempted both its unattended and its post-press egress, so neither absence assertion can pass on a document that never tried", + "a scripted window.open and location.href driven by a real press elsewhere in the document route nothing and move no browser count", + "a real target=_blank press in the rendered document routes out as an Orca browser tab while the preview stays open" + ] + }, + { + "file": "src/main/browser/doc-preview-file-reader.test.ts", + "assertions": [ + "a host-reported truncated read is refused instead of served as partial bytes", + "an over-cap binary and an empty binary body are reported as too-large and unsupported rather than a bare read failure", + "a document outside the paired worktree is refused without issuing an RPC", + "an SSH path that canonicalizes outside the grant root is refused before any read", + "a 0-byte file the host still typed is served as 0 bytes rather than reported as a refused format" + ] + }, + { + "file": "src/main/browser/doc-preview-grant-registry.test.ts", + "assertions": [ + "a path escaping the grant root resolves to nothing, including sibling-prefix roots", + "a revoked grant stops resolving" + ] + }, + { + "file": "src/main/browser/doc-preview-protocol.test.ts", + "assertions": [ + "an unknown or revoked grant is answered exactly like a missing file, with no read attempted", + "a failed read pushes its reason for the requested path while a served document pushes nothing", + "every served document carries the self-only content security policy", + "the preview session cancels any request that is not in-document", + "the scheme is handled on the preview session and never on the default one", + "the scheme claims exactly its five privileges, so a widened one fails" + ] + }, + { + "file": "src/main/browser/doc-preview-failure-notice.test.ts", + "assertions": ["413 and 415 map to distinct reasons and everything else stays generic"] + }, + { + "file": "src/renderer/src/lib/file-preview-action-visibility.test.tsx", + "assertions": [ + "the Explorer row predicate and the preview plan return one answer for the same file" + ] + }, + { + "file": "src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts", + "assertions": [ + "an external preview link becomes a browser tab, and a tab that is refused or that fails outright surfaces instead of being dropped" + ] + }, + { + "file": "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", + "assertions": [ + "each entry-document failure reason surfaces its own sentence instead of one generic unavailable panel", + "a refused subresource names itself in a notice, deduplicated per path, while the document stays rendered", + "an entry-document failure replaces the asset notice with the panel", + "a failure for another preview's grant leaves the rendered document alone" + ] + }, + { + "file": "src/main/browser/doc-preview-guest-policy.test.ts", + "assertions": [ + "the guest may only navigate within its own live grant, and not at all until a document has bound it to one", + "every navigation and popup sink refuses without routing, whenever genuine input happened", + "a subframe cannot navigate outside the grant, and only the main frame binds the guest to one", + "a reported click routes exactly once, and only from a focused preview guest with a live bound grant", + "a reported click on a non-web URL, or from a destroyed guest, routes nothing", + "the guest is denied non-proxied WebRTC UDP when its policy is installed" + ] + }, + { + "file": "src/preload/doc-preview-link-interception.test.ts", + "assertions": [ + "a click the document dispatched itself is not trusted and routes nothing", + "a trusted press on an external anchor reports it once and stops the navigation, including through what the anchor wraps and an SVG animated href", + "a fragment link scrolls in the document, raw or percent-encoded, and a bare hash goes to the top", + "a sibling preview document is left to the guest's own navigation, and a middle click opens nothing whether or not it is trusted", + "an SVG link is resolved against the document, so its relative, rooted and fragment forms behave exactly like the identical HTML anchor", + "the preload reports on exactly the channel main listens on" + ] + }, + { + "file": "src/main/window/main-window-webview-security.test.ts", + "assertions": [ + "an orca-preview src is admitted only on the preview partition with a live grant", + "preview guests receive their own policy instead of the browser guest policies", + "a preview attach is pinned to the preview preload over any renderer-supplied value, and a browsing attach never receives it", + "grants a renderer can no longer release are cleared at window creation and on reload, and survive an in-document navigation" + ] + }, + { + "file": "src/shared/doc-preview-scheme.test.ts", + "assertions": ["preview URLs round-trip per segment and reject a malformed grant id"] + }, + { + "file": "src/renderer/src/lib/doc-preview-grants.test.ts", + "assertions": [ + "grants are deduped per preview tab so a StrictMode double-mount cannot revoke the surviving webview", + "a mint that rejects after its tab was released and reopened leaves the reopened tab's grant revocable" + ] + }, + { + "file": "src/main/ipc/doc-preview-grant-ipc.test.ts", + "assertions": [ + "a sender that is not the trusted renderer mints no grant and revokes none", + "a reported link click reaches the guest policy with its own sender, and a non-string report reaches nothing" + ] + }, + { + "file": "src/renderer/src/store/slices/browser-workspace-doc-location.test.ts", + "assertions": [ + "closing a document browser tab revokes its grant, and closing a URL tab beside it revokes nothing", + "a document page's url is blank in the store, in the written session and after restore", + "a navigation committing a url onto a document page leaves the url blank, the document title standing and nothing loading, while an ordinary page beside it takes the url it is given" + ] + }, + { + "file": "src/renderer/src/store/slices/tabs-hydration.test.ts", + "assertions": [ + "chrome for a preview tab written before previews were browser tabs is dropped, and the ordinary editor tab for the same document is kept" + ] + }, + { + "file": "src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts", + "assertions": [ + "a browser tab located by a workspace document is not published to mobile clients, while an ordinary browser tab beside it is", + "with tab groups in play the document is kept out of the published groups too: no group's order, recency or active tab names a tab the snapshot does not carry, the held-back group leaves the published layout tree with it, and the grouped URL tab beside it still publishes" + ] + }, + { + "file": "src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx", + "assertions": [ + "the pane offers its guest focus when the reader is in this very preview", + "it refuses while the reader is in a terminal in front of it, in a different browser tab, or while its own page is not the active one" + ] + }, + { + "file": "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "assertions": [ + "a preview that is the reader's surface hands its guest focus, and one that is not leaves focus alone", + "the window taking focus back re-offers it to the guest, and re-offers nothing for a preview that was refusing" ] }, { @@ -2999,22 +3182,301 @@ ], "evidenceRuns": [ { - "date": "2026-08-12", + "date": "2026-08-27", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts src/renderer/src/lib/file-preview-capability-cleanup.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", "result": "failed", - "durationSeconds": 0.72, - "summary": "The unchanged STA-4063 oracle failed 5/5 on scan e4c278eb52 and latest main bc2e30000b: direct create never closed the new split, and side-preview rejection produced neither cleanup nor a toast. Pre-fix PR #14100 passed the two caller cases but still failed all three direct transaction cases." + "durationSeconds": 7, + "summary": "The adversarial pass answered the request for its own group-projection probe after this round landed, and it had measured a third observable this test was missing: the held-back group also has to leave the published layout tree, or the phone renders a split whose second pane can never hold anything. Adopted as a fourth assertion and re-run against the same sole delta, which now fails on the dangling-reference check and, with that check blinded, on the layout alone — group-left and group-right against the group-right the clean tree publishes. The two probes agreed on everything else, including that the tab list is unchanged under this mutant, which is why an assertion reading only the tab list cannot reach the site at all." }, { - "date": "2026-08-12", + "date": "2026-08-27", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts src/renderer/src/lib/file-preview-capability-cleanup.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "result": "passed", + "durationSeconds": 3, + "summary": "The adversarial pass's four coverage findings, landed and pinned: 58/58. The third url door now keeps a document page blank and names it by its document, the group projection holds a document back from published groups as well as from the tab list, the pane's focus handoff is pinned in the widening direction as well as the narrowing one, and the handoff is re-offered when the window takes focus back." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "result": "failed", + "durationSeconds": 3, + "summary": "Three sole-delta mutants on the third url door, each failing only the new store test: committing the reported url onto a document page, dropping the document from the title normalizer there (which renames the tab New Tab, since a blank url is what that fallback reads), and marking a blank document page loading. The ordinary-page half of the same test passes under all three, so the door is pinned as a document-only refusal rather than as a door that writes nothing." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "result": "failed", + "durationSeconds": 3, + "summary": "Two sole-delta mutants on the mobile publish boundary. Dropping the document from the group projection's browser ids fails only the new grouped test — the tab list stays correct because the per-tab guard still refuses the tab, and what breaks is a published group whose order and active tab name a tab the phone is never sent. This is the survivor the adversarial pass reported: it survives the whole suite without this test, because no other test in it has tab groups. Dropping the per-tab guard instead fails only the ungrouped test, so the two halves of the boundary are pinned separately." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "result": "failed", + "durationSeconds": 3, + "summary": "Three sole-delta mutants widening the focus handoff, the direction the adversarial pass found uncovered: dropping the page-activity half, dropping the active-tab-type half, and dropping the active-browser-tab half each fail exactly one of the pane's refusals — the inactive page, the terminal in front, and the other browser tab respectively. The presence half of that test passes under all three, so a pane that had stopped offering focus at all would still fail rather than pass the refusals vacuously." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "result": "failed", + "durationSeconds": 3, + "summary": "One sole-delta mutant dropping the window-focus re-offer: the guest is focused once at mount and never again after the window comes back, which is the desktop gap behind it — another app taking the front leaves the preview's only route out shut until the pane remounts. It fails only the new re-offer test, and the refusing-preview case beside it stays green, so the re-offer is pinned as gated rather than unconditional." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "passed", + "durationSeconds": 186, + "summary": "The rewritten journey passed 2/2. The first test proves the inverted claim: the client gains exactly one browser workspace and it is the document one — page and tab url blank, the document path mirrored onto both, the tab named by the document's own title, the chip still naming the file, no editor row of the retired preview species — while the host's page registry and tab snapshot both stay at their pre-action content. Both host oracles carry a presence precondition in the same run: an ordinary URL browser tab this client opens does arrive in browser.tabList and in session.tabs.list, and leaves both again when it is closed, so the preview's absence is a decision rather than an oracle that cannot see browser pages. The document still reaches nothing on its own (zero ICE candidates, unattended and post-press egress both swallowed), and the one route out still works: a real press on a target=_blank anchor routes exactly one call and opens a tab while the preview stays open. The second test quits the client with a document tab open and relaunches it on the same profile: the same workspace row comes back blank and named by the document, renders again, and does so over a grant URL that differs from the one that was quit." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "failed", + "durationSeconds": 108, + "summary": "Candidate with the sole delta that the preview action also opens an ordinary browser tab through the runtime — what it did before STA-5557 — failed at the client-workspace count, 2 against the 1 the claim allows. It dies there rather than at the host oracle, so it proves the exactly-one-workspace half; the host half has no single line to mutate, since a document page never calls the runtime at all, and is protected instead by the presence precondition recorded above." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "failed", + "durationSeconds": 190, + "summary": "Candidate with the sole delta that the workspace row stops mirroring the document its active page shows failed in both tests on workspaceDocFilePath, null against the document path — the tab entry would go on naming nothing while the page it renders is a document. Two earlier mutants survived and are reported as ill-posed rather than as blind spots: flipping the mobile publish filter changes what this desktop publishes to its own phone clients, not what a paired client puts on its host, and flipping the document mirror in the workspace constructor is repaired on the next state write by the active-page mirror, which is the line the mutant above hits." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "failed", + "durationSeconds": 195, + "summary": "Candidate with the sole delta that the preview pane does not hand focus to its guest — the tree as it stood before this change — failed at the target=_blank press: six real presses reached the anchor, the embedder had the webview as its active element, and main's own log answered each report with registered true, grant live, focused false. This is the gap the rewrite found: since previews became browser pages they had no chrome to pass focus on, so the one route out of a preview was shut." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/doc-preview-grant-registry.test.ts src/main/browser/doc-preview-file-reader.test.ts src/main/browser/doc-preview-protocol.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/browser/doc-preview-failure-notice.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/lib/doc-preview-grants.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/lib/file-preview-action-visibility.test.tsx src/main/window/main-window-webview-security.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts", + "result": "passed", + "durationSeconds": 1.37, + "summary": "174/174 over the grant, reader, protocol, guest-policy, scheme-admission, plan-routing, action-visibility, failure-message, external-link and terminal-row contracts, re-issued at the relocated preview paths. This replaces the 2026-08-26 record of the same thirteen files at 152/152, whose command named the pre-relocation paths: it established the stale-mint rejection that must not evict a reopened preview tab, the CSP header and session request filter, the SSH symlink refusal, subframe containment, truncated and over-cap reads, the unservable-asset and empty-file split, the out-of-worktree paired refusal with SSH left unrestricted, reload re-minting a stale grant, and the guest deny-only navigation sinks. The count moved with the cases added since; nothing in those files changed but their directory." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/doc-preview-download-block-notice.test.ts src/main/browser/browser-session-partition-policies.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", + "result": "passed", + "durationSeconds": 1.78, + "summary": "64/64 on the visible download refusal, re-issued at the relocated path. This replaces the 2026-08-27 record of the same four files at 62/62, whose ten mutants — removing the notice from the deny listener, adding it to the routing listener, dropping the guest-registry lookup that names which preview to tell, dropping the shell branch and the row, letting the row survive the failure panel, removing the per-preview throttle, sharing one throttle across previews, never expiring the window, and publishing for a contents no preview is bound to — all failed as sole deltas. The relocation moved no line of any of them." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx src/shared/doc-preview-scheme.test.ts", + "result": "passed", + "durationSeconds": 2.05, + "summary": "39/39 on the title-parity trio at the relocated path, replacing the 2026-08-27 record of the same three files, which killed five mutants as sole deltas: disabling the document branch restores New Tab for a named document, dropping the grant-URL refusal puts the scheme into the title, the tab mirror and the written session, returning New Tab instead of the file name breaks the fallback, removing the guest listener leaves the document unable to name its tab, and making the scheme test parse rather than prefix-match lets a title that merely looks like a preview URL through." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", + "result": "passed", + "durationSeconds": 1.54, + "summary": "14/14 on the reader-facing failure suite at its relocated path. This and the red run below replace the 2026-08-26 pair recorded against the pre-relocation path (10/10 green, 3/10 red), re-measured today so the entry/subresource split keeps a red half rather than only a green one." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", + "result": "failed", + "durationSeconds": 1.27, + "summary": "Candidate with the sole delta that every failure sets the blocking reason, asset or not, failed 4/14 on the asset-notice assertions — the same mutant the retired 2026-08-26 record measured at 3/10, re-run at the relocated path. A refused subresource must name itself over a document that stays mounted; only the entry document may take the page away." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/editor/EditorPanelShell.header.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "result": "passed", + "durationSeconds": 1.52, + "summary": "174/174 at the relocated paths, replacing the 2026-08-27 records of the same command at 162/162 and 158/158. Those established, as sole deltas: removing the unregister-channel refusal goes red because the grab disposal beside it drops the intent an in-flight preview grab compares by identity, while an ordinary browser page driven through the identical sequence still has its grab state disposed; removing registration’s already-gone-guest refusal goes red for both a missing and a destroyed guest; removing the map deletion on the refusal path goes red at shutdown; the bridge’s own unregisterGuest branch, restored, tears down a live page over a misaddressed request; and the download fall-through for an unresolved owner hands back a same-named local file under a remote document’s name." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/ipc/browser-tab-registration-wait.test.ts src/main/ipc/doc-preview-grant-ipc.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/main/browser/browser-manager-guest-policy-profile.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "result": "passed", + "durationSeconds": 1.5, + "summary": "175/175 at the relocated paths on the unified tool authority, replacing the 2026-08-27 record of the same command at 174/174. That run killed twelve of fourteen mutants as sole deltas — dropping the document branch from the one authority door, removing either browsing-registration refusal or the mint-side refusal that is its converse, deleting the registration notification or widening it to the worktree-wide and any-tab waits, disposing revoked state by grant instead of by page, dropping the hosting-renderer or destroyed-contents check, rebuilding the renderer tool target from the grant, and deleting the teardown identity check under a re-mint — and reported two as ill-posed rather than as kills. The relocation changed no file it names except their directory." + }, +{ + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/lib/file-preview.test.ts", + "result": "passed", + "durationSeconds": 22.09, + "summary": "29/29 on the candidate that makes the preview a browser tab. The preview action now creates a page located by the document; reopening the same document activates the tab it is already in rather than minting a second grant on one file; and closing that tab revokes its grant, which nothing else does now that the editor tab's close hook is gone. Red-green with each mutant as the sole delta: dropping the reuse lookup opens a second tab for a document already on screen, and dropping the release on close leaves the document readable through a grant nothing revokes until the process ends. Both are paired with presence preconditions in the same runs — a second, different document still gets its own tab, and a URL tab closed beside the document tab revokes nothing, so a release fired for every close would fail rather than pass." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/doc-preview-grant-ipc.test.ts src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/store/slices/tabs-hydration.test.ts", + "result": "passed", + "durationSeconds": 4.77, + "summary": "40/40 on the candidate that removes the editor preview species and moves its two boundary duties onto the browser tab. The publish filter moved with the tab: it used to exclude an editor file by mode, and now excludes a browser workspace by whether it is located by a document — asserted at both the group projection and the tab loop, because a mutant that filters only one of them still publishes. Migration is the other half: sessions written by builds that made previews editor tabs carry chrome whose entity id encodes the document, and whose document was never persisted, so it has always come back naming a surface no restore produces; that chrome is now dropped. Each mutant as the sole delta: filtering neither place publishes the document tab to mobile, filtering only the projection still publishes it, and removing the migration leaves the stale strip entry. Presence preconditions in the same runs: an ordinary browser tab beside the document tab does publish, and the ordinary editor tab for the very same document survives hydration." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts", + "result": "passed", + "durationSeconds": 1.54, + "summary": "10/10 on the candidate that gives a browser page a workspace-document location. The trap this is built around: page.url is read by persistence, the mobile publisher, history and the address bar, so a grant URL parked there would outlive the grant that named it and travel to readers that cannot resolve it. Creation overrides the url rather than trusting its caller, and restore re-asserts the same thing, so a session written by another build cannot carry one in. Red-green with each mutant as the sole delta: dropping the creation override writes the grant URL into the store and into the session payload; dropping the restore override brings a foreign session's grant URL back; leaving docLocation out of either browser schema strips it on load and restores the document as a blank New Tab under a strip entry that still names it (both schemas are plain z.object, and this is the trap that already cost one round on the editor schema); dropping it from the workspace build or the mirror leaves the tab entry naming a document its page has left, which the publish boundary reads; dropping the salvage-path copy loses the document when only the tab row survived; and dropping the address-bar exclusion focuses an address bar the surface does not have. The blank-url assertions are paired with a presence precondition in the same run: an ordinary tab created through the same call keeps its URL and does ask for the address bar." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-manager-guest-policy-profile.test.ts src/main/window/main-window-webview-security.test.ts src/main/browser/doc-preview-guest-policy.test.ts", + "result": "passed", + "durationSeconds": 0.32, + "summary": "55/55 on the candidate that gives every guest one attach door. A preview used to reach its policies through an installer of its own, which meant the manager's attachment bookkeeping — the set registration consults and the cleanup teardown runs — never knew a preview existed. It is now a profile on `attachGuestPolicies`, and the doc policy is what that profile installs. Red-green with each mutant as the sole delta: deleting the profile branch hands a previewed document the browsing machinery (clicked-link routing, frame routing, popup child windows, anti-detection over the debugger) and lets it navigate off-grant; dropping the manager's destroyed wiring leaves the guest answerable through the grant-keyed authority after it dies and its id marked attached forever; and making the window fork ask for the default profile is caught at the fork itself. The absence assertions are paired with a presence precondition in the same run: a browsing guest attached through the same method observably takes every one of those things, so a profile that fenced nothing, or an attach path that had stopped installing anything at all, fails rather than passing empty." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-session-partition-policies.test.ts src/main/browser/doc-preview-protocol.test.ts", + "result": "passed", + "durationSeconds": 0.17, + "summary": "22/22 on the candidate that stops a previewed document writing into this desktop's Downloads folder. The hole was read out of the shipping path, not guessed: the preview partition took the shared browser partition policies whole, including will-download, and a preview guest joins no browser-page registry, so handleGuestWillDownload resolved no owner context, routeBrowserClientDownload answered local for a non-client-hosted contents, and the item got a reserved Downloads name with no prompt and no tab to attribute it to. Downloads are now the one policy the preview partition does not inherit. Red-green with each mutant as the sole delta: making the installer ignore the deny option routes the preview partition's download to the router again, and making the preview protocol installer stop asking for the deny leaves the option unset. The absence half is paired with a presence precondition in the same run — an ordinary browsing partition installed beside the preview one in one test still reaches the download router, so a deny that fenced everything, or a download path that had been removed entirely, would fail rather than pass quietly." + }, +{ + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/ipc/browser.test.ts", "result": "passed", "durationSeconds": 0.7, - "summary": "Rebased candidate 14b5877a75 passed 5/5: absent, unknown, mixed-version, preflight-known, and post-preflight capability failures all reported failure and retained no new empty split." + "summary": "99/99 on the candidate that binds a preview guest to its grant when the policy installs, not only on a later navigation. Live on a dev instance against a real SSH workspace, the previous shape refused every tool with not-ready for as long as the reader stayed on the page they opened: the embedder hands the guest over after it has already started loading its src, so the did-start-navigation latch never saw the only navigation most previews make. Following an in-document link bound it and the tools began answering, which is what identified the miss. Two cases pin it: a guest already reporting its URL at install, and one whose first navigation only reaches the commit." + }, + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "passed", + "durationSeconds": 23.1, + "summary": "The journey passed 1/1 on the candidate that requires the guest to be bound to a grant before any navigation is allowed, rather than admitting an unbound one. The document still rendered and the target=_blank press still routed out, which is the half that would break if the renderer-set src reached will-navigate: it does not, because that load is browser-initiated, so the permissive branch it was written for is unreachable." + }, + { + "date": "2026-08-26", + "runner": "ci", + "platform": "linux", + "command": "npx playwright test --config tests/playwright.config.ts --project=electron-headless tests/e2e/paired-remote-html-preview-local-render.spec.ts --workers=1", + "result": "failed", + "durationSeconds": 72, + "summary": "The journey failed on Ubuntu at the post-press presence precondition, with #post-input-egress still reading 'idle' after 30 seconds. The trace shows the press was dispatched at 769.5,143.9, a point the guest's own hit test had just confirmed was on its heading, and the focus report before it was already isFocused true, so neither focus nor coordinates were the break: the press never entered the guest at all. A standalone Electron 43 probe under Xvfb with this suite's Linux flags reproduced it in roughly two runs in five when pressing immediately after guest attach — the embedder received the pointerdown with the webview element as its target, meaning the browser resolved the press against the embedder because the freshly attached guest had not yet registered its hit-test region. The same probe never dropped a press on macOS, which is why local runs of either project could not see this. The spec now presses until the document answers, mirroring the target=_blank phase." + }, + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line --repeat-each=3", + "result": "passed", + "durationSeconds": 72, + "summary": "The journey passed 3/3 on the trusted-click candidate, which removed the recent-input gate, made both navigation sinks deny-only, dropped the webview's allowpopups attribute and gave the preview guest its own pinned preload. The fixture now also attempts a window.open and location.href from inside a pointerdown handler, so the egress rides a press the reader really made: it reported 'attempted' every run while both browser counts stayed at that phase's baseline and nothing routed, and the later real press on the target=_blank link still opened exactly one Orca browser tab with the preview still open." + }, + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "failed", + "durationSeconds": 26.9, + "summary": "Candidate with the sole delta that the navigation sinks route again behind a two-second recent-input gate failed on clientBrowserWorkspaceCountAllWorktrees 1 versus baseline 0: the document's location.href, run from inside the handler for the reader's press on the heading, opened a real browser tab. That is the case the earlier design could not distinguish from the press's own effect, and it is why the sinks route nothing at all now. The same run still refused the unattended on-load egress, so this measures the new half specifically." + }, + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/ipc/browser.test.ts", + "result": "passed", + "durationSeconds": 0.97, + "summary": "97/97 passed over the preview tool-authorization seam. The channel census asserts the split is total, so a new browser channel cannot be added without classifying it: all six tool channels resolved the preview guest and refused both a renderer that does not host the preview and a grant no preview rendered, while all twenty-three page/session channels never reached the preview authority at all. Five mutations were killed \u2014 dropping the preview branch (12 failures), turning the two authorities into a fallback chain (13), ignoring the hosting renderer id (6), ignoring grant revocation (1), and removing one channel from the census (1)." + }, + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/preload/doc-preview-link-interception.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/window/main-window-webview-security.test.ts src/main/ipc/doc-preview-grant-ipc.test.ts", + "result": "passed", + "durationSeconds": 0.36, + "summary": "The four surfaces the trusted-click route is built from passed 63/63: the preload's click policy over a real document, including an untrusted dispatched click, an untrusted middle click, an SVG animated href resolved against the document so a relative, rooted or fragment SVG link behaves exactly like the identical HTML anchor, fragment and percent-encoded fragment targets and a trusted middle click; the guest policy's deny-only sinks, its refusal of a navigation that arrives before a document has bound the guest to a grant, and its report gate for an unfocused guest, an unregistered sender, a missing or revoked grant and non-web URLs; will-attach-webview pinning the preview preload in both directions; and the click channel handing main its own sender without a trusted-renderer check." + }, +{ + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "passed", + "durationSeconds": 22.8, + "summary": "The journey passed 1/1 with the fixture document now attempting its own egress on every load: an unattended window.open and location.href to an off-machine URL, and an ICE gathering probe. Both browser counts held at baseline and the probe reported candidates=0, while the document still rendered and the later real target=_blank press still routed out as an Orca browser tab." + }, + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "failed", + "durationSeconds": 23.6, + "summary": "Candidate with the sole delta that the preview guest no longer receives the disable_non_proxied_udp IP-handling policy failed on candidates=6 versus 0, proving the document can gather ICE — including a STUN reflexive candidate against an off-machine server in the standalone probe — whenever that one main-process call is missing." + }, + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line --repeat-each=3", + "result": "passed", + "durationSeconds": 66, + "summary": "The journey passed 3/3 again under --repeat-each=3 on the candidate that clears grants on every fresh shell document and gates the mint channel on the trusted renderer, so neither change disturbs a live preview: the document still rendered, both browser counts held at baseline, and the target=_blank press still routed out as an Orca browser tab." + }, +{ + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line --repeat-each=3", + "result": "passed", + "durationSeconds": 59.7, + "summary": "The renamed journey passed 3/3 under --repeat-each=3 on the hardened candidate: the host document rendered in the orca-preview guest, both browser counts held at baseline through the split, focus, reactivation and close phases, and the final phase's real target=_blank press routed out as an Orca browser tab with the routing call recorded as opened while the preview tab stayed open." }, { "date": "2026-08-12", @@ -3070,78 +3532,6 @@ "durationSeconds": 3.48, "summary": "Seven focused host and renderer contract files passed 183/183 tests, covering owning-runtime routing, focused and unfocused creation, placement, reconciliation, failure cleanup, and teardown." }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=3", - "result": "passed", - "durationSeconds": 16.1, - "summary": "The candidate passed exact host/client cardinality, split stability, rendered DOM, focus authority, close convergence, and surviving editor and terminal." - }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "failed", - "durationSeconds": 11.6, - "summary": "Latest-main a90a18d43da333e1cc327779a3f9912a78308848 failed the byte-identical 5806b23f oracle because the HTML preview collapsed into the source editor group instead of retaining its requested split." - }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 6.5, - "summary": "Rebased candidate b7de9c0961a4edf389a29079b2b4d5250184b57e passed the byte-identical 5806b23f oracle: one host page, one client workspace, requested split, rendered DOM, stable focus, and browser-only close." - }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "failed", - "durationSeconds": 10.9, - "summary": "Rebased candidate b7de9c0961a4edf389a29079b2b4d5250184b57e with only the side-preview owning-runtime routing seam disabled failed the byte-identical 5806b23f oracle with two client workspaces." - }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 7.6, - "summary": "Final candidate d5767481b982b736e7cc5b4653b2a32a61280638 on merge-base 36d45af062ec4022265aabac8fc6a9d36eecedf1 passed the byte-identical 5806b23f oracle after final review and rebase: one host page, one client workspace, requested split, rendered DOM, stable focus, and browser-only close." - }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "failed", - "durationSeconds": 11.8, - "summary": "Latest-main 346e59c879 failed the byte-identical 2ec1dcf1 oracle with two client HTML browser workspaces instead of one." - }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 8.4, - "summary": "Candidate d05217c40d passed the byte-identical 2ec1dcf1 oracle: one host page, one client workspace, stable split, rendered DOM, editor focus after creation, explicit-click browser focus, and browser-only close." - }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "failed", - "durationSeconds": 12.3, - "summary": "Candidate d05217c40d with only the side-preview focusOnCreate:false call disabled failed the byte-identical 2ec1dcf1 oracle because preview creation activated the browser instead of preserving editor focus." - }, { "date": "2026-08-13", "runner": "local", @@ -3171,7 +3561,7 @@ }, "redGreenEvidence": { "status": "complete", - "evidence": "The unchanged STA-4063 five-assertion oracle is red 5/5 on scan e4c278eb52 and latest main bc2e30000b, partially green 2/5 on pre-fix PR #14100, green 5/5 on candidate 14b5877a75, and red 3/5 when only the new transaction cleanup boundary is disabled on final candidate a9e6fb7716. The earlier STA-4025 transaction oracle remains red on the post-#13909 base and green on this candidate. The byte-identical STA-4231 headed oracle is red on latest-main b19d99bd00: both 15-second creates return runtime_timeout without a page ID while the host retains two distinct pages after retry. The relevant behavior is unchanged through 0a0ae974d4. The candidate is green with one returned canonical ID, one host/client page, and no retry; disabling only the navigation-independent acknowledgement is red with the same two orphaned pages. The byte-identical 2ec1dcf1 Electron oracle remains red on latest main 346e59c879, green on candidate d05217c40d, and red with only side-preview focus disabled; historical 5806b23f evidence separately proves owning-runtime routing." + "evidence": "The unchanged STA-4063 five-assertion oracle is red 5/5 on scan e4c278eb52 and latest main bc2e30000b, partially green 2/5 on pre-fix PR #14100, green 5/5 on candidate 14b5877a75, and red 3/5 when only the new transaction cleanup boundary is disabled on final candidate a9e6fb7716. The earlier STA-4025 transaction oracle remains red on the post-#13909 base and green on this candidate. The byte-identical STA-4231 headed oracle is red on latest-main b19d99bd00: both 15-second creates return runtime_timeout without a page ID while the host retains two distinct pages after retry. The relevant behavior is unchanged through 0a0ae974d4. The candidate is green with one returned canonical ID, one host/client page, and no retry; disabling only the navigation-independent acknowledgement is red with the same two orphaned pages. The byte-identical 2ec1dcf1 Electron oracle remains red on latest main 346e59c879, green on candidate d05217c40d, and red with only side-preview focus disabled; historical 5806b23f evidence separately proves owning-runtime routing. For the STA-5557 preview behavior the rewritten journey is green 1/1 on the candidate and red when the sole delta makes the same preview action also create a browser tab, failing on clientBrowserWorkspaceCount 1 versus baseline 0. That absence assertion is gated on first reading the rendered document out of the guest, so it cannot pass by the preview never happening. Against pre-STA-5557 builds the journey is red by construction rather than by measurement: those builds have no html-preview tab mode, so the run would fail earlier, at materialization, and it has not been executed against an old build. The reader-facing half is red-green at the unit layer. The 6/6 green and 2/6 red pair recorded earlier on 2026-08-26 measured the superseded shape, where a subresource failure was filtered out entirely; the shipping shape is green 10/10 and red 3/10 when the sole delta makes every failure, asset or entry, set the blocking reason. The containment lines the second review found uncovered are red-green too: installing the scheme handler on the default session instead of the preview session, and widening the scheme privileges with allowServiceWorkers and bypassCSP, each fail the protocol suite as the sole delta, and deleting the html-preview case from the closed-tab cleanup fails the hook suite. The external-link phase added on 2026-08-26 is red-green by measurement: green 3/3 on the trusted-click design, in which the guest's own preload reports a trusted press on an anchor and main answers only a focused preview guest holding a live grant. Its earlier shape depended on the webview carrying allowpopups and went red with zero recorded routing calls when only that attribute was dropped; the preload now intercepts the press before Chromium considers a popup, so the attribute is gone and that measurement retired with it. One further run failed in the paired-client harness before reaching any preview step, when the client window never exposed its store within the launch timeout; that is a harness flake, not a preview signal. The two egress fences added on 2026-08-26 are red-green by measurement in the same journey, whose fixture attempts its own escape on every load and again from inside the handler for the reader's press on the heading: with deny-only navigation sinks the document opens no browser tab and the counts hold at baseline, and with the sole delta that those sinks route again behind a two-second recent-input gate the post-press location.href opens a real tab, clientBrowserWorkspaceCountAllWorktrees 1 against a baseline of 0. An earlier measurement of that same gate, taken when it was the shipping design, showed the unattended on-load egress opening two tabs once the gate was removed entirely; it was retired with the gate, because a gate that cannot tell a script's navigation from the click it followed was never the fence the claim needed. With the guest's IP-handling policy the document reports candidates=0, and with the sole delta that the policy call is removed it reports candidates=6. Both mutants were applied to the shipping module, rebuilt, and run against the live paired journey rather than reasoned about." }, "performanceBudget": { "required": true, @@ -3184,9 +3574,14 @@ "knownGaps": [ "Live paired-server validation currently covers macOS only.", "Multiple simultaneous viewers are not exercised by the Electron journey.", - "Direct SSH remote file URLs remain intentionally unsupported and local file previews retain their existing path." + "Local workspaces intentionally keep the embedded browser tab at a file URL and are not covered by the preview journey.", + "The SSH preview owner has unit coverage only; no live SSH host renders a preview in this gate.", + "The preview journey exercises one self-contained document. Relative subresources, reload re-reading the workspace disk, and the truncated and out-of-worktree refusals are covered by unit contracts only.", + "Grant revocation on tab close is asserted at the unit layer; the journey observes the tab and open file disappearing, not the main-process registry.", + "The external-link route trusts a real press on an anchor in a focused preview guest, so it proves the reader pressed that anchor, not that the anchor's visible text matches its target. The tab it opens is visible, which is the remaining containment.", + "WebRTC is fenced by the IP-handling policy rather than by removing the API; the guest can still construct a peer connection, and the journey measures that it gathers nothing." ], - "demotionRule": "Demote if one paired HTML action can create duplicate client or host browser identities, lose the requested split, steal editor focus before an explicit browser click, lose browser focus after that click, or remove unrelated editor or terminal state on close." + "demotionRule": "Demote if a paired HTML preview creates a browser workspace on either side, renders from anything other than the orca-preview scheme, serves a read the host reported as truncated or over-cap, serves a document without the self-only content security policy, lets the preview session carry an off-machine request, reads an SSH path that canonicalizes outside its grant root, steals focus from the source editor when it opens, removes unrelated editor or terminal state on close, hides a document that rendered because one subresource was refused, leaves a grant readable after the tab or renderer that owns it is gone, mints one for a sender that is not the trusted renderer, opens a browser tab for a navigation the document started with no one at the keyboard, or lets the guest gather an ICE candidate; or if a paired browser creation this gate still owns produces duplicate client or host identities or loses its requested split." }, { "id": "browser-network-tunnel.bounded-fail-closed-route", diff --git a/electron.vite.config.ts b/electron.vite.config.ts index f81dbb93041..4faa844ee4c 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -209,6 +209,7 @@ export const electronViteConfig: UserConfig = { index: resolve('src/main/index.ts'), // Why: sandboxed webview preloads cannot load Rollup helper chunks. 'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'), + 'doc-preview-link-preload': resolve('src/preload/doc-preview-link.ts'), 'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'), 'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'), 'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'), diff --git a/src/main/browser/browser-manager-annotation-bridge.test.ts b/src/main/browser/browser-manager-annotation-bridge.test.ts new file mode 100644 index 00000000000..2d9aa5e50fe --- /dev/null +++ b/src/main/browser/browser-manager-annotation-bridge.test.ts @@ -0,0 +1,210 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const browserMocks = vi.hoisted(() => ({ + appGetPathMock: vi.fn(() => '/downloads'), + shellOpenExternalMock: vi.fn(), + browserWindowFromWebContentsMock: vi.fn(), + menuBuildFromTemplateMock: vi.fn(), + guestOffMock: vi.fn(), + guestOnMock: vi.fn(), + guestSetBackgroundThrottlingMock: vi.fn(), + guestSetWindowOpenHandlerMock: vi.fn(), + guestOpenDevToolsMock: vi.fn(), + webContentsFromIdMock: vi.fn(), + screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })), + openPopupWithOriginBarMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: browserMocks.appGetPathMock }, + BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock }, + clipboard: { writeText: vi.fn() }, + shell: { openExternal: browserMocks.shellOpenExternalMock }, + Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock }, + screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock }, + webContents: { fromId: browserMocks.webContentsFromIdMock } +})) + +vi.mock('./popup-origin-bar-window', () => ({ + openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock +})) + +import { browserManager } from './browser-manager' +import type { BrowserAnnotationViewportBridgeOptions } from '../../shared/browser-annotation-viewport-bridge' +import { + rendererWebContentsId, + resetBrowserManagerMocks, + resetBrowserManagerState +} from './browser-manager-test-harness' +import { + createViewportGuestFactory, + flushViewportOps +} from './browser-manager-viewport-test-fixtures' + +const { webContentsFromIdMock } = browserMocks +const makeGuest = createViewportGuestFactory(browserMocks) + +const BRIDGE_OPTIONS: BrowserAnnotationViewportBridgeOptions = { + emitViewport: false, + enabled: true, + markers: [], + token: 'annotationviewporttoken' +} + +function registerPage(pageId: string, guest: Record): void { + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: pageId, + webContentsId: guest.id as number, + rendererWebContentsId + }) +} + +/** The production resolver's shape: read the registry now, not when the request was made. */ +function resolveFromRegistry(pageId: string): () => Electron.WebContents | null { + return () => browserManager.getAuthorizedGuest(pageId, rendererWebContentsId) +} + +describe('browserManager.setAnnotationViewportBridge', () => { + beforeEach(() => { + resetBrowserManagerMocks(browserMocks) + resetBrowserManagerState() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('installs the bridge in an isolated world', async () => { + const { guest } = makeGuest(4646) + registerPage('tab-annotations', guest) + + const ok = await browserManager.setAnnotationViewportBridge( + 'tab-annotations', + BRIDGE_OPTIONS, + resolveFromRegistry('tab-annotations') + ) + + expect(ok).toBe(true) + expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith( + expect.any(Number), + [ + expect.objectContaining({ + code: expect.stringContaining('__orcaBrowserAnnotationViewportBridge') + }) + ], + false + ) + }) + + // Why this is the case that matters: a cross-process navigation re-registers the same page id + // with a new WebContents and leaves the retired one alive, so a bridge op that resolved its + // guest when the request arrived would inject into a page nobody is looking at — and the + // annotation badges would stop tracking the document on screen. + it('injects into the guest the page has when a queued op finally runs, not the one it was asked with', async () => { + const { guest: firstGuest } = makeGuest(5101) + let releaseFirstInjection = (): void => {} + // One shared gate for every call, so a wrongly-routed second injection still settles and the + // test fails on where it landed rather than on a timeout. + const firstInjectionGate = new Promise((resolve) => { + releaseFirstInjection = () => resolve() + }) + firstGuest.executeJavaScriptInIsolatedWorld = vi.fn(() => firstInjectionGate) + registerPage('tab-swap', firstGuest) + + const firstDone = browserManager.setAnnotationViewportBridge( + 'tab-swap', + BRIDGE_OPTIONS, + resolveFromRegistry('tab-swap') + ) + await flushViewportOps() + + // A second request arrives while the first still holds the chain — at this moment the page is + // still on the first guest, which is the guest a request-time resolution would capture. + const secondDone = browserManager.setAnnotationViewportBridge( + 'tab-swap', + BRIDGE_OPTIONS, + resolveFromRegistry('tab-swap') + ) + await flushViewportOps() + + // Only now does the page swap renderer processes, while the second op is still queued. + const { guest: secondGuest } = makeGuest(5102) + registerPage('tab-swap', secondGuest) + + releaseFirstInjection() + await expect(firstDone).resolves.toBe(true) + await expect(secondDone).resolves.toBe(true) + + expect(secondGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1) + expect(firstGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1) + }) + + // Why this is the resolver's cleanup and not the bridge's: the authority that reads the registry + // is the one that can see the guest is gone. The bridge only reports the refusal. + it('refuses when its guest died while the op was queued, and the resolver drops the registration', async () => { + const { guest } = makeGuest(5103) + registerPage('tab-dies', guest) + expect(browserManager.getGuestWebContentsId('tab-dies')).toBe(5103) + + webContentsFromIdMock.mockReturnValue(null) + await expect( + browserManager.setAnnotationViewportBridge( + 'tab-dies', + BRIDGE_OPTIONS, + resolveFromRegistry('tab-dies') + ) + ).resolves.toBe(false) + + // Why assert the registry and not just the answer: a stale guest has to clear every per-tab + // entry, or the page keeps a dead WebContents id that later ops resolve against. + expect(browserManager.getGuestWebContentsId('tab-dies')).toBeNull() + }) + + // Why this one exists: an unresolved guest is not the same as a dead one. A request addressed by + // the wrong renderer names a page that is alive and on screen, and answering it with teardown + // would cancel that page's in-flight downloads and grabs over a misaddressed message. + it('refuses a request from the wrong renderer without tearing down the healthy page it named', async () => { + const { guest } = makeGuest(5105) + registerPage('tab-mismatch', guest) + const unregisterGuest = vi.spyOn(browserManager, 'unregisterGuest') + + await expect( + browserManager.setAnnotationViewportBridge('tab-mismatch', BRIDGE_OPTIONS, () => + browserManager.getAuthorizedGuest('tab-mismatch', rendererWebContentsId + 1) + ) + ).resolves.toBe(false) + + expect(unregisterGuest).not.toHaveBeenCalled() + expect(browserManager.getGuestWebContentsId('tab-mismatch')).toBe(5105) + expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled() + }) + + it('refuses a destroyed guest without injecting into it', async () => { + const { guest } = makeGuest(5104) + registerPage('tab-destroyed', guest) + ;(guest.isDestroyed as ReturnType).mockReturnValue(true) + + await expect( + browserManager.setAnnotationViewportBridge( + 'tab-destroyed', + BRIDGE_OPTIONS, + () => guest as never + ) + ).resolves.toBe(false) + expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled() + }) + + // Why: a document page owns no browsing state, so treating its refusal as a stale page would run + // teardown against an id the browsing registry never held. + it('does not run page teardown when a document page resolves to nothing', async () => { + const unregisterGuest = vi.spyOn(browserManager, 'unregisterGuest') + + await expect( + browserManager.setAnnotationViewportBridge('doc-page-1', BRIDGE_OPTIONS, () => null) + ).resolves.toBe(false) + + expect(unregisterGuest).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/browser/browser-manager-guest-lifecycle.test.ts b/src/main/browser/browser-manager-guest-lifecycle.test.ts index d1a6a16c085..6d71db94585 100644 --- a/src/main/browser/browser-manager-guest-lifecycle.test.ts +++ b/src/main/browser/browser-manager-guest-lifecycle.test.ts @@ -45,6 +45,32 @@ import { resetBrowserManagerMocks, resetBrowserManagerState } from './browser-manager-test-harness' +import { installDocPreviewGuestPolicy } from './doc-preview-guest-policy' +import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry' +import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme' + +/** + * A page the document half of the registry really holds. Built rather than named: membership is + * what both doors refuse on now, so an id that merely looks like a preview's would be admitted. + */ +function registerWorkspaceDocPage(browserPageId: string): void { + const grant = mintDocPreviewGrant({ + owner: { kind: 'ssh', connectionId: 'ssh-1' }, + root: '/home/alice/docs', + entryRelativePath: 'index.html', + browserPageId + }) + const guest = { + isFocused: () => false, + isDestroyed: () => false, + getURL: () => buildDocPreviewUrl(grant.id, grant.entryRelativePath), + on: vi.fn(), + once: vi.fn(), + setWindowOpenHandler: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn() + } + installDocPreviewGuestPolicy(guest as never, { id: rendererWebContentsId, send: vi.fn() }) +} const { guestOffMock, @@ -60,6 +86,7 @@ describe('browserManager', () => { beforeEach(() => { resetBrowserManagerMocks(browserMocks) resetBrowserManagerState() + revokeAllDocPreviewGrants() }) afterEach(() => { @@ -92,6 +119,77 @@ describe('browserManager', () => { expect(browserManager.getSessionProfileIdForTab('browser-1')).toBe('work') }) + // Why both doors: one id in both halves of the registry would make the tool door answer with a + // document guest for a page the reader is browsing in. + it.each(['registerGuest', 'registerOffscreenGuest'] as const)( + 'refuses %s for a page the document registry already holds', + (entryPoint) => { + const guest = { + id: 129, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + const browserPageId = 'doc-page-1' + registerWorkspaceDocPage(browserPageId) + + if (entryPoint === 'registerGuest') { + expect( + browserManager.registerGuest({ + browserPageId, + webContentsId: guest.id, + rendererWebContentsId + }) + ).toBe(false) + } else { + expect( + browserManager.registerOffscreenGuest({ browserPageId, webContentsId: guest.id }) + ).toBe(false) + } + + expect(browserManager.getGuestWebContentsId(browserPageId)).toBeNull() + } + ) + + // Why this answer is load-bearing: the headless backend destroys its window on false, so a true + // for a guest that is already gone would leave a page id registered onto nothing. + it.each(['missing', 'destroyed'] as const)( + 'refuses registerOffscreenGuest when the named guest is %s', + (guestState) => { + webContentsFromIdMock.mockReturnValue( + guestState === 'missing' ? null : { id: 137, isDestroyed: vi.fn(() => true) } + ) + + expect( + browserManager.registerOffscreenGuest({ browserPageId: 'offscreen-1', webContentsId: 137 }) + ).toBe(false) + + expect(browserManager.getGuestWebContentsId('offscreen-1')).toBeNull() + } + ) + + // Why the exit door needs the same check: a document page withdraws by revoking its grant, so its + // id here is misaddressed — and unregistering opens by evicting whatever grab that id names. + it('refuses unregisterGuest for a page the document registry holds', () => { + registerWorkspaceDocPage('doc-page-2') + const cancelGrabOp = vi.spyOn(browserManager, 'cancelGrabOp') + + browserManager.unregisterGuest('doc-page-2') + + expect(cancelGrabOp).not.toHaveBeenCalled() + + // The presence half: the same door does evict a browsing page's grab. + browserManager.unregisterGuest('browser-page-1') + expect(cancelGrabOp).toHaveBeenCalledWith('browser-page-1', 'evicted') + cancelGrabOp.mockRestore() + }) + it('blocks non-web guest navigations after attach', () => { const guest = { isDestroyed: vi.fn(() => false), diff --git a/src/main/browser/browser-manager-guest-policy-profile.test.ts b/src/main/browser/browser-manager-guest-policy-profile.test.ts new file mode 100644 index 00000000000..47871f64ea9 --- /dev/null +++ b/src/main/browser/browser-manager-guest-policy-profile.test.ts @@ -0,0 +1,250 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const browserMocks = vi.hoisted(() => ({ + appGetPathMock: vi.fn(() => '/downloads'), + shellOpenExternalMock: vi.fn(), + browserWindowFromWebContentsMock: vi.fn(), + menuBuildFromTemplateMock: vi.fn(), + guestOffMock: vi.fn(), + guestOnMock: vi.fn(), + guestSetBackgroundThrottlingMock: vi.fn(), + guestSetWindowOpenHandlerMock: vi.fn(), + guestOpenDevToolsMock: vi.fn(), + webContentsFromIdMock: vi.fn(), + screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })), + openPopupWithOriginBarMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: browserMocks.appGetPathMock }, + BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock }, + clipboard: { writeText: vi.fn() }, + shell: { openExternal: browserMocks.shellOpenExternalMock }, + Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock }, + screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock }, + webContents: { fromId: browserMocks.webContentsFromIdMock } +})) +vi.mock('./popup-origin-bar-window', () => ({ + openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock +})) + +import { browserManager } from './browser-manager' +import { resetBrowserManagerMocks, resetBrowserManagerState } from './browser-manager-test-harness' +import { getWorkspaceDocPageGuest } from './doc-preview-guest-policy' +import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry' +import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme' + +type GuestFake = { + id: number + url: string + listeners: Map void)[]> + windowOpenHandler: ((details: { url: string; frameName: string }) => unknown) | null + webRtcPolicy: string | null + isDestroyed: () => boolean + isFocused: () => boolean + getURL: () => string + getType: () => string + setBackgroundThrottling: (value: boolean) => void + setWebRTCIPHandlingPolicy: (policy: string) => void + setWindowOpenHandler: (handler: (details: { url: string; frameName: string }) => unknown) => void + executeJavaScriptInIsolatedWorld: ReturnType + debugger: { + isAttached: () => boolean + attach: ReturnType + sendCommand: ReturnType + } + on: (event: string, listener: (...args: never[]) => void) => void + once: (event: string, listener: (...args: never[]) => void) => void + off: (event: string, listener: (...args: never[]) => void) => void +} + +function createGuest(id: number, url: string): GuestFake { + const listeners = new Map void)[]>() + const guest: GuestFake = { + id, + url, + listeners, + windowOpenHandler: null, + webRtcPolicy: null, + isDestroyed: () => false, + isFocused: () => true, + getURL: () => guest.url, + getType: () => 'webview', + setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: (policy: string) => { + guest.webRtcPolicy = policy + }, + setWindowOpenHandler: (handler) => { + guest.windowOpenHandler = handler + }, + executeJavaScriptInIsolatedWorld: vi.fn(async () => undefined), + debugger: { + isAttached: () => true, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined) + }, + on: (event, listener) => { + listeners.set(event, [...(listeners.get(event) ?? []), listener]) + }, + once: (event, listener) => { + listeners.set(event, [...(listeners.get(event) ?? []), listener]) + }, + off: (event, listener) => { + listeners.set( + event, + (listeners.get(event) ?? []).filter((entry) => entry !== listener) + ) + } + } + return guest +} + +function listenerCount(guest: GuestFake, event: string): number { + return guest.listeners.get(event)?.length ?? 0 +} + +/** Drives the guest's own will-navigate listeners and reports whether they refused. */ +function navigateTo(guest: GuestFake, url: string): boolean { + let prevented = false + const event = { preventDefault: () => (prevented = true) } as never + for (const listener of guest.listeners.get('will-navigate') ?? []) { + ;(listener as (event: unknown, url: string) => void)(event, url) + } + return prevented +} + +const host = { id: 5001, send: vi.fn() } as unknown as Electron.WebContents + +beforeEach(() => { + resetBrowserManagerMocks(browserMocks) + resetBrowserManagerState() + revokeAllDocPreviewGrants() + browserMocks.guestSetBackgroundThrottlingMock.mockReturnValue(undefined) +}) + +describe('guest policy profiles', () => { + function attachPreviewGuest(id = 301): { + guest: GuestFake + grantId: string + browserPageId: string + } { + const browserPageId = `doc-page-${id}` + const grant = mintDocPreviewGrant({ + owner: { kind: 'ssh', connectionId: 'ssh-1' }, + root: '/home/alice/docs', + entryRelativePath: 'index.html', + browserPageId + }) + const guest = createGuest(id, buildDocPreviewUrl(grant.id, 'index.html')) + browserManager.attachGuestPolicies(guest as never, null, { profile: 'workspace-doc', host }) + return { guest, grantId: grant.id, browserPageId } + } + + // The presence half of every absence below: a browsing guest observably takes all of it through + // the same method, so a profile that fenced nothing — or an attach path that stopped installing + // anything at all — cannot pass these by being uniformly empty. + it('gives a browsing guest link routing, popups and anti-detection', () => { + const guest = createGuest(300, 'https://example.com/') + + browserManager.attachGuestPolicies(guest as never) + + expect(listenerCount(guest, 'dom-ready')).toBe(1) + expect(listenerCount(guest, 'frame-created')).toBe(1) + expect(listenerCount(guest, 'did-create-window')).toBe(1) + expect(guest.debugger.sendCommand).toHaveBeenCalled() + expect(navigateTo(guest, 'https://elsewhere.example/')).toBe(false) + }) + + it('gives a workspace-document guest none of it', () => { + const { guest } = attachPreviewGuest() + + expect(listenerCount(guest, 'dom-ready')).toBe(0) + expect(listenerCount(guest, 'frame-created')).toBe(0) + expect(listenerCount(guest, 'did-create-window')).toBe(0) + expect(guest.debugger.sendCommand).not.toHaveBeenCalled() + expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled() + }) + + it('holds a workspace-document guest inside the grant it is showing', () => { + const { guest, grantId } = attachPreviewGuest() + + expect(navigateTo(guest, buildDocPreviewUrl(grantId, 'other.html'))).toBe(false) + expect(navigateTo(guest, 'https://example.com/')).toBe(true) + expect(guest.webRtcPolicy).toBe('disable_non_proxied_udp') + }) + + it('denies every window a workspace-document guest asks for', () => { + const { guest } = attachPreviewGuest() + + expect(guest.windowOpenHandler?.({ url: 'https://example.com/', frameName: '' })).toEqual({ + action: 'deny' + }) + }) + + // Why teardown and not just install: registration and teardown both key on the guest having been + // policy-attached, so a profile that installs outside that bookkeeping leaves the id marked + // attached forever and stays answerable to tools after the surface is gone. + it('tears a workspace-document guest down through the same policy cleanup', () => { + const { guest, browserPageId } = attachPreviewGuest(302) + expect(getWorkspaceDocPageGuest(browserPageId, host.id)).toBe(guest as never) + + for (const listener of guest.listeners.get('destroyed') ?? []) { + listener() + } + + expect(getWorkspaceDocPageGuest(browserPageId, host.id)).toBeNull() + // The manager's own bookkeeping was reached too: a second attach is refused while the id is + // still marked policy-attached, and accepted once its teardown has run. + browserManager.attachGuestPolicies(guest as never) + expect(listenerCount(guest, 'dom-ready')).toBe(1) + }) + + // The seam the whole split rests on: one public door answers for both halves, and each page id + // resolves in exactly one of them. Asserted against the real manager, because the IPC census + // test has to mock it. + describe('the one door across both halves of the registry', () => { + const BROWSING_PAGE_ID = 'browser-page-1' + + function registerBrowsingGuest(id = 400): GuestFake { + const guest = createGuest(id, 'https://example.com/') + browserMocks.webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: BROWSING_PAGE_ID, + workspaceId: 'workspace-1', + worktreeId: 'wt-1', + webContentsId: id, + rendererWebContentsId: host.id + }) + return guest + } + + it('answers each page with the guest of its own half', () => { + const browsing = registerBrowsingGuest() + const { guest: document, browserPageId } = attachPreviewGuest() + + expect(browserManager.getAuthorizedGuest(BROWSING_PAGE_ID, host.id)).toBe(browsing as never) + expect(browserManager.getAuthorizedGuest(browserPageId, host.id)).toBe(document as never) + }) + + // Why this is the containment claim and not a lookup detail: page management, agent commands, + // download routing and certificate attribution all read the browsing map directly, so a + // document page being absent from it is what fences them without a guard of their own. + it('keeps a document page out of the browsing map entirely', () => { + registerBrowsingGuest() + const { browserPageId } = attachPreviewGuest() + + expect(browserManager.getGuestWebContentsId(browserPageId)).toBeNull() + expect([...browserManager.getWebContentsIdByTabId().keys()]).toEqual([BROWSING_PAGE_ID]) + }) + }) + + it('attaches a guest once whatever profile it was asked for', () => { + const { guest } = attachPreviewGuest(303) + + browserManager.attachGuestPolicies(guest as never) + + expect(listenerCount(guest, 'dom-ready')).toBe(0) + expect(listenerCount(guest, 'will-navigate')).toBe(1) + }) +}) diff --git a/src/main/browser/browser-manager-viewport-override.test.ts b/src/main/browser/browser-manager-viewport-override.test.ts index c72936cf6a3..b7d3bbabe0a 100644 --- a/src/main/browser/browser-manager-viewport-override.test.ts +++ b/src/main/browser/browser-manager-viewport-override.test.ts @@ -882,34 +882,5 @@ describe('browserManager', () => { expect(debuggerAttach).toHaveBeenCalledWith('1.3') expect(debuggerSendCommand).not.toHaveBeenCalled() }) - - it('installs annotation viewport bridge in an isolated world', async () => { - const { guest } = makeGuest(4646) - webContentsFromIdMock.mockReturnValue(guest) - browserManager.attachGuestPolicies(guest as never) - browserManager.registerGuest({ - browserPageId: 'tab-annotations', - webContentsId: guest.id as number, - rendererWebContentsId - }) - - const ok = await browserManager.setAnnotationViewportBridge('tab-annotations', { - emitViewport: false, - enabled: true, - markers: [], - token: 'annotationviewporttoken' - }) - - expect(ok).toBe(true) - expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith( - expect.any(Number), - [ - expect.objectContaining({ - code: expect.stringContaining('__orcaBrowserAnnotationViewportBridge') - }) - ], - false - ) - }) }) }) diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 7ac64b485b9..2b43e17a371 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -63,6 +63,11 @@ import { BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID, buildBrowserAnnotationViewportBridgeScript } from '../../shared/browser-annotation-viewport-bridge' +import { + getWorkspaceDocPageGuest, + installDocPreviewGuestPolicy, + isWorkspaceDocPageId +} from './doc-preview-guest-policy' import type { KeybindingOverrides } from '../../shared/keybindings' import { BrowserCertificateTrustController, @@ -157,6 +162,15 @@ type PopupOwnerContext = { browserTabId: string rootGuestWebContentsId: number } +/** + * What a guest is allowed to be. A browsing guest is the web — popups, clicked-link routing and + * anti-detection all apply. A workspace-document guest renders one granted document and gets none + * of that; `host` is the renderer that minted its grant, and the only sink for what it reports. + */ +export type BrowserGuestPolicy = + | { profile: 'browsing' } + | { profile: 'workspace-doc'; host: Electron.WebContents } +const BROWSING_GUEST_POLICY: BrowserGuestPolicy = { profile: 'browsing' } type PendingMainFrameNavigation = { currentUrl: string supersededUrls: string[] @@ -671,12 +685,20 @@ export class BrowserManager { attachGuestPolicies( guest: Electron.WebContents, - inheritedOwnerContext: PopupOwnerContext | null = null + inheritedOwnerContext: PopupOwnerContext | null = null, + policy: BrowserGuestPolicy = BROWSING_GUEST_POLICY ): void { if (this.policyAttachedGuestIds.has(guest.id)) { return } this.policyAttachedGuestIds.add(guest.id) + // Why one door with a profile rather than a second installer beside it: whether a guest was + // policy-attached at all is what registration and teardown both key on, so a guest that took + // another path into the app is invisible to both. + if (policy.profile === 'workspace-doc') { + this.attachWorkspaceDocGuestPolicies(guest, policy.host) + return + } if (inheritedOwnerContext) { this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext) } @@ -1016,6 +1038,30 @@ export class BrowserManager { }) } + /** + * A workspace document is not the web: no popups, no link routing, no anti-detection, and no + * navigation bookkeeping for chrome it does not have. What it does share with a browsing guest is + * this method's teardown, so a retired preview drops its listeners on the same path. + */ + private attachWorkspaceDocGuestPolicies( + guest: Electron.WebContents, + host: Electron.WebContents + ): void { + const disposeDocPolicy = installDocPreviewGuestPolicy(guest, host) + const handleDestroyed = (): void => { + this.cleanupGuestPolicyAttachment(guest.id) + } + guest.on('destroyed', handleDestroyed) + this.policyCleanupByGuestId.set(guest.id, () => { + disposeDocPolicy() + try { + guest.off('destroyed', handleDestroyed) + } catch { + // guest may already be destroyed + } + }) + } + // Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA, // not the request header, so the header-level Firefox switch in setupClientHintsOverride // must be matched here per navigation or the two layers disagree — itself a bot tell. @@ -1318,7 +1364,9 @@ export class BrowserManager { rendererWebContentsId }: BrowserGuestRegistration): boolean { const browserTabId = browserPageId ?? legacyBrowserTabId - if (!browserTabId) { + // Why refuse rather than overwrite: the two halves of the registry must stay disjoint, or one + // id resolves in both and the tool door silently prefers the document guest over the page. + if (!browserTabId || isWorkspaceDocPageId(browserTabId)) { return false } // Why: on guest-surface swap, cancel any grab bound to the old guest's listeners so it doesn't strand on a stale webContents. @@ -1377,6 +1425,12 @@ export class BrowserManager { } unregisterGuest(browserTabId: string): void { + // Why the check on the exit door too: a document page withdraws by revoking its grant, never + // through here, so its id arriving is misaddressed — and the cancel below would evict that + // preview's live grab on the strength of it. + if (isWorkspaceDocPageId(browserTabId)) { + return + } // Why: teardown mid-grab must cancel it so the renderer gets a signal, not a dangling Promise. this.cancelGrabOp(browserTabId, 'evicted') @@ -1444,10 +1498,15 @@ export class BrowserManager { sessionProfileId?: string | null userAgentMode?: BrowserSessionUserAgentMode webContentsId: number - }): void { + }): boolean { + // Why the same check on both registration doors: one id resolving in both halves is the exact + // confusion the split registries exist to prevent. + if (isWorkspaceDocPageId(browserPageId)) { + return false + } const guest = webContents.fromId(webContentsId) if (!guest || guest.isDestroyed()) { - return + return false } // Why: offscreen pages have no renderer webview listeners, so main owns their load-failure lifecycle. this.offscreenGuestIds.add(webContentsId) @@ -1468,6 +1527,7 @@ export class BrowserManager { this.worktreeIdByTabId.set(browserPageId, worktreeId) } this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId) + return true } unregisterAll(): void { @@ -1843,12 +1903,13 @@ export class BrowserManager { async setAnnotationViewportBridge( browserTabId: string, - options: BrowserAnnotationViewportBridgeOptions + options: BrowserAnnotationViewportBridgeOptions, + resolveGuest: () => Electron.WebContents | null ): Promise { const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve() const next = prev .catch(() => {}) - .then(() => this.doSetAnnotationViewportBridgeImpl(browserTabId, options)) + .then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest)) this.annotationViewportBridgeOpsByTabId.set(browserTabId, next) try { return await next @@ -1859,18 +1920,22 @@ export class BrowserManager { } } + // Why the caller resolves the guest: the same bridge serves browsing pages and workspace + // documents, which live in different halves of the page registry. + // Why a resolver and not the guest itself: this op may have waited behind another one, and a + // cross-process navigation meanwhile swaps the tab's contents without destroying the old one — + // injecting into the guest the request named would bridge a page nobody is looking at. + // Why no tab id: with teardown gone this reaches only the guest the resolver hands back, and + // taking an id it cannot act on would invite the next reader to act on it. private async doSetAnnotationViewportBridgeImpl( - browserTabId: string, - options: BrowserAnnotationViewportBridgeOptions + options: BrowserAnnotationViewportBridgeOptions, + resolveGuest: () => Electron.WebContents | null ): Promise { - const webContentsId = this.webContentsIdByTabId.get(browserTabId) - if (!webContentsId) { - return false - } - const guest = webContents.fromId(webContentsId) + // Why no teardown here: the resolver already unregisters a page whose guest died, and the only + // case it uniquely leaves is an ownership mismatch on a healthy page — where tearing down would + // cancel that page's in-flight downloads and grabs over a request that was merely misaddressed. + const guest = resolveGuest() if (!guest || guest.isDestroyed()) { - // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. - this.unregisterGuest(browserTabId) return false } @@ -1977,10 +2042,21 @@ export class BrowserManager { // --- Browser Context Grab — main-owned operations --- /** Validate that the sender owns browserTabId; returns the guest WebContents or null. */ + /** + * The guest a request from `senderWebContentsId` may act on, across both halves of the page + * registry. This is the only door taught about workspace-document guests: they are kept out of + * the browsing maps entirely, so page management, agent commands, download routing and + * certificate attribution all miss them without a guard of their own — and a reader who opens a + * tool on the document in front of them still gets an answer. + */ getAuthorizedGuest( browserTabId: string, senderWebContentsId: number ): Electron.WebContents | null { + const docGuest = getWorkspaceDocPageGuest(browserTabId, senderWebContentsId) + if (docGuest) { + return docGuest + } const registeredRenderer = this.rendererWebContentsIdByTabId.get(browserTabId) if (registeredRenderer == null || registeredRenderer !== senderWebContentsId) { return null diff --git a/src/main/browser/browser-session-partition-policies.test.ts b/src/main/browser/browser-session-partition-policies.test.ts new file mode 100644 index 00000000000..a3667154c9b --- /dev/null +++ b/src/main/browser/browser-session-partition-policies.test.ts @@ -0,0 +1,185 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { BrowserSessionProfile } from '../../shared/browser-workspace-types' + +const mocks = vi.hoisted(() => ({ + handleGuestWillDownload: vi.fn(), + noticeDocPreviewDownloadBlocked: vi.fn() +})) + +type WillDownloadListener = ( + event: { preventDefault: () => void }, + item: { id: string }, + webContents: { id: number } +) => void + +type FakeSession = { + listeners: WillDownloadListener[] + on: ReturnType + removeListener: ReturnType + getUserAgent: () => string + setUserAgent: ReturnType + setPermissionRequestHandler: ReturnType + setPermissionCheckHandler: ReturnType + setDisplayMediaRequestHandler: ReturnType +} + +const sessionsByPartition = new Map() + +function fakeSession(): FakeSession { + const listeners: WillDownloadListener[] = [] + return { + listeners, + on: vi.fn((event: string, listener: WillDownloadListener) => { + if (event === 'will-download') { + listeners.push(listener) + } + }), + removeListener: vi.fn((event: string, listener: WillDownloadListener) => { + if (event !== 'will-download') { + return + } + const index = listeners.indexOf(listener) + if (index !== -1) { + listeners.splice(index, 1) + } + }), + getUserAgent: () => 'Mozilla/5.0 Orca', + setUserAgent: vi.fn(), + setPermissionRequestHandler: vi.fn(), + setPermissionCheckHandler: vi.fn(), + setDisplayMediaRequestHandler: vi.fn() + } +} + +vi.mock('electron', () => ({ + session: { + fromPartition: (partition: string) => { + const existing = sessionsByPartition.get(partition) + if (existing) { + return existing + } + const created = fakeSession() + sessionsByPartition.set(partition, created) + return created + } + } +})) +vi.mock('./browser-manager', () => ({ + browserManager: { + handleGuestWillDownload: mocks.handleGuestWillDownload, + installCertificateRequestGuard: vi.fn(), + removeCertificateRequestGuard: vi.fn(), + notifyPermissionDenied: vi.fn() + } +})) +vi.mock('./doc-preview-download-block-notice', () => ({ + noticeDocPreviewDownloadBlocked: mocks.noticeDocPreviewDownloadBlocked +})) +vi.mock('./browser-media-access', () => ({ + hasSystemMediaAccess: () => false, + requestSystemMediaAccess: async () => false +})) +vi.mock('./browser-session-ua', () => ({ + cleanElectronUserAgent: (userAgent: string) => userAgent, + setupClientHintsOverride: vi.fn() +})) +vi.mock('./browser-session-user-agent-mode', () => ({ + setBrowserSessionUserAgentMode: vi.fn() +})) +vi.mock('./browser-webauthn-access', () => ({ + allowsBrowserWebAuthnPermission: () => false, + clearBrowserWebAuthnAccessHandlers: vi.fn(), + installBrowserWebAuthnAccessHandlers: vi.fn() +})) + +type PartitionPolicyInstaller = ( + profile: BrowserSessionProfile, + options?: { downloads?: 'route' | 'deny' } +) => void + +// Why imported per test rather than at the top: the installer remembers which partitions it has +// already configured in module state, so a shared import would make the second test's install a +// no-op and leave it reading the first test's listener. +async function loadInstaller(): Promise { + const module = await import('./browser-session-partition-policies') + return module.installBrowserSessionPartitionPolicies +} + +function profileFor(partition: string): BrowserSessionProfile { + return { + id: partition, + scope: 'isolated', + partition, + label: partition, + source: null, + userAgentMode: 'clean' + } +} + +/** Fires the partition's real `will-download` listener and reports what it decided. */ +function fireWillDownload(partition: string): { cancelled: boolean } { + const sess = sessionsByPartition.get(partition) + if (!sess || sess.listeners.length !== 1) { + throw new Error(`expected exactly one will-download listener on ${partition}`) + } + let cancelled = false + sess.listeners[0]({ preventDefault: () => (cancelled = true) }, { id: 'item-1' }, { id: 42 }) + return { cancelled } +} + +beforeEach(() => { + vi.clearAllMocks() + sessionsByPartition.clear() + vi.resetModules() +}) + +describe('partition download policy', () => { + // The presence half: without it, a deny assertion passes for a partition that installed no + // listener at all, and would keep passing if the whole download path were removed. + it('routes a download on a partition that did not ask for the deny', async () => { + const install = await loadInstaller() + install(profileFor('persist:browsing-1')) + + expect(fireWillDownload('persist:browsing-1').cancelled).toBe(false) + expect(mocks.handleGuestWillDownload).toHaveBeenCalledWith( + expect.objectContaining({ guestWebContentsId: 42 }) + ) + }) + + it('cancels a download on a partition that asked for the deny, routing nothing', async () => { + const install = await loadInstaller() + install(profileFor('orca-doc-preview'), { downloads: 'deny' }) + + expect(fireWillDownload('orca-doc-preview').cancelled).toBe(true) + expect(mocks.handleGuestWillDownload).not.toHaveBeenCalled() + }) + + // Why in the same run as the routing test above: a refusal the reader cannot see is a pressed + // button that does nothing, and a notice on the routing partition would announce a download that + // is about to arrive normally. + it('tells the reader about the refusal, and only on the partition that refused', async () => { + const install = await loadInstaller() + install(profileFor('orca-doc-preview'), { downloads: 'deny' }) + install(profileFor('persist:browsing-1')) + + fireWillDownload('persist:browsing-1') + expect(mocks.noticeDocPreviewDownloadBlocked).not.toHaveBeenCalled() + + fireWillDownload('orca-doc-preview') + expect(mocks.noticeDocPreviewDownloadBlocked).toHaveBeenCalledWith( + expect.objectContaining({ id: 42 }) + ) + }) + + // Why both partitions in one run: the listener is module state shared across sessions, so a deny + // installed for one partition must not follow the next partition that installs after it. + it('keeps each partition on its own decision', async () => { + const install = await loadInstaller() + install(profileFor('orca-doc-preview'), { downloads: 'deny' }) + install(profileFor('persist:browsing-1')) + + expect(fireWillDownload('orca-doc-preview').cancelled).toBe(true) + expect(fireWillDownload('persist:browsing-1').cancelled).toBe(false) + expect(mocks.handleGuestWillDownload).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/browser/browser-session-partition-policies.ts b/src/main/browser/browser-session-partition-policies.ts index 2118c62ed68..cf542665ae4 100644 --- a/src/main/browser/browser-session-partition-policies.ts +++ b/src/main/browser/browser-session-partition-policies.ts @@ -11,6 +11,7 @@ import { clearBrowserWebAuthnAccessHandlers, installBrowserWebAuthnAccessHandlers } from './browser-webauthn-access' +import { noticeDocPreviewDownloadBlocked } from './doc-preview-download-block-notice' // Why: one shared installer keeps every partition's deny-by-default permission/download policies from drifting apart. const configuredPartitions = new Set() @@ -22,6 +23,23 @@ const handleWillDownload = ( browserManager.handleGuestWillDownload({ guestWebContentsId: webContents.id, item }) } +/** + * Why a second listener instead of a branch inside the shared one: `will-download` is a session + * event that names no partition, so the only place the decision can be keyed by partition is which + * listener that partition's session got. A workspace-document guest has no page of its own to + * attribute a download to, so routing one lands it in this desktop's Downloads folder under a + * remote-authored name that nothing in the UI accounts for. + */ +const handleDeniedWillDownload = ( + event: Electron.Event, + _item: Electron.DownloadItem, + webContents: Electron.WebContents +): void => { + event.preventDefault() + // The page gets nothing back; the reader gets a sentence, or a pressed button just does nothing. + noticeDocPreviewDownloadBlocked(webContents) +} + function resolvePermissionNoticeUrl( webContents: Electron.WebContents, details: Electron.PermissionRequest | undefined @@ -37,7 +55,13 @@ function resolvePermissionNoticeUrl( } } -export function installBrowserSessionPartitionPolicies(profile: BrowserSessionProfile): void { +/** `route` hands the item to the owning page's download flow; `deny` cancels it before it starts. */ +export type BrowserPartitionDownloadPolicy = 'route' | 'deny' + +export function installBrowserSessionPartitionPolicies( + profile: BrowserSessionProfile, + options?: { downloads?: BrowserPartitionDownloadPolicy } +): void { const { partition } = profile const sess = session.fromPartition(partition) setBrowserSessionUserAgentMode(sess, profile.userAgentMode ?? 'clean') @@ -106,7 +130,11 @@ export function installBrowserSessionPartitionPolicies(profile: BrowserSessionPr callback({ video: undefined, audio: undefined }) }) sess.removeListener('will-download', handleWillDownload) - sess.on('will-download', handleWillDownload) + sess.removeListener('will-download', handleDeniedWillDownload) + sess.on( + 'will-download', + options?.downloads === 'deny' ? handleDeniedWillDownload : handleWillDownload + ) configuredPartitions.add(partition) } @@ -115,6 +143,7 @@ export function clearBrowserSessionPartitionPolicies(partition: string, sess: Se configuredPartitions.delete(partition) browserManager.removeCertificateRequestGuard(sess) sess.removeListener('will-download', handleWillDownload) + sess.removeListener('will-download', handleDeniedWillDownload) clearBrowserWebAuthnAccessHandlers(sess) sess.setPermissionRequestHandler(null) sess.setPermissionCheckHandler(null) diff --git a/src/main/browser/doc-preview-download-block-notice.test.ts b/src/main/browser/doc-preview-download-block-notice.test.ts new file mode 100644 index 00000000000..9fc4396b329 --- /dev/null +++ b/src/main/browser/doc-preview-download-block-notice.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + publishDocPreviewFailure: vi.fn(), + boundGrantIdByGuest: new Map() +})) + +vi.mock('./doc-preview-failure-notice', () => ({ + publishDocPreviewFailure: mocks.publishDocPreviewFailure +})) +vi.mock('./doc-preview-guest-policy', () => ({ + readDocPreviewGuestBoundGrantId: (guest: object) => mocks.boundGrantIdByGuest.get(guest) ?? null +})) + +const GRANT_ID = 'a'.repeat(32) +const OTHER_GRANT_ID = 'b'.repeat(32) + +/** Only the identity matters: the module asks the guest registry what grant this contents holds. */ +function guestBoundTo(grantId: string | null): Electron.WebContents { + const guest = {} as Electron.WebContents + if (grantId !== null) { + mocks.boundGrantIdByGuest.set(guest, grantId) + } + return guest +} + +async function loadNotifier(): Promise<(guest: Electron.WebContents) => void> { + const module = await import('./doc-preview-download-block-notice') + return module.noticeDocPreviewDownloadBlocked +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.boundGrantIdByGuest.clear() + // Why per test: the module remembers which grants it has already told the reader about. + vi.resetModules() + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-27T00:00:00.000Z')) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('noticeDocPreviewDownloadBlocked', () => { + it('tells the shell which preview had a download refused', async () => { + const notice = await loadNotifier() + + notice(guestBoundTo(GRANT_ID)) + + expect(mocks.publishDocPreviewFailure).toHaveBeenCalledWith({ + grantId: GRANT_ID, + reason: 'download-blocked' + }) + }) + + // Why: a document can ask in a loop. The reader learns nothing from the second notice, and every + // attempt would otherwise cross the IPC boundary and re-render the strip. + it('says it once however often the document asks', async () => { + const notice = await loadNotifier() + const guest = guestBoundTo(GRANT_ID) + + notice(guest) + notice(guest) + vi.advanceTimersByTime(1_500) + notice(guest) + + expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(1) + }) + + it('says it again for an attempt long after the last one', async () => { + const notice = await loadNotifier() + const guest = guestBoundTo(GRANT_ID) + + notice(guest) + vi.advanceTimersByTime(2_500) + notice(guest) + + expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(2) + }) + + // Why not one throttle for the whole app: two previews are two readers, and silencing the second + // because the first just refused something leaves that press unexplained. + it('throttles each preview on its own', async () => { + const notice = await loadNotifier() + + notice(guestBoundTo(GRANT_ID)) + notice(guestBoundTo(OTHER_GRANT_ID)) + + expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(2) + expect(mocks.publishDocPreviewFailure).toHaveBeenLastCalledWith({ + grantId: OTHER_GRANT_ID, + reason: 'download-blocked' + }) + }) + + // The absence half of the first test: no shell is showing this contents, so there is no preview + // to put a notice on. Without the presence tests above, this would pass on a module that never + // published anything at all. + it('says nothing for a contents no preview is bound to', async () => { + const notice = await loadNotifier() + + notice(guestBoundTo(null)) + + expect(mocks.publishDocPreviewFailure).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/browser/doc-preview-download-block-notice.ts b/src/main/browser/doc-preview-download-block-notice.ts new file mode 100644 index 00000000000..2aefb2824a7 --- /dev/null +++ b/src/main/browser/doc-preview-download-block-notice.ts @@ -0,0 +1,34 @@ +import { publishDocPreviewFailure } from './doc-preview-failure-notice' +import { readDocPreviewGuestBoundGrantId } from './doc-preview-guest-policy' + +/** + * Why the reader is told at all, when the point of the fence is that nothing happens: a press that + * produces no file and no explanation reads as Orca being broken. The notice is chrome the document + * cannot see or read back, so the refusal stays as silent to the page as it was. + * + * Why a floor between notices: a document can ask in a loop, and every attempt would otherwise + * cross the IPC boundary and re-render the strip. The reader learns nothing from the thousandth. + */ +const NOTICE_MIN_INTERVAL_MS = 2_000 +const noticedAtByGrantId = new Map() + +export function noticeDocPreviewDownloadBlocked(guest: Electron.WebContents): void { + const grantId = readDocPreviewGuestBoundGrantId(guest) + // Nothing to route a notice to: no shell is showing this contents as a preview. + if (grantId === null) { + return + } + const now = Date.now() + // Doubles as the expiry check, so what survives is exactly "noticed inside the window", and a + // grant that stops downloading stops being remembered. + for (const [noticedGrantId, noticedAt] of noticedAtByGrantId) { + if (now - noticedAt >= NOTICE_MIN_INTERVAL_MS) { + noticedAtByGrantId.delete(noticedGrantId) + } + } + if (noticedAtByGrantId.has(grantId)) { + return + } + noticedAtByGrantId.set(grantId, now) + publishDocPreviewFailure({ grantId, reason: 'download-blocked' }) +} diff --git a/src/main/browser/doc-preview-failure-notice.test.ts b/src/main/browser/doc-preview-failure-notice.test.ts new file mode 100644 index 00000000000..2272e7bf614 --- /dev/null +++ b/src/main/browser/doc-preview-failure-notice.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { DOC_PREVIEW_LOAD_FAILURE_CHANNEL } from '../../shared/doc-preview-scheme' +import { publishDocPreviewFailure, setDocPreviewFailureSink } from './doc-preview-failure-notice' + +afterEach(() => { + setDocPreviewFailureSink(null) +}) + +describe('publishDocPreviewFailure', () => { + it('sends the grant, path, and reason on the failure channel', () => { + const send = vi.fn() + setDocPreviewFailureSink({ send }) + + publishDocPreviewFailure({ + grantId: 'a'.repeat(32), + relativePath: 'index.html', + reason: 'too-large' + }) + + expect(send).toHaveBeenCalledWith(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, { + grantId: 'a'.repeat(32), + relativePath: 'index.html', + reason: 'too-large' + }) + }) + + // Why: reads can outlive the window that asked for them; a missing sink must not throw inside + // the protocol handler. + it('is a no-op with no sink registered', () => { + expect(() => + publishDocPreviewFailure({ + grantId: 'b'.repeat(32), + relativePath: 'index.html', + reason: 'unreadable' + }) + ).not.toThrow() + }) + + it('sends nothing into a destroyed window', () => { + const send = vi.fn() + setDocPreviewFailureSink({ send, isDestroyed: () => true }) + + publishDocPreviewFailure({ + grantId: 'c'.repeat(32), + relativePath: 'index.html', + reason: 'unreadable' + }) + + expect(send).not.toHaveBeenCalled() + }) + + // Why: a WebContents can be torn down between the liveness check and the send. + it('swallows a throwing sink and stops using it', () => { + const send = vi.fn(() => { + throw new Error('Object has been destroyed') + }) + setDocPreviewFailureSink({ send }) + const failure = { + grantId: 'd'.repeat(32), + relativePath: 'index.html', + reason: 'unreadable' as const + } + + expect(() => publishDocPreviewFailure(failure)).not.toThrow() + publishDocPreviewFailure(failure) + + expect(send).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/browser/doc-preview-failure-notice.ts b/src/main/browser/doc-preview-failure-notice.ts new file mode 100644 index 00000000000..1b7330d6f27 --- /dev/null +++ b/src/main/browser/doc-preview-failure-notice.ts @@ -0,0 +1,34 @@ +import { + DOC_PREVIEW_LOAD_FAILURE_CHANNEL, + type DocPreviewFailure +} from '../../shared/doc-preview-scheme' + +type DocPreviewFailureSink = { + send: (channel: string, payload: DocPreviewFailure) => void + isDestroyed?: () => boolean +} + +let failureSink: DocPreviewFailureSink | null = null + +export function setDocPreviewFailureSink(sink: DocPreviewFailureSink | null): void { + failureSink = sink +} + +/** + * The preview shell cannot read the guest's HTTP status, and a 4xx body renders as + * if it were the document. Pushing the reason lets the shell replace that with a + * localized notice for the failure the user actually hit. + */ +export function publishDocPreviewFailure(failure: DocPreviewFailure): void { + const sink = failureSink + // Why: a read can outlive the window that asked for it, and sending into torn-down + // WebContents throws — an unreadable asset must not take the protocol handler with it. + if (!sink || sink.isDestroyed?.()) { + return + } + try { + sink.send(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, failure) + } catch { + failureSink = null + } +} diff --git a/src/main/browser/doc-preview-file-reader.test.ts b/src/main/browser/doc-preview-file-reader.test.ts new file mode 100644 index 00000000000..fc707279f8f --- /dev/null +++ b/src/main/browser/doc-preview-file-reader.test.ts @@ -0,0 +1,333 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + callRuntimeEnvironment: vi.fn(), + readFile: vi.fn(), + realpath: vi.fn(), + requireSshFilesystemProvider: vi.fn() +})) + +vi.mock('../ipc/runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: mocks.callRuntimeEnvironment +})) +vi.mock('../persistence', () => ({ getCanonicalUserDataPath: () => '/user-data' })) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + requireSshFilesystemProvider: mocks.requireSshFilesystemProvider +})) + +import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader' +import { docPreviewContentType, readDocPreviewFile } from './doc-preview-file-reader' +import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry' + +function sshGrant(): ReturnType { + return mintDocPreviewGrant({ + owner: { kind: 'ssh', connectionId: 'ssh-1' }, + root: '/home/alice/docs', + entryRelativePath: 'index.html', + browserPageId: 'page-1' + }) +} + +function runtimeGrant(root = '/srv/repo/docs'): ReturnType { + return mintDocPreviewGrant({ + owner: { + kind: 'runtime', + environmentId: 'env-1', + worktreeSelector: 'id:wt-1', + worktreeRoot: '/srv/repo' + }, + root, + entryRelativePath: 'index.html', + browserPageId: 'page-1' + }) +} + +beforeEach(() => { + vi.clearAllMocks() + revokeAllDocPreviewGrants() + // Why: an unsymlinked host canonicalizes to the path it was given. + mocks.realpath.mockImplementation((path: string) => Promise.resolve(path)) + mocks.requireSshFilesystemProvider.mockReturnValue({ + readFile: mocks.readFile, + realpath: mocks.realpath + }) +}) + +describe('docPreviewContentType', () => { + it('maps document and asset extensions, defaulting to octet-stream', () => { + expect(docPreviewContentType('index.html')).toBe('text/html; charset=utf-8') + expect(docPreviewContentType('assets/app.CSS')).toBe('text/css; charset=utf-8') + expect(docPreviewContentType('assets/logo.png')).toBe('image/png') + expect(docPreviewContentType('data.bin')).toBe('application/octet-stream') + }) +}) + +describe('readDocPreviewFile — ssh owner', () => { + it('reads text through the SSH filesystem provider', async () => { + mocks.readFile.mockResolvedValue({ content: '

hi

', isBinary: false }) + + const outcome = await readDocPreviewFile(sshGrant(), 'index.html') + + expect(mocks.requireSshFilesystemProvider).toHaveBeenCalledWith('ssh-1') + expect(mocks.readFile).toHaveBeenCalledWith('/home/alice/docs/index.html') + expect(outcome).toEqual({ + ok: true, + bytes: Buffer.from('

hi

', 'utf8'), + contentType: 'text/html; charset=utf-8' + }) + }) + + it('decodes a base64 binary asset', async () => { + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]) + mocks.readFile.mockResolvedValue({ content: png.toString('base64'), isBinary: true }) + + const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png') + + expect(outcome).toEqual({ ok: true, bytes: png, contentType: 'image/png' }) + }) + + // Why: the SSH reader rejects an over-cap file rather than clamping it, so a completed read is + // always whole and needs no truncation flag. + it('serves a whole SSH read that carries no truncation flag', async () => { + mocks.readFile.mockResolvedValue({ content: '

whole

', isBinary: false }) + + expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ ok: true }) + }) + + // Why: the SSH read path only serves images and PDFs as bytes, so a font is refused there by + // design — the failure must name the file type, not a stale server. + it('reports a file type the host will not send as unsupported-asset', async () => { + mocks.readFile.mockResolvedValue({ content: '', isBinary: true }) + + const outcome = await readDocPreviewFile(sshGrant(), 'assets/font.woff2') + + expect(outcome).toMatchObject({ ok: false, status: 415, reason: 'unsupported-asset' }) + }) + + // Why: a host that still named the type read a 0-byte file, so 0 bytes is the honest answer — + // reporting it as a refused format would be a failure the workspace never reported. + it('serves an empty file the host still typed instead of calling it unsupported', async () => { + mocks.readFile.mockResolvedValue({ content: '', isBinary: true, mimeType: 'image/png' }) + + const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png') + + expect(outcome).toEqual({ ok: true, bytes: Buffer.alloc(0), contentType: 'image/png' }) + }) + + // Why: containment above is lexical, and the SSH read RPC enforces no root of its own, so a + // symlink inside the grant would otherwise read anything the account can reach. + it('404s a path that canonicalizes outside the grant root', async () => { + mocks.realpath.mockImplementation((path: string) => + Promise.resolve(path === '/home/alice/docs/escape.html' ? '/etc/shadow' : path) + ) + + const outcome = await readDocPreviewFile(sshGrant(), 'escape.html') + + expect(outcome).toMatchObject({ ok: false, status: 404 }) + expect(mocks.readFile).not.toHaveBeenCalled() + }) + + it('reads the canonical path once containment holds', async () => { + mocks.realpath.mockImplementation((path: string) => + Promise.resolve(path === '/home/alice/docs/link.html' ? '/home/alice/docs/real.html' : path) + ) + mocks.readFile.mockResolvedValue({ content: '

real

', isBinary: false }) + + expect(await readDocPreviewFile(sshGrant(), 'link.html')).toMatchObject({ ok: true }) + expect(mocks.readFile).toHaveBeenCalledWith('/home/alice/docs/real.html') + }) + + // Why: a symlinked root is legitimate; containment must be judged on what both sides resolve to. + it('keeps serving a grant whose own root is a symlink', async () => { + mocks.realpath.mockImplementation((path: string) => + Promise.resolve(path.replace('/home/alice/docs', '/mnt/data/docs')) + ) + mocks.readFile.mockResolvedValue({ content: '

hi

', isBinary: false }) + + expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ ok: true }) + expect(mocks.readFile).toHaveBeenCalledWith('/mnt/data/docs/index.html') + }) + + it('404s when the host cannot canonicalize the path at all', async () => { + mocks.realpath.mockRejectedValue(new Error('no such file')) + + expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ + ok: false, + status: 404 + }) + expect(mocks.readFile).not.toHaveBeenCalled() + }) + + it('404s a path outside the grant root without touching the provider', async () => { + const outcome = await readDocPreviewFile(sshGrant(), '../../etc/passwd') + + expect(outcome).toMatchObject({ ok: false, status: 404 }) + expect(mocks.requireSshFilesystemProvider).not.toHaveBeenCalled() + }) + + it('reports an over-cap SSH file as too large rather than unreadable', async () => { + mocks.readFile.mockRejectedValue(new FileReadCapExceededError('exceeds client cap')) + + expect(await readDocPreviewFile(sshGrant(), 'huge.html')).toMatchObject({ + ok: false, + status: 413 + }) + }) + + it('404s when the provider read fails', async () => { + mocks.readFile.mockRejectedValue(new Error('no such file')) + + expect(await readDocPreviewFile(sshGrant(), 'missing.html')).toMatchObject({ + ok: false, + status: 404 + }) + }) +}) + +describe('readDocPreviewFile — paired runtime owner', () => { + it('reads text over worktree-relative files.read', async () => { + mocks.callRuntimeEnvironment.mockResolvedValue({ + ok: true, + result: { content: '

remote

', truncated: false, byteLength: 15 } + }) + + const outcome = await readDocPreviewFile(runtimeGrant(), 'index.html') + + expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith( + '/user-data', + 'env-1', + 'files.read', + { worktree: 'id:wt-1', relativePath: 'docs/index.html' }, + 15_000 + ) + expect(outcome).toEqual({ + ok: true, + bytes: Buffer.from('

remote

', 'utf8'), + contentType: 'text/html; charset=utf-8' + }) + }) + + it('falls back to the base64 preview RPC for a binary asset', async () => { + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]) + mocks.callRuntimeEnvironment + .mockResolvedValueOnce({ + ok: false, + error: { code: 'runtime_error', message: 'binary_file' } + }) + .mockResolvedValueOnce({ + ok: true, + result: { + content: png.toString('base64'), + isBinary: true, + isImage: true, + mimeType: 'image/png' + } + }) + + const outcome = await readDocPreviewFile(runtimeGrant(), 'assets/logo.png') + + expect(mocks.callRuntimeEnvironment).toHaveBeenNthCalledWith( + 2, + '/user-data', + 'env-1', + 'files.readPreview', + { worktree: 'id:wt-1', relativePath: 'docs/assets/logo.png' }, + 15_000 + ) + expect(outcome).toEqual({ ok: true, bytes: png, contentType: 'image/png' }) + }) + + it('degrades on an old server whose empty binary preview carries no metadata', async () => { + mocks.callRuntimeEnvironment + .mockResolvedValueOnce({ + ok: false, + error: { code: 'runtime_error', message: 'binary_file' } + }) + .mockResolvedValueOnce({ ok: true, result: { content: '', isBinary: true } }) + + expect(await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')).toMatchObject({ + ok: false, + status: 415, + reason: 'unsupported-asset' + }) + }) + + it('serves an empty paired asset the host typed rather than reporting a refusal', async () => { + mocks.callRuntimeEnvironment + .mockResolvedValueOnce({ + ok: false, + error: { code: 'runtime_error', message: 'binary_file' } + }) + .mockResolvedValueOnce({ + ok: true, + result: { content: '', isBinary: true, isImage: true, mimeType: 'image/png' } + }) + + expect(await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')).toEqual({ + ok: true, + bytes: Buffer.alloc(0), + contentType: 'image/png' + }) + }) + + // Why: files.read clamps text at the host cap and only says so in `truncated`; serving the + // clamped bytes renders a document that silently stops halfway. + it('refuses a truncated text read instead of serving the clamped bytes', async () => { + mocks.callRuntimeEnvironment.mockResolvedValue({ + ok: true, + result: { content: '

half of', truncated: true, byteLength: 40_000_000 } + }) + + const outcome = await readDocPreviewFile(runtimeGrant(), 'index.html') + + expect(outcome).toMatchObject({ ok: false, status: 413 }) + expect(outcome).not.toMatchObject({ ok: true }) + }) + + it('serves a read the host reports as complete', async () => { + mocks.callRuntimeEnvironment.mockResolvedValue({ + ok: true, + result: { content: '

all

', truncated: false, byteLength: 12 } + }) + + expect(await readDocPreviewFile(runtimeGrant(), 'index.html')).toMatchObject({ ok: true }) + }) + + // Why: the binary RPC has no `truncated` field — it rejects an over-cap asset with this error. + it('reports the host rejecting an over-cap binary as too large', async () => { + mocks.callRuntimeEnvironment + .mockResolvedValueOnce({ + ok: false, + error: { code: 'runtime_error', message: 'binary_file' } + }) + .mockResolvedValueOnce({ + ok: false, + error: { code: 'runtime_error', message: 'file_too_large' } + }) + + expect(await readDocPreviewFile(runtimeGrant(), 'assets/huge.png')).toMatchObject({ + ok: false, + status: 413 + }) + }) + + it('does not treat an unrelated RPC failure as a binary fallback', async () => { + mocks.callRuntimeEnvironment.mockResolvedValue({ + ok: false, + error: { code: 'runtime_error', message: 'permission_denied' } + }) + + expect(await readDocPreviewFile(runtimeGrant(), 'index.html')).toMatchObject({ + ok: false, + status: 404 + }) + expect(mocks.callRuntimeEnvironment).toHaveBeenCalledOnce() + }) + + it('404s a document outside the worktree, which files.read cannot address', async () => { + const outcome = await readDocPreviewFile(runtimeGrant('/tmp/agent-docs'), 'index.html') + + expect(outcome).toMatchObject({ ok: false, status: 404 }) + expect(mocks.callRuntimeEnvironment).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/browser/doc-preview-file-reader.ts b/src/main/browser/doc-preview-file-reader.ts new file mode 100644 index 00000000000..967461a91f9 --- /dev/null +++ b/src/main/browser/doc-preview-file-reader.ts @@ -0,0 +1,198 @@ +import { extname } from 'node:path' +import type { + RuntimeFilePreviewResult, + RuntimeFileReadResult +} from '../../shared/runtime-file-contracts' +import type { DocPreviewFileFailureReason } from '../../shared/doc-preview-scheme' +import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing' +import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader' +import { getCanonicalUserDataPath } from '../persistence' +import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { + resolveCanonicalDocPreviewPath, + resolveDocPreviewTargetPath, + toRuntimeWorktreeRelativePath, + type DocPreviewGrant +} from './doc-preview-grant-registry' + +const DOC_PREVIEW_READ_TIMEOUT_MS = 15_000 + +/** Why not "needs a newer server": the SSH read path only ever serves images and PDFs as bytes, so + * a font is refused there by design, not by version. Name the file type, not the host's age. */ +const UNSERVABLE_ASSET_PREVIEW_MESSAGE = 'This workspace cannot send this file type to a preview.' + +/** `files.read` clamps text at the host's cap and reports it; serving the clamped bytes would + * render a silently half-finished document. */ +const TRUNCATED_PREVIEW_MESSAGE = 'This document is too large for the server to send in full.' + +/** The paired host rejects an over-cap asset outright instead of clamping it. */ +const RUNTIME_TOO_LARGE_ERROR = 'file_too_large' + +/** Both owners refuse an over-cap file; only their error shapes differ. */ +function isTooLargeReadError(error: unknown): boolean { + return ( + error instanceof FileReadCapExceededError || + (error instanceof Error && error.message === RUNTIME_TOO_LARGE_ERROR) + ) +} + +export type DocPreviewReadOutcome = + | { ok: true; bytes: Buffer; contentType: string } + | { ok: false; status: number; reason: DocPreviewFileFailureReason; message: string } + +const DOC_PREVIEW_CONTENT_TYPES: Record = { + '.html': 'text/html; charset=utf-8', + '.htm': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.txt': 'text/plain; charset=utf-8', + '.md': 'text/plain; charset=utf-8', + '.csv': 'text/plain; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.bmp': 'image/bmp', + '.ico': 'image/x-icon', + '.pdf': 'application/pdf', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.otf': 'font/otf' +} + +export function docPreviewContentType(relativePath: string): string { + return ( + DOC_PREVIEW_CONTENT_TYPES[extname(relativePath).toLowerCase()] ?? 'application/octet-stream' + ) +} + +type PreviewFileBytes = { + content: string + isBinary: boolean + truncated?: boolean + /** Set by every owner that agreed to serve the bytes, so it also survives a 0-byte file. */ + mimeType?: string +} + +function toOutcome(source: PreviewFileBytes, contentType: string): DocPreviewReadOutcome { + if (source.truncated) { + return { ok: false, status: 413, reason: 'too-large', message: TRUNCATED_PREVIEW_MESSAGE } + } + if (!source.isBinary) { + return { ok: true, bytes: Buffer.from(source.content, 'utf8'), contentType } + } + if (source.content) { + return { ok: true, bytes: Buffer.from(source.content, 'base64'), contentType } + } + // Why: an empty binary body is two different answers. A host that still named the file's type + // read a 0-byte file, and 0 bytes is what it should serve; a host that named no type declined + // the format outright and has nothing to send. + return source.mimeType + ? { ok: true, bytes: Buffer.alloc(0), contentType } + : { + ok: false, + status: 415, + reason: 'unsupported-asset', + message: UNSERVABLE_ASSET_PREVIEW_MESSAGE + } +} + +async function readRuntimeDocPreviewFile( + environmentId: string, + worktreeSelector: string, + relativePath: string +): Promise { + const userDataPath = getCanonicalUserDataPath() + const response = await callRuntimeEnvironment( + userDataPath, + environmentId, + 'files.read', + { worktree: worktreeSelector, relativePath }, + DOC_PREVIEW_READ_TIMEOUT_MS + ) + if (response.ok) { + const result = response.result as RuntimeFileReadResult + return { content: result.content, isBinary: false, truncated: result.truncated === true } + } + // Why: files.read rejects binaries with a typed error; the base64 preview RPC serves + // images and fonts the same way it does for markdown previews. Match the exact + // message so an unrelated failure can't spoof the fallback. + if (response.error.message !== 'binary_file') { + throw new Error(response.error.message) + } + const previewResponse = await callRuntimeEnvironment( + userDataPath, + environmentId, + 'files.readPreview', + { worktree: worktreeSelector, relativePath }, + DOC_PREVIEW_READ_TIMEOUT_MS + ) + if (!previewResponse.ok) { + throw new Error(previewResponse.error.message) + } + const preview = previewResponse.result as RuntimeFilePreviewResult + // Why: readPreview never clamps — it rejects an over-cap asset — so its body is whole or absent. + return { + content: preview.content, + isBinary: preview.isBinary, + ...(preview.mimeType ? { mimeType: preview.mimeType } : {}) + } +} + +function notFoundOutcome(message = 'Not found'): DocPreviewReadOutcome { + return { ok: false, status: 404, reason: 'unreadable', message } +} + +/** Reads one in-grant path over the same channel the editor uses for that owner. */ +export async function readDocPreviewFile( + grant: DocPreviewGrant, + relativePath: string +): Promise { + const absolutePath = resolveDocPreviewTargetPath(grant, relativePath) + if (!absolutePath) { + return notFoundOutcome() + } + const contentType = docPreviewContentType(relativePath) + try { + if (grant.owner.kind === 'ssh') { + const provider = requireSshFilesystemProvider(grant.owner.connectionId) + // Why: the SSH read RPC enforces no root of its own, so containment has to survive a symlink + // before the read — the lexical check above only proves the requested path looked contained. + const canonicalPath = await resolveCanonicalDocPreviewPath(grant, absolutePath, (path) => + provider.realpath(path) + ) + if (!canonicalPath) { + return notFoundOutcome() + } + // Why: the SSH reader rejects an over-cap file outright, so its result is never partial. + return toOutcome(await provider.readFile(canonicalPath), contentType) + } + const worktreeRelativePath = toRuntimeWorktreeRelativePath( + grant.owner.worktreeRoot, + absolutePath + ) + if (!worktreeRelativePath) { + // Why: files.read is worktree-scoped, so a doc outside the worktree has no client-side channel. + return notFoundOutcome() + } + // Why no realpath pass here: the host resolves this path through resolveAuthorizedPath, which + // canonicalizes and re-checks the worktree root server-side before reading. + return toOutcome( + await readRuntimeDocPreviewFile( + grant.owner.environmentId, + grant.owner.worktreeSelector, + worktreeRelativePath + ), + contentType + ) + } catch (error) { + return isTooLargeReadError(error) + ? { ok: false, status: 413, reason: 'too-large', message: TRUNCATED_PREVIEW_MESSAGE } + : notFoundOutcome(error instanceof Error ? error.message : undefined) + } +} diff --git a/src/main/browser/doc-preview-grant-registry.test.ts b/src/main/browser/doc-preview-grant-registry.test.ts new file mode 100644 index 00000000000..8aadf9ed695 --- /dev/null +++ b/src/main/browser/doc-preview-grant-registry.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + getDocPreviewGrant, + mintDocPreviewGrant, + resolveCanonicalDocPreviewPath, + resolveDocPreviewTargetPath, + revokeAllDocPreviewGrants, + revokeDocPreviewGrant, + toRuntimeWorktreeRelativePath, + type DocPreviewGrant +} from './doc-preview-grant-registry' + +const sshOwner = { kind: 'ssh', connectionId: 'ssh-1' } as const + +function mintPosixGrant(root = '/srv/repo/docs'): DocPreviewGrant { + return mintDocPreviewGrant({ + owner: sshOwner, + root, + entryRelativePath: 'index.html', + browserPageId: 'page-1' + }) +} + +beforeEach(() => { + revokeAllDocPreviewGrants() +}) + +describe('doc preview grants', () => { + it('mints unguessable ids and looks them up', () => { + const first = mintPosixGrant() + const second = mintPosixGrant() + + expect(first.id).toMatch(/^[0-9a-f]{32}$/) + expect(first.id).not.toBe(second.id) + expect(getDocPreviewGrant(first.id)).toBe(first) + }) + + it('returns nothing for an unknown or revoked grant', () => { + const grant = mintPosixGrant() + + expect(getDocPreviewGrant('0'.repeat(32))).toBeNull() + expect(revokeDocPreviewGrant(grant.id)).toBe(true) + expect(getDocPreviewGrant(grant.id)).toBeNull() + expect(revokeDocPreviewGrant(grant.id)).toBe(false) + }) +}) + +describe('resolveDocPreviewTargetPath', () => { + it('resolves paths inside the grant root', () => { + const grant = mintPosixGrant() + + expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html') + expect(resolveDocPreviewTargetPath(grant, 'assets/logo.png')).toBe( + '/srv/repo/docs/assets/logo.png' + ) + }) + + it('refuses parent traversal, absolute escapes and empty paths', () => { + const grant = mintPosixGrant() + + expect(resolveDocPreviewTargetPath(grant, '../secret.env')).toBeNull() + expect(resolveDocPreviewTargetPath(grant, 'assets/../../secret.env')).toBeNull() + expect(resolveDocPreviewTargetPath(grant, '..')).toBeNull() + expect(resolveDocPreviewTargetPath(grant, '')).toBeNull() + expect(resolveDocPreviewTargetPath(grant, 'a//b')).toBeNull() + }) + + it('refuses backslash and NUL segments that could re-split on the owning host', () => { + const grant = mintPosixGrant() + + expect(resolveDocPreviewTargetPath(grant, '..\\secret.env')).toBeNull() + expect(resolveDocPreviewTargetPath(grant, 'index.html\0.png')).toBeNull() + }) + + // Why this is a traversal test and not a containment test: every request path that names a + // sibling directory has to climb out of the root first, so the `..` segment guard answers it + // before the prefix check runs. Sibling containment is exercised where it is reachable — + // against a canonicalized path, below. + it('refuses a sibling directory by refusing the traversal that reaches it', () => { + const grant = mintPosixGrant() + + expect(resolveDocPreviewTargetPath(grant, '../docs-private/secret.html')).toBeNull() + }) + + it('keeps a Windows drive root addressable instead of turning it drive-relative', () => { + const grant = mintDocPreviewGrant({ + owner: sshOwner, + root: 'C:\\', + entryRelativePath: 'index.html', + browserPageId: 'page-1' + }) + + expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('C:\\index.html') + }) + + it('follows the owning host path flavor rather than this process platform', () => { + const windowsGrant = mintDocPreviewGrant({ + owner: sshOwner, + root: 'C:\\srv\\repo\\docs', + entryRelativePath: 'index.html', + browserPageId: 'page-1' + }) + + expect(resolveDocPreviewTargetPath(windowsGrant, 'assets/logo.png')).toBe( + 'C:\\srv\\repo\\docs\\assets\\logo.png' + ) + expect(resolveDocPreviewTargetPath(windowsGrant, '../secret.env')).toBeNull() + }) + + it('normalizes a trailing separator on the root', () => { + const grant = mintDocPreviewGrant({ + owner: sshOwner, + root: '/srv/repo/docs/', + entryRelativePath: 'index.html', + browserPageId: 'page-1' + }) + + expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html') + expect(resolveDocPreviewTargetPath(grant, '../secret.env')).toBeNull() + }) +}) + +describe('resolveCanonicalDocPreviewPath', () => { + // Why here and not above: a canonical path is the one input that can name a sibling directory + // without traversing — the host resolved a symlink to it — so this is where the prefix check + // is the only thing standing between the grant and `/srv/repo/docs-private`. + it('refuses a canonical path in a sibling directory that shares the root prefix', async () => { + const grant = mintPosixGrant() + + await expect( + resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/report.html', async (path) => + path === grant.root ? path : '/srv/repo/docs-private/secret.html' + ) + ).resolves.toBeNull() + }) + + it('answers the canonical path when it stays inside the canonical root', async () => { + const grant = mintPosixGrant() + + await expect( + resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/report.html', async (path) => path) + ).resolves.toBe('/srv/repo/docs/report.html') + }) +}) + +describe('toRuntimeWorktreeRelativePath', () => { + it('produces a worktree-relative path for files inside the worktree', () => { + expect(toRuntimeWorktreeRelativePath('/srv/repo', '/srv/repo/docs/index.html')).toBe( + 'docs/index.html' + ) + }) + + it('rejects paths outside the worktree, which files.read cannot address', () => { + expect(toRuntimeWorktreeRelativePath('/srv/repo', '/tmp/agent/report.html')).toBeNull() + expect(toRuntimeWorktreeRelativePath('/srv/repo', '/srv/repo')).toBeNull() + }) + + it('uses Windows semantics for a Windows worktree root', () => { + expect(toRuntimeWorktreeRelativePath('C:\\srv\\repo', 'C:\\srv\\repo\\docs\\index.html')).toBe( + 'docs/index.html' + ) + expect(toRuntimeWorktreeRelativePath('C:\\srv\\repo', 'D:\\other\\index.html')).toBeNull() + }) +}) diff --git a/src/main/browser/doc-preview-grant-registry.ts b/src/main/browser/doc-preview-grant-registry.ts new file mode 100644 index 00000000000..51237a685f1 --- /dev/null +++ b/src/main/browser/doc-preview-grant-registry.ts @@ -0,0 +1,208 @@ +import { randomBytes } from 'node:crypto' +import { posix, win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' + +/** + * A preview grant is the only authority that turns an `orca-preview://` request + * into bytes: it names the host that owns the file and the single directory + * subtree requests may resolve inside. No grant, no bytes. + */ +export type DocPreviewOwner = + | { kind: 'ssh'; connectionId: string } + | { + kind: 'runtime' + environmentId: string + /** Selector the runtime resolves `files.read` against. */ + worktreeSelector: string + /** Worktree root on the runtime host; `files.read` only accepts paths inside it. */ + worktreeRoot: string + } + +export type DocPreviewGrant = { + id: string + owner: DocPreviewOwner + /** Containing directory of the opened document, on the owning host. */ + root: string + /** Path of the opened document relative to `root`. */ + entryRelativePath: string + /** + * Browser page the reader opened this document in. Main registers the guest under it once the + * guest commits to the grant, so the surface a tool names is the page the reader is looking at + * and not the grant, which a re-mint replaces underneath the same page. + */ + browserPageId: string +} + +const grantsById = new Map() + +function pathFlavorFor(root: string): typeof posix | typeof win32 { + return isWindowsAbsolutePathLike(root) ? win32 : posix +} + +function normalizeRootPath(root: string): string { + const flavor = pathFlavorFor(root) + const normalized = + flavor === win32 ? flavor.normalize(root.replace(/\//g, '\\')) : flavor.normalize(root) + // Why: `C:\` is the whole root, and trimming its separator would make win32.join answer the + // drive-relative `C:x`, which resolves against the host's cwd instead of inside the grant. + if (flavor === win32 && /^[a-zA-Z]:\\$/.test(normalized)) { + return normalized + } + // Why: a trailing separator would make the containment prefix check accept a sibling directory. + return normalized.length > 1 && normalized.endsWith(flavor.sep) + ? normalized.slice(0, -1) + : normalized +} + +export function mintDocPreviewGrant(params: { + owner: DocPreviewOwner + root: string + entryRelativePath: string + browserPageId: string +}): DocPreviewGrant { + const grant: DocPreviewGrant = { + id: randomBytes(16).toString('hex'), + owner: params.owner, + root: normalizeRootPath(params.root), + entryRelativePath: params.entryRelativePath.replace(/\\/g, '/'), + browserPageId: params.browserPageId + } + grantsById.set(grant.id, grant) + return grant +} + +export function getDocPreviewGrant(grantId: string): DocPreviewGrant | null { + return grantsById.get(grantId) ?? null +} + +/** + * Why anything listens at all: a grant is the only thing that names a preview's lifetime. State + * elsewhere in main keyed by a preview's tool target — grab intent, a queued grab chain — has no + * other signal telling it the surface is gone, and would otherwise accrete one entry per grant + * for the life of the process. + */ +const revocationListeners = new Set<(grant: DocPreviewGrant) => void>() + +/** Why the whole grant and not its id: it is already gone from the registry when listeners run. */ +export function onDocPreviewGrantRevoked(listener: (grant: DocPreviewGrant) => void): () => void { + revocationListeners.add(listener) + return () => revocationListeners.delete(listener) +} + +function notifyRevoked(grant: DocPreviewGrant): void { + for (const listener of revocationListeners) { + listener(grant) + } +} + +export function revokeDocPreviewGrant(grantId: string): boolean { + canonicalRootByGrantId.delete(grantId) + const grant = grantsById.get(grantId) + if (!grant) { + return false + } + grantsById.delete(grantId) + notifyRevoked(grant) + return true +} + +export function revokeAllDocPreviewGrants(): void { + canonicalRootByGrantId.clear() + const revoked = [...grantsById.values()] + grantsById.clear() + for (const grant of revoked) { + notifyRevoked(grant) + } +} + +function hasUnsafeSegment(segments: string[]): boolean { + return segments.some( + (segment) => + segment.length === 0 || + segment === '.' || + segment === '..' || + segment.includes('\0') || + segment.includes('\\') + ) +} + +/** + * Resolves a request path to an absolute path on the owning host, or null when + * it would escape the grant's root. Path flavor follows the root (the owning + * host may be Windows while this client is not), never `process.platform`. + */ +export function resolveDocPreviewTargetPath( + grant: DocPreviewGrant, + relativePath: string +): string | null { + const segments = relativePath.split('/').filter((segment, index, all) => { + // Why: keep empty segments visible to the safety check except a single trailing one from `dir/`. + return !(segment === '' && index === all.length - 1) + }) + if (segments.length === 0 || hasUnsafeSegment(segments)) { + return null + } + const flavor = pathFlavorFor(grant.root) + const resolved = flavor.normalize(flavor.join(grant.root, ...segments)) + return isInsideRoot(grant.root, resolved, flavor) ? resolved : null +} + +function isInsideRoot( + root: string, + candidate: string, + flavor: typeof posix | typeof win32 +): boolean { + const rootPrefix = root.endsWith(flavor.sep) ? root : `${root}${flavor.sep}` + return candidate.startsWith(rootPrefix) +} + +/** Why: realpath is a host round-trip, and a grant's root is fixed for its lifetime. */ +const canonicalRootByGrantId = new Map>() + +/** + * Second containment pass for hosts where the lexical one is not enough: a symlink + * inside the root can point anywhere, and the SSH read RPC applies no root of its + * own. Both sides are canonicalized on the owning host before the prefix re-check; + * a host that cannot canonicalize a path answers nothing. + */ +export async function resolveCanonicalDocPreviewPath( + grant: DocPreviewGrant, + absolutePath: string, + realpath: (path: string) => Promise +): Promise { + try { + let canonicalRoot = canonicalRootByGrantId.get(grant.id) + if (!canonicalRoot) { + canonicalRoot = realpath(grant.root).then(normalizeRootPath) + canonicalRootByGrantId.set(grant.id, canonicalRoot) + } + const [root, canonicalPath] = await Promise.all([canonicalRoot, realpath(absolutePath)]) + const flavor = pathFlavorFor(root) + return isInsideRoot(root, canonicalPath, flavor) ? canonicalPath : null + } catch { + // Why: a root that no longer canonicalizes must not fall back to the lexical answer. + canonicalRootByGrantId.delete(grant.id) + return null + } +} + +/** + * Path a runtime `files.read` can address, i.e. relative to the worktree root. + * Returns null when the grant root sits outside the worktree — the runtime file + * RPCs are worktree-scoped, so those documents are unreadable client-side. + */ +export function toRuntimeWorktreeRelativePath( + worktreeRoot: string, + absolutePath: string +): string | null { + const flavor = pathFlavorFor(worktreeRoot) + const normalizedRoot = normalizeRootPath(worktreeRoot) + const relative = flavor.relative(normalizedRoot, absolutePath) + if (!relative || relative === '..' || relative.startsWith(`..${flavor.sep}`)) { + return null + } + if (flavor === win32 && /^[a-zA-Z]:/.test(relative)) { + return null + } + return relative.replace(/\\/g, '/') +} diff --git a/src/main/browser/doc-preview-guest-policy.test.ts b/src/main/browser/doc-preview-guest-policy.test.ts new file mode 100644 index 00000000000..26b117c0fc0 --- /dev/null +++ b/src/main/browser/doc-preview-guest-policy.test.ts @@ -0,0 +1,550 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + getWorkspaceDocPageGuest, + installDocPreviewGuestPolicy, + readDocPreviewGuestBoundGrantId, + reportDocPreviewLinkClick +} from './doc-preview-guest-policy' +import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme' +import { + mintDocPreviewGrant, + revokeAllDocPreviewGrants, + revokeDocPreviewGrant +} from './doc-preview-grant-registry' + +type GuestHandlers = Record void> + +const HOST_RENDERER_ID = 42 + +function installOnFakeGuest( + hostId: number = HOST_RENDERER_ID, + /** What the guest is already showing when the embedder hands it over, as a real one usually is. */ + initialUrl = '' +): { + contents: object + handlers: GuestHandlers + hostId: number + isFocused: ReturnType + send: ReturnType + setWebRTCIPHandlingPolicy: ReturnType + windowOpenHandler: (details: { url: string }) => { action: string } + /** Why without the `destroyed` event: Chromium tears the contents down before main runs it. */ + markContentsDestroyed: () => void +} { + const handlers: GuestHandlers = {} + let contentsDestroyed = false + const send = vi.fn() + const isFocused = vi.fn(() => true) + const setWebRTCIPHandlingPolicy = vi.fn() + let windowOpenHandler: (details: { url: string }) => { action: string } = () => ({ + action: 'deny' + }) + const register = (event: string, handler: (...args: never[]) => void): void => { + handlers[event] = handler + } + const guest = { + isFocused, + isDestroyed: () => contentsDestroyed, + getURL: () => initialUrl, + on: vi.fn(register), + once: vi.fn(register), + setWindowOpenHandler: vi.fn((handler: (details: { url: string }) => { action: string }) => { + windowOpenHandler = handler + }), + setWebRTCIPHandlingPolicy + } + installDocPreviewGuestPolicy(guest as never, { id: hostId, send }) + return { + contents: guest, + handlers, + hostId, + isFocused, + send, + setWebRTCIPHandlingPolicy, + windowOpenHandler: (details) => windowOpenHandler(details), + markContentsDestroyed: () => { + contentsDestroyed = true + } + } +} + +type FakeGuest = ReturnType + +function startMainFrameNavigation(guest: FakeGuest, url: string): void { + guest.handlers['did-start-navigation']?.({ url, isMainFrame: true } as never) +} + +/** A guest already showing a document, which is the only state a link can be pressed in. */ +function boundGuest(): { grant: ReturnType; guest: FakeGuest } { + const grant = mintGrant() + const guest = installOnFakeGuest() + startMainFrameNavigation(guest, buildDocPreviewUrl(grant.id, 'index.html')) + return { grant, guest } +} + +function reportClick(guest: FakeGuest, url: string): void { + reportDocPreviewLinkClick(guest.contents as never, url) +} + +// Why a fresh page per grant: the registry is keyed by the page now, so two grants sharing one +// would have the second silently replace the first rather than stand beside it. +let nextDocPageOrdinal = 0 + +function mintGrant(): ReturnType { + nextDocPageOrdinal += 1 + return mintDocPreviewGrant({ + owner: { kind: 'ssh', connectionId: 'ssh-1' }, + root: '/home/alice/docs', + entryRelativePath: 'index.html', + browserPageId: `doc-page-${nextDocPageOrdinal}` + }) +} + +beforeEach(() => { + vi.clearAllMocks() + revokeAllDocPreviewGrants() +}) + +describe('doc preview guest policy', () => { + it('allows relative navigation within the bound grant', () => { + const { grant, guest } = boundGuest() + const preventDefault = vi.fn() + + guest.handlers['will-navigate']?.( + { preventDefault } as never, + buildDocPreviewUrl(grant.id, 'guide.html') as never + ) + + expect(preventDefault).not.toHaveBeenCalled() + expect(guest.send).not.toHaveBeenCalled() + }) + + // Why an unlatched guest is refused rather than trusted: the renderer-set src is + // browser-initiated and never reaches will-navigate, so a navigation arriving before the latch is + // one the guest started for itself. + it('blocks a navigation the guest starts before a document has bound it', () => { + const grant = mintGrant() + const guest = installOnFakeGuest() + const preventDefault = vi.fn() + + guest.handlers['will-navigate']?.( + { preventDefault } as never, + buildDocPreviewUrl(grant.id, 'index.html') as never + ) + + expect(preventDefault).toHaveBeenCalledOnce() + expect(guest.send).not.toHaveBeenCalled() + }) + + it('blocks navigation into a different live grant once bound', () => { + const { guest } = boundGuest() + const otherGrant = mintGrant() + const preventDefault = vi.fn() + + guest.handlers['will-navigate']?.( + { preventDefault } as never, + buildDocPreviewUrl(otherGrant.id, 'index.html') as never + ) + + expect(preventDefault).toHaveBeenCalledOnce() + }) + + it('blocks a revoked grant even when it matches the bound id', () => { + const { grant, guest } = boundGuest() + revokeAllDocPreviewGrants() + const preventDefault = vi.fn() + + guest.handlers['will-navigate']?.( + { preventDefault } as never, + buildDocPreviewUrl(grant.id, 'guide.html') as never + ) + + expect(preventDefault).toHaveBeenCalledOnce() + }) + + it('blocks an external navigation without offering it to the renderer', () => { + const { guest } = boundGuest() + const preventDefault = vi.fn() + + guest.handlers['will-navigate']?.({ preventDefault } as never, 'https://example.com/' as never) + + expect(preventDefault).toHaveBeenCalledOnce() + expect(guest.send).not.toHaveBeenCalled() + }) + + it('blocks a file: navigation', () => { + const { guest } = boundGuest() + const preventDefault = vi.fn() + + guest.handlers['will-navigate']?.({ preventDefault } as never, 'file:///etc/passwd' as never) + + expect(preventDefault).toHaveBeenCalledOnce() + expect(guest.send).not.toHaveBeenCalled() + }) + + it('applies the same rule to redirects', () => { + const { guest } = boundGuest() + const preventDefault = vi.fn() + + guest.handlers['will-redirect']?.({ preventDefault } as never, 'https://evil.test/' as never) + + expect(preventDefault).toHaveBeenCalledOnce() + expect(guest.send).not.toHaveBeenCalled() + }) + + it('denies every popup and routes none of them', () => { + const { guest } = boundGuest() + + expect(guest.windowOpenHandler({ url: 'https://example.com/docs' })).toEqual({ + action: 'deny' + }) + expect(guest.windowOpenHandler({ url: 'file:///etc/passwd' })).toEqual({ action: 'deny' }) + expect(guest.send).not.toHaveBeenCalled() + }) + + // Why: will-navigate never fires for a subframe, so an