diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index 3df15650809..712a0324ec0 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -17,7 +17,6 @@ 1 src/renderer/src/components/activity/use-agent-pane-threads.ts 4 src/renderer/src/components/agent/AgentSettingsDialog.tsx 1 src/renderer/src/components/automations/automation-host-client.ts -1 src/renderer/src/components/automations/automation-orca-save.ts 1 src/renderer/src/components/automations/automations-page-fixtures.ts 1 src/renderer/src/components/automations/use-automation-source-availability.ts 1 src/renderer/src/components/automations/use-automation-source-host-availability.ts @@ -95,11 +94,10 @@ 1 src/renderer/src/components/new-workspace/use-jira-source-connection.ts 4 src/renderer/src/components/new-workspace/use-linear-source-connection.ts 1 src/renderer/src/components/new-workspace/use-smart-workspace-name-field-controller.ts -2 src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts +1 src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts 2 src/renderer/src/components/new-workspace/use-work-item-source-foundation.ts 1 src/renderer/src/components/new-workspace/use-work-item-source-search.ts 1 src/renderer/src/components/quick-open-file-list-test-harness.ts -3 src/renderer/src/components/repo/CreateFromPicker.tsx 1 src/renderer/src/components/right-sidebar/ChecksPanel.tsx 2 src/renderer/src/components/right-sidebar/FileExplorer.tsx 1 src/renderer/src/components/right-sidebar/FileExplorerFilesTreePane.tsx @@ -114,23 +112,19 @@ 1 src/renderer/src/components/right-sidebar/git-status-file-watch-refresh.ts 1 src/renderer/src/components/right-sidebar/source-control/listing/entry-context-menu.tsx 1 src/renderer/src/components/right-sidebar/source-control/sync/git-history-commit-context-menu.tsx -1 src/renderer/src/components/right-sidebar/source-control/sync/use-base-ref-default.ts -2 src/renderer/src/components/right-sidebar/use-create-pull-request-base-ref-discovery.ts +1 src/renderer/src/components/right-sidebar/use-create-pull-request-base-ref-discovery.ts 2 src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts 1 src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts 4 src/renderer/src/components/right-sidebar/useFileSearchPanel.ts 2 src/renderer/src/components/right-sidebar/useFileSearchRunner.ts 2 src/renderer/src/components/right-sidebar/useFileSearchScope.ts 2 src/renderer/src/components/right-sidebar/useGitStatusPolling.ts -3 src/renderer/src/components/settings/BaseRefPicker.tsx +1 src/renderer/src/components/settings/BaseRefPicker.tsx 2 src/renderer/src/components/settings/BrowserPane.tsx 1 src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx 1 src/renderer/src/components/settings/GeneralPane.tsx 1 src/renderer/src/components/settings/IntegrationsPane.tsx 3 src/renderer/src/components/settings/LinearAgentSkillPane.tsx -2 src/renderer/src/components/settings/RepositoryGitHubAccountSection.tsx -1 src/renderer/src/components/settings/RepositoryHooksSection.tsx -1 src/renderer/src/components/settings/RepositoryIconPicker.tsx 4 src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx 2 src/renderer/src/components/settings/Settings.tsx 1 src/renderer/src/components/settings/SettingsSetupGuidePane.tsx @@ -150,7 +144,6 @@ 2 src/renderer/src/components/settings/task-tracker-integration-cards.tsx 2 src/renderer/src/components/settings/token-source-control-integration-cards.tsx 2 src/renderer/src/components/settings/use-integration-provider-status-refresh.ts -3 src/renderer/src/components/settings/use-repository-issue-command.ts 1 src/renderer/src/components/settings/use-runtime-environment-mutation-actions.ts 2 src/renderer/src/components/settings/use-settings-navigation-model.ts 3 src/renderer/src/components/settings/use-settings-store-model.ts @@ -158,8 +151,7 @@ 4 src/renderer/src/components/settings/use-task-source-provider-readiness.ts 1 src/renderer/src/components/setup-guide/SetupGuideModal.tsx 1 src/renderer/src/components/setup-guide/SetupGuideTelemetryObserver.tsx -1 src/renderer/src/components/setup-guide/setup-guide-progress-readiness.ts -3 src/renderer/src/components/setup-guide/use-setup-guide-progress.ts +2 src/renderer/src/components/setup-guide/use-setup-guide-progress.ts 1 src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx 3 src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx 1 src/renderer/src/components/sidebar/SavedHostScopeWidenGate.tsx @@ -290,10 +282,7 @@ 1 src/renderer/src/hooks/composer-state/composer-attachment-drop-state.ts 1 src/renderer/src/hooks/composer-state/composer-navigation-actions.ts 2 src/renderer/src/hooks/composer-state/composer-source-state.ts -1 src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts -2 src/renderer/src/hooks/composer-state/composer-target-state.ts -1 src/renderer/src/hooks/composer-state/provider-runtime-sync.ts -1 src/renderer/src/hooks/composer-state/quick-creation-execution.ts +1 src/renderer/src/hooks/composer-state/composer-target-state.ts 1 src/renderer/src/hooks/composer-state/workspace-identity-state.ts 1 src/renderer/src/hooks/editor-external-watch-target-rows.ts 3 src/renderer/src/hooks/editor-external-watch-targets.ts @@ -336,7 +325,6 @@ 1 src/renderer/src/lib/default-creation-host.ts # the sanctioned reader of the default host 1 src/renderer/src/lib/doc-preview-grants.ts 1 src/renderer/src/lib/empty-workspace-default-agent-chat.ts -3 src/renderer/src/lib/ensure-hooks-confirmed.ts 3 src/renderer/src/lib/execute-open-editor-path-move.ts 1 src/renderer/src/lib/execution-host-display-label.ts 2 src/renderer/src/lib/execution-host-facts.ts @@ -351,7 +339,6 @@ 2 src/renderer/src/lib/launch-agent-web-host-tab.ts 2 src/renderer/src/lib/launch-ai-vault-session.ts 1 src/renderer/src/lib/launch-structured-agent-session.ts -1 src/renderer/src/lib/launch-work-item-direct.ts 1 src/renderer/src/lib/launch-worktree-background-terminals.ts 1 src/renderer/src/lib/linear-issue-url-lookup.ts 2 src/renderer/src/lib/local-preflight-context.ts @@ -386,10 +373,6 @@ 1 src/renderer/src/lib/worktree-activation-pty-inventory.ts 3 src/renderer/src/lib/worktree-activation.ts 1 src/renderer/src/lib/worktree-creation-agent-seeds.ts -1 src/renderer/src/lib/worktree-creation-flow-execute.ts -1 src/renderer/src/lib/worktree-creation-flow-startup.ts -1 src/renderer/src/lib/worktree-creation-flow.ts -1 src/renderer/src/lib/worktree-creation-hook-preparation.ts 1 src/renderer/src/lib/worktree-initial-terminal-seeding.ts 1 src/renderer/src/lib/worktree-jump-navigation.ts 2 src/renderer/src/lib/worktree-operation-route.ts @@ -399,7 +382,6 @@ 1 src/renderer/src/runtime/close-mirrored-editor-tab.ts 1 src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts 1 src/renderer/src/runtime/runtime-client-target.ts -3 src/renderer/src/runtime/runtime-hooks-client.ts 17 src/renderer/src/runtime/runtime-jira-client.ts 2 src/renderer/src/runtime/runtime-jira-summary-client.ts 1 src/renderer/src/runtime/runtime-jira-target.ts @@ -407,7 +389,6 @@ 8 src/renderer/src/runtime/runtime-linear-client.ts 6 src/renderer/src/runtime/runtime-linear-issue-mutations.ts 12 src/renderer/src/runtime/runtime-linear-project-client.ts -3 src/renderer/src/runtime/runtime-repo-client.ts 3 src/renderer/src/runtime/structured-agent-session-owner.ts 1 src/renderer/src/runtime/structured-conversation-name.ts 5 src/renderer/src/runtime/use-runtime-session-mirror-environment-key.ts @@ -496,17 +477,12 @@ 1 src/renderer/src/store/slices/worktree-visibility-owner-settings.ts 3 src/renderer/src/store/slices/worktree-visibility-settings-write.ts 3 src/renderer/src/store/slices/worktrees-slice-test-harness.ts -12 src/renderer/src/store/slices/worktrees.ts -3 src/renderer/src/store/slices/worktrees/create/create-worktree.ts +7 src/renderer/src/store/slices/worktrees.ts +1 src/renderer/src/store/slices/worktrees/create/create-worktree.ts 1 src/renderer/src/store/slices/worktrees/create/created-worktree-state-merge.ts -2 src/renderer/src/store/slices/worktrees/create/prefetch-worktree-create-base.ts -2 src/renderer/src/store/slices/worktrees/listing/detected-worktree-provider-request.ts -7 src/renderer/src/store/slices/worktrees/listing/detected-worktree-refresh.ts -4 src/renderer/src/store/slices/worktrees/listing/fetch-all-worktrees.ts -4 src/renderer/src/store/slices/worktrees/listing/fetch-detected-worktrees.ts -6 src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts +2 src/renderer/src/store/slices/worktrees/listing/fetch-detected-worktrees.ts +2 src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts 1 src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts -2 src/renderer/src/store/slices/worktrees/listing/known-ssh-worktree-fetch.ts 2 src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts 3 src/renderer/src/store/slices/worktrees/listing/worktree-owner-settings.ts 2 src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts @@ -515,10 +491,7 @@ 1 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-refresh.ts 1 src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts 3 src/renderer/src/store/slices/worktrees/session/worktree-terminal-reference.ts -1 src/renderer/src/store/slices/worktrees/teardown/force-delete-preserved-branch.ts -1 src/renderer/src/store/slices/worktrees/teardown/missing-worktree-terminal-teardown.ts 1 src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts -2 src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts 1 src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts 2 src/renderer/src/store/terminals/terminal-active-workspace-creation.ts 1 src/renderer/src/store/terminals/terminal-pane-hibernation.ts diff --git a/config/owner-routing-baseline.txt b/config/owner-routing-baseline.txt index a054597471b..528a9498e9d 100644 --- a/config/owner-routing-baseline.txt +++ b/config/owner-routing-baseline.txt @@ -4,21 +4,6 @@ # Prune after removing sites: pnpm check:owner-routing-ratchet --prune 1 src/renderer/src/components/linear-api-key-dialog.tsx 1 src/renderer/src/components/settings/GeneralPane.tsx -1 src/renderer/src/components/settings/RepositoryGitHubAccountSection.tsx -1 src/renderer/src/components/settings/RepositoryIconPicker.tsx -1 src/renderer/src/components/setup-guide/setup-guide-progress-readiness.ts -1 src/renderer/src/hooks/composer-state/provider-runtime-sync.ts -1 src/renderer/src/hooks/composer-state/quick-creation-execution.ts -1 src/renderer/src/lib/worktree-creation-flow-startup.ts -3 src/renderer/src/runtime/runtime-hooks-client.ts -3 src/renderer/src/runtime/runtime-repo-client.ts 1 src/renderer/src/store/slices/preflight.ts 1 src/renderer/src/store/slices/worktree-visibility-owner-settings.ts 3 src/renderer/src/store/slices/worktree-visibility-settings-write.ts -1 src/renderer/src/store/slices/worktrees/create/create-worktree.ts -1 src/renderer/src/store/slices/worktrees/create/prefetch-worktree-create-base.ts -2 src/renderer/src/store/slices/worktrees/listing/detected-worktree-provider-request.ts -1 src/renderer/src/store/slices/worktrees/listing/detected-worktree-refresh.ts -1 src/renderer/src/store/slices/worktrees/teardown/force-delete-preserved-branch.ts -1 src/renderer/src/store/slices/worktrees/teardown/missing-worktree-terminal-teardown.ts -1 src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts diff --git a/src/renderer/src/components/automations/use-automations-page-setup-state.ts b/src/renderer/src/components/automations/use-automations-page-setup-state.ts index 88e5d54b1b1..ae0939085fe 100644 --- a/src/renderer/src/components/automations/use-automations-page-setup-state.ts +++ b/src/renderer/src/components/automations/use-automations-page-setup-state.ts @@ -5,7 +5,11 @@ import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' import type { AutomationDraft } from './AutomationEditorDialog' import { getAutomationHostTargetFromKey, type AutomationHostTarget } from './automation-host-client' import { getAutomationCreateRepos } from './automation-create-projects' -import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { + getRepoExecutionHostId, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' import { getVisibleAutomationSetupDecision } from './automation-setup-decision' import { capturedAutomationOwner, capturedAutomationOwnerKey } from './automation-captured-owner' import type { AutomationsPageListState } from './use-automations-page-list-state' @@ -116,8 +120,15 @@ export function useAutomationsPageSetupState({ if (existingPromise) { return (await existingPromise).hooks } - const settingsForRepo = getSettingsForRepoRuntimeOwner({ repos, settings }, repoId) - const promise = checkRuntimeHooks(settingsForRepo, repoId, hostId) + // Why: a repo id on two hosts has no single owner to inspect; never let focus pick one. + const ownerRows = hostId ? [] : repos.filter((repo) => repo.id === repoId) + const ownerHostId = + hostId ?? (ownerRows.length === 1 ? getRepoExecutionHostId(ownerRows[0]) : null) + const promise = ( + ownerHostId + ? checkRuntimeHooks(ownerHostId, repoId) + : Promise.reject(new Error('The project host is unknown.')) + ) .then((result) => ({ hooks: result.status === 'error' ? null : ((result.hooks as OrcaHooks | null) ?? null), ok: result.status !== 'error' diff --git a/src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts b/src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts index 7c18681bb1d..1d9a48af476 100644 --- a/src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts +++ b/src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts @@ -1,3 +1,4 @@ +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { useEffect, useMemo, useRef } from 'react' import { searchRuntimeRepoBaseRefDetails } from '@/runtime/runtime-repo-client' import { lookupLinearIssueUrl } from '@/lib/linear-issue-url-lookup' @@ -37,7 +38,6 @@ export function useSmartWorkspaceSecondarySearches({ mode, selectedRepo, debouncedQuery, - selectedRepoOwnerSettings, setBranches, setBranchResultsSource, setBranchesLoading, @@ -98,8 +98,10 @@ export function useSmartWorkspaceSecondarySearches({ ] ) + // Why: branches are searched on the selected repo's own host. + const selectedRepoHostId = selectedRepo ? getRepoExecutionHostId(selectedRepo) : null useEffect(() => { - if (!branchSearchRequest) { + if (!branchSearchRequest || !selectedRepoHostId) { setBranches([]) setBranchResultsSource(null) setBranchesLoading(false) @@ -109,7 +111,7 @@ export function useSmartWorkspaceSecondarySearches({ // Why: visibility retains prior rows while typing ahead of the debounced query. setBranchesLoading(true) void searchRuntimeRepoBaseRefDetails( - selectedRepoOwnerSettings, + selectedRepoHostId, branchSearchRequest.repoId, branchSearchRequest.query, branchSearchRequest.limit @@ -139,7 +141,7 @@ export function useSmartWorkspaceSecondarySearches({ } }, [ branchSearchRequest, - selectedRepoOwnerSettings, + selectedRepoHostId, setBranches, setBranchResultsSource, setBranchesLoading diff --git a/src/renderer/src/components/repo/CreateFromPicker.test.tsx b/src/renderer/src/components/repo/CreateFromPicker.test.tsx index 9216645fbc6..c54a1e65779 100644 --- a/src/renderer/src/components/repo/CreateFromPicker.test.tsx +++ b/src/renderer/src/components/repo/CreateFromPicker.test.tsx @@ -110,11 +110,7 @@ describe('CreateFromPicker host routing', () => { await renderPicker(repo) - expect(getRuntimeRepoBaseRefDefault).toHaveBeenCalledWith( - { activeRuntimeEnvironmentId: 'owner-runtime' }, - repo.id, - 'runtime:owner-runtime' - ) + expect(getRuntimeRepoBaseRefDefault).toHaveBeenCalledWith('runtime:owner-runtime', repo.id) }) // Why: the composer's Branch tab already lists on an empty query; a minimum-length gate left @@ -131,13 +127,7 @@ describe('CreateFromPicker host routing', () => { await new Promise((resolve) => setTimeout(resolve, 250)) }) - expect(searchRuntimeRepoBaseRefs).toHaveBeenCalledWith( - { activeRuntimeEnvironmentId: null }, - repo.id, - '', - 30, - 'local' - ) + expect(searchRuntimeRepoBaseRefs).toHaveBeenCalledWith('local', repo.id, '', 30) }) it('keeps an explicit local repo on the local client even when a runtime is focused', async () => { @@ -146,11 +136,7 @@ describe('CreateFromPicker host routing', () => { await renderPicker(repo) - expect(getRuntimeRepoBaseRefDefault).toHaveBeenCalledWith( - { activeRuntimeEnvironmentId: null }, - repo.id, - 'local' - ) + expect(getRuntimeRepoBaseRefDefault).toHaveBeenCalledWith('local', repo.id) expect(searchRuntimeRepoBaseRefs).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/repo/CreateFromPicker.tsx b/src/renderer/src/components/repo/CreateFromPicker.tsx index 80d63909a74..5b58fc6a7e7 100644 --- a/src/renderer/src/components/repo/CreateFromPicker.tsx +++ b/src/renderer/src/components/repo/CreateFromPicker.tsx @@ -19,8 +19,6 @@ import { cn } from '@/lib/utils' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import { getRepoExecutionHostId } from '../../../../shared/execution-host' -import { useAppStore } from '@/store' -import { getRuntimeEnvironmentIdForRepo } from '@/lib/repo-runtime-owner' import { getRuntimeRepoBaseRefDefault, searchRuntimeRepoBaseRefs @@ -56,11 +54,6 @@ export function CreateFromPicker({ onValueChange: (baseBranch: string) => void onSetDefault?: (baseBranch: string) => void | Promise }): React.JSX.Element { - // 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) const repoHostId = repo ? getRepoExecutionHostId(repo) : undefined const [open, setOpen] = React.useState(false) @@ -161,16 +154,12 @@ export function CreateFromPicker({ ) React.useEffect(() => { - if (!repoId) { + if (!repoId || !repoHostId) { return } let stale = false setDefaultBaseRef(null) - void getRuntimeRepoBaseRefDefault( - { activeRuntimeEnvironmentId: repoRuntimeEnvironmentId }, - repoId, - repoHostId - ) + void getRuntimeRepoBaseRefDefault(repoHostId, repoId) .then((result) => { if (!stale) { setDefaultBaseRef(result.defaultBaseRef) @@ -184,7 +173,7 @@ export function CreateFromPicker({ return () => { stale = true } - }, [repoHostId, repoRuntimeEnvironmentId, repoId]) + }, [repoHostId, repoId]) React.useEffect(() => { if (!isRuntimeRepoRefSearchQueryWithinLimit(query)) { @@ -195,7 +184,7 @@ export function CreateFromPicker({ const trimmedQuery = query.trim() // Why: an empty query lists the repo's branches, as the composer's Branch tab already does; // a minimum length left this picker showing only the default and worktree branches. - if (!open || !repoId) { + if (!open || !repoId || !repoHostId) { setSearchResults([]) setIsSearching(false) return @@ -204,13 +193,7 @@ export function CreateFromPicker({ let stale = false setIsSearching(true) const timer = window.setTimeout(() => { - void searchRuntimeRepoBaseRefs( - { activeRuntimeEnvironmentId: repoRuntimeEnvironmentId }, - repoId, - trimmedQuery, - 30, - repoHostId - ) + void searchRuntimeRepoBaseRefs(repoHostId, repoId, trimmedQuery, 30) .then((results) => { if (!stale) { setSearchResults(results) @@ -232,7 +215,7 @@ export function CreateFromPicker({ stale = true window.clearTimeout(timer) } - }, [repoHostId, repoRuntimeEnvironmentId, open, query, repoId]) + }, [repoHostId, open, query, repoId]) const compactTriggerContent = ( <> diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-review-context.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-review-context.ts index 581045013f4..61857afa30f 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-review-context.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-review-context.ts @@ -20,7 +20,6 @@ export function useSourceControlReviewContext(panelState: SourceControlPanelStat activeRepoConnectionId, activeRepoExecutionHostId, activeRepoId, - activeRepoRuntimeEnvironmentId, activeRepoTarget, activeWorktree, activeWorktreeId, @@ -58,7 +57,6 @@ export function useSourceControlReviewContext(panelState: SourceControlPanelStat activeRepoConnectionId, activeRepoExecutionHostId, activeRepoId, - activeRepoRuntimeEnvironmentId, activeRepoWorktreeBaseRef: activeRepo?.worktreeBaseRef, activeWorktreeBaseRef: activeWorktree?.baseRef, hostedReview, diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/use-base-ref-default.ts b/src/renderer/src/components/right-sidebar/source-control/sync/use-base-ref-default.ts index 2da00d5bcc0..c1f48124ff1 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/use-base-ref-default.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/use-base-ref-default.ts @@ -6,32 +6,26 @@ export function useSourceControlBaseRefDefault({ activeRepoConnectionId, activeRepoExecutionHostId, activeRepoId, - activeRepoRuntimeEnvironmentId, isBranchVisible, isFolder }: { activeRepoConnectionId: string | null activeRepoExecutionHostId: ExecutionHostId | null activeRepoId: string | null - activeRepoRuntimeEnvironmentId: string | null | undefined isBranchVisible: boolean isFolder: boolean }): string | null { // Why: start null (not 'origin/main') so branch compare doesn't fire with a fabricated ref before the IPC resolves. const [defaultBaseRef, setDefaultBaseRef] = useState(null) useEffect(() => { - if (!isBranchVisible || !activeRepoId || isFolder) { + if (!isBranchVisible || !activeRepoId || !activeRepoExecutionHostId || isFolder) { return } // Why: reset to null so that effectiveBaseRef becomes falsy until the IPC resolves, so branch compare can't fire with a stale defaultBaseRef from a different repo (transient "invalid-base" on switch). setDefaultBaseRef(null) let stale = false - void getRuntimeRepoBaseRefDefault( - { activeRuntimeEnvironmentId: activeRepoRuntimeEnvironmentId }, - activeRepoId, - // Why: the direct-repo path resolves the record by OWNER host, not the focused runtime. - activeRepoExecutionHostId ?? undefined - ) + // Why: resolved on the repo's OWNER host, not the focused runtime. + void getRuntimeRepoBaseRefDefault(activeRepoExecutionHostId, activeRepoId) .then((result) => { if (!stale) { // IPC returns a { defaultBaseRef, remoteCount } envelope; only defaultBaseRef is needed here (remoteCount powers BaseRefPicker's multi-remote hint). @@ -53,7 +47,6 @@ export function useSourceControlBaseRefDefault({ activeRepoConnectionId, activeRepoExecutionHostId, activeRepoId, - activeRepoRuntimeEnvironmentId, isBranchVisible, isFolder ]) diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/use-base-refs.ts b/src/renderer/src/components/right-sidebar/source-control/sync/use-base-refs.ts index 04614dd5b59..d747aba2708 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/use-base-refs.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/use-base-refs.ts @@ -15,7 +15,6 @@ export function useSourceControlBaseRefs({ activeRepoConnectionId, activeRepoExecutionHostId, activeRepoId, - activeRepoRuntimeEnvironmentId, activeRepoWorktreeBaseRef, activeWorktreeBaseRef, hostedReview, @@ -27,7 +26,6 @@ export function useSourceControlBaseRefs({ activeRepoConnectionId: SourceControlWorktreeContext['activeRepoConnectionId'] activeRepoExecutionHostId: SourceControlWorktreeContext['activeRepoExecutionHostId'] activeRepoId: string | null - activeRepoRuntimeEnvironmentId: string | null activeRepoWorktreeBaseRef: string | undefined activeWorktreeBaseRef: string | undefined hostedReview: HostedReviewInfo | null @@ -40,7 +38,6 @@ export function useSourceControlBaseRefs({ activeRepoConnectionId, activeRepoExecutionHostId, activeRepoId, - activeRepoRuntimeEnvironmentId, isBranchVisible, isFolder }) diff --git a/src/renderer/src/components/right-sidebar/use-create-pull-request-base-ref-discovery.ts b/src/renderer/src/components/right-sidebar/use-create-pull-request-base-ref-discovery.ts index bf2c30658b3..95fea02981e 100644 --- a/src/renderer/src/components/right-sidebar/use-create-pull-request-base-ref-discovery.ts +++ b/src/renderer/src/components/right-sidebar/use-create-pull-request-base-ref-discovery.ts @@ -1,5 +1,7 @@ import { useEffect, useState, type Dispatch, type SetStateAction } from 'react' -import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import { useAppStore } from '@/store' +import { findRepoForHost } from '@/store/slices/repo-host-identity' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { getRuntimeRepoBaseRefDefault, searchRuntimeRepoBaseRefDetails @@ -12,8 +14,6 @@ import { type CreatePullRequestBaseRefDiscoveryOptions = { open: boolean repoId: string - /** The repo owner's transport; `null` skips discovery. */ - target: RuntimeClientTarget | null base: string baseQuery: string setBase: Dispatch> @@ -22,17 +22,9 @@ type CreatePullRequestBaseRefDiscoveryOptions = { setBaseSearchError: Dispatch> } -// Why: the repo client still takes the settings shape; build it from the owner, never from focus. -function repoClientRoute(target: RuntimeClientTarget) { - return { - activeRuntimeEnvironmentId: target.kind === 'environment' ? target.environmentId : null - } -} - export function useCreatePullRequestBaseRefDiscovery({ open, repoId, - target, base, baseQuery, setBase, @@ -44,6 +36,11 @@ export function useCreatePullRequestBaseRefDiscovery({ // previous repo's default branch would silently suppress the stacked-PR lookup. const [repoDefault, setRepoDefault] = useState<{ repoId: string; baseRef: string } | null>(null) const repoDefaultBaseRef = repoDefault?.repoId === repoId ? repoDefault.baseRef : null + // Why: refs live on the repo's own host, not the focused one. + const repoHostId = useAppStore((s) => { + const repo = findRepoForHost(s.repos, repoId, { settings: s.settings }) + return repo ? getRepoExecutionHostId(repo) : null + }) // Why: resolved separately from eligibility's defaultBaseRef, which reports the // worktree's own base. Consumers that need "is this the repo's default branch?" @@ -51,11 +48,11 @@ export function useCreatePullRequestBaseRefDiscovery({ useEffect(() => { // Why: the repo default doesn't move while a repo stays open, so skip the probe // once it is known — on a remote runtime it is an RPC round-trip per composer open. - if (!open || repoDefaultBaseRef || !target) { + if (!open || repoDefaultBaseRef || !repoHostId) { return } let stale = false - void getRuntimeRepoBaseRefDefault(repoClientRoute(target), repoId) + void getRuntimeRepoBaseRefDefault(repoHostId, repoId) .then((result) => { if (!stale && result.defaultBaseRef) { setRepoDefault({ repoId, baseRef: stripBaseRef(result.defaultBaseRef) }) @@ -65,7 +62,7 @@ export function useCreatePullRequestBaseRefDiscovery({ return () => { stale = true } - }, [open, repoDefaultBaseRef, repoId, target]) + }, [open, repoDefaultBaseRef, repoHostId, repoId]) useEffect(() => { if (!open || base || !repoDefaultBaseRef) { @@ -75,7 +72,7 @@ export function useCreatePullRequestBaseRefDiscovery({ }, [base, open, repoDefaultBaseRef, setBase]) useEffect(() => { - if (!open || baseQuery.trim().length < 2 || !target) { + if (!open || !repoHostId || baseQuery.trim().length < 2) { setBaseResults([]) setBaseSearchPending(false) setBaseSearchError(null) @@ -84,7 +81,7 @@ export function useCreatePullRequestBaseRefDiscovery({ let stale = false setBaseSearchPending(true) const timer = window.setTimeout(() => { - void searchRuntimeRepoBaseRefDetails(repoClientRoute(target), repoId, baseQuery.trim(), 20) + void searchRuntimeRepoBaseRefDetails(repoHostId, repoId, baseQuery.trim(), 20) .then((results) => { if (!stale) { setBaseResults(normalizeCreateReviewBaseSearchResults(results)) @@ -107,7 +104,15 @@ export function useCreatePullRequestBaseRefDiscovery({ stale = true window.clearTimeout(timer) } - }, [baseQuery, open, repoId, target, setBaseResults, setBaseSearchError, setBaseSearchPending]) + }, [ + baseQuery, + open, + repoHostId, + repoId, + setBaseResults, + setBaseSearchError, + setBaseSearchPending + ]) return repoDefaultBaseRef } diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.repo-default.test.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.repo-default.test.ts index ffc092f2abd..b1e068f6ad9 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.repo-default.test.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.repo-default.test.ts @@ -13,6 +13,7 @@ vi.mock('@/runtime/runtime-repo-client', () => ({ })) const { useCreatePullRequestDialogFields } = await import('./useCreatePullRequestDialogFields') +const { useAppStore } = await import('@/store') type DialogFields = ReturnType @@ -87,6 +88,14 @@ function renderFields(initialRepoId: string): { beforeEach(() => { getRuntimeRepoBaseRefDefault.mockReset() + // Why: refs are read on the repo row's own host. + const repo = { path: '/repo', displayName: 'Repo', badgeColor: '#000', addedAt: 0 } + useAppStore.setState({ + repos: [ + { ...repo, id: 'repo-1' }, + { ...repo, id: 'repo-2' } + ] + }) }) describe('useCreatePullRequestDialogFields repo default base ref', () => { diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts index dbe37c1823a..84e236d7095 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts @@ -198,7 +198,6 @@ export function useCreatePullRequestDialogFields({ const repoDefaultBaseRef = useCreatePullRequestBaseRefDiscovery({ open, repoId, - target, base, baseQuery, setBase, diff --git a/src/renderer/src/components/settings/BaseRefPicker.tsx b/src/renderer/src/components/settings/BaseRefPicker.tsx index 8bb96a508cc..49037b623bd 100644 --- a/src/renderer/src/components/settings/BaseRefPicker.tsx +++ b/src/renderer/src/components/settings/BaseRefPicker.tsx @@ -3,14 +3,14 @@ import { useEffect, useRef, useState } from 'react' import { Button } from '../ui/button' import { Input } from '../ui/input' import { useAppStore } from '@/store' -import { getRuntimeEnvironmentIdForRepo } from '@/lib/repo-runtime-owner' +import { findRepoForHost } from '@/store/slices/repo-host-identity' import { getRuntimeRepoBaseRefDefault, searchRuntimeRepoBaseRefs } from '@/runtime/runtime-repo-client' import { isRuntimeRepoRefSearchQueryWithinLimit } from '@/runtime/runtime-repo-search-bounds' import { translate } from '@/i18n/i18n' -import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' type BaseRefPickerProps = { repoId: string @@ -27,15 +27,14 @@ export function BaseRefPicker({ onSelect, onUsePrimary }: BaseRefPickerProps): React.JSX.Element { - const focusedRuntimeEnvironmentId = useAppStore((state) => - getRuntimeEnvironmentIdForRepo(state, repoId) - ) - const selectedHost = parseExecutionHostId(hostId) - const activeRuntimeEnvironmentId = hostId - ? selectedHost?.kind === 'runtime' - ? selectedHost.environmentId - : null - : focusedRuntimeEnvironmentId + // Why: refs live on the repo's own host; with no host given, the repo row names it. + const ownerHostId = useAppStore((state) => { + if (hostId) { + return hostId + } + const repo = findRepoForHost(state.repos, repoId, { settings: state.settings }) + return repo ? getRepoExecutionHostId(repo) : null + }) // Why: null until the IPC resolves (or when the repo has no default base ref // available). We avoid seeding with 'origin/main' because that would display // a fabricated default in repos that don't actually have origin/main. @@ -73,11 +72,10 @@ export function BaseRefPicker({ const loadDefaultBaseRef = async (): Promise => { try { - const result = await getRuntimeRepoBaseRefDefault( - { activeRuntimeEnvironmentId }, - repoId, - hostId - ) + if (!ownerHostId) { + return + } + const result = await getRuntimeRepoBaseRefDefault(ownerHostId, repoId) if (!stale) { setDefaultBaseRef(result.defaultBaseRef) setRemoteCount(result.remoteCount) @@ -103,7 +101,7 @@ export function BaseRefPicker({ return () => { stale = true } - }, [activeRuntimeEnvironmentId, hostId, repoId]) + }, [ownerHostId, repoId]) useEffect(() => { if (!isRuntimeRepoRefSearchQueryWithinLimit(baseRefQuery)) { @@ -112,7 +110,7 @@ export function BaseRefPicker({ return } const trimmedQuery = baseRefQuery.trim() - if (trimmedQuery.length < 2) { + if (trimmedQuery.length < 2 || !ownerHostId) { setBaseRefResults([]) setIsSearchingBaseRefs(false) return @@ -122,13 +120,7 @@ export function BaseRefPicker({ setIsSearchingBaseRefs(true) const timer = window.setTimeout(() => { - void searchRuntimeRepoBaseRefs( - { activeRuntimeEnvironmentId }, - repoId, - trimmedQuery, - 20, - hostId - ) + void searchRuntimeRepoBaseRefs(ownerHostId, repoId, trimmedQuery, 20) .then((results) => { if (!stale) { setBaseRefResults(results) @@ -151,7 +143,7 @@ export function BaseRefPicker({ stale = true window.clearTimeout(timer) } - }, [activeRuntimeEnvironmentId, baseRefQuery, hostId, repoId]) + }, [baseRefQuery, ownerHostId, repoId]) const effectiveBaseRef = currentBaseRef ?? defaultBaseRef diff --git a/src/renderer/src/components/settings/RepositoryGitHubAccountSection.inventory-lifetime.test.tsx b/src/renderer/src/components/settings/RepositoryGitHubAccountSection.inventory-lifetime.test.tsx index 979a79e3bae..94cbd5b5352 100644 --- a/src/renderer/src/components/settings/RepositoryGitHubAccountSection.inventory-lifetime.test.tsx +++ b/src/renderer/src/components/settings/RepositoryGitHubAccountSection.inventory-lifetime.test.tsx @@ -189,22 +189,23 @@ describe('repository account inventory request ownership', () => { expect(listAccountsMock).toHaveBeenCalledTimes(2) }) - it('reloads for a changed legacy runtime target and keeps explicit refresh forced', async () => { + it('keeps an unstamped repo on this computer when the default host changes', async () => { const repo: Repo = { ...BASE_REPO, executionHostId: undefined } storeState.settings = { activeRuntimeEnvironmentId: 'server-1' } await render(repo) storeState.settings = { activeRuntimeEnvironmentId: 'server-2' } await render(repo) + expect(listAccountsMock).toHaveBeenCalledTimes(1) expect(listAccountsMock).toHaveBeenNthCalledWith( - 2, - { kind: 'environment', environmentId: 'server-2' }, + 1, + { kind: 'local' }, { id: repo.id, path: repo.path }, { refreshCapability: false } ) await act(async () => getRefreshButton().click()) expect(listAccountsMock).toHaveBeenNthCalledWith( - 3, - { kind: 'environment', environmentId: 'server-2' }, + 2, + { kind: 'local' }, { id: repo.id, path: repo.path }, { refreshCapability: true } ) diff --git a/src/renderer/src/components/settings/RepositoryGitHubAccountSection.tsx b/src/renderer/src/components/settings/RepositoryGitHubAccountSection.tsx index f5da489a363..5d10750e657 100644 --- a/src/renderer/src/components/settings/RepositoryGitHubAccountSection.tsx +++ b/src/renderer/src/components/settings/RepositoryGitHubAccountSection.tsx @@ -7,8 +7,8 @@ import { Button } from '../ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { SearchableSetting } from './SearchableSetting' import { useAppStore } from '../../store' -import { getRepoOwnerRoutedSettings } from '@/lib/repo-runtime-owner' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' import { translate } from '@/i18n/i18n' import { searchKeywords } from './settings-search-keywords' import { @@ -35,7 +35,6 @@ export function RepositoryGitHubAccountSection({ updateRepo, forceVisible }: RepositoryGitHubAccountSectionProps): React.JSX.Element | null { - const settings = useAppStore((state) => state.settings) const [inventory, setInventory] = useState(null) const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) @@ -44,13 +43,12 @@ export function RepositoryGitHubAccountSection({ const loadGenerationRef = useRef(0) const selectLabelId = useId() - // Why: getRepoOwnerRoutedSettings returns a fresh object each render, and the target - // depends on nothing else — key the memo on the id so it stays stable. - const activeRuntimeEnvironmentId = - getRepoOwnerRoutedSettings(settings, repo)?.activeRuntimeEnvironmentId ?? null + // Why: the binding lives on the repo's own host; key the memo on its id so it stays stable. + const ownerHost = parseExecutionHostId(getRepoExecutionHostId(repo)) + const ownerEnvironmentId = ownerHost?.kind === 'runtime' ? ownerHost.environmentId : null const runtimeTarget = useMemo( - () => getActiveRuntimeTarget({ activeRuntimeEnvironmentId }), - [activeRuntimeEnvironmentId] + () => runtimeTargetForOwnerEnvironment(ownerEnvironmentId), + [ownerEnvironmentId] ) const loadInventory = useCallback( diff --git a/src/renderer/src/components/settings/RepositoryHooksSection.test.ts b/src/renderer/src/components/settings/RepositoryHooksSection.test.ts index 4c8d329d3b5..83902cdebf1 100644 --- a/src/renderer/src/components/settings/RepositoryHooksSection.test.ts +++ b/src/renderer/src/components/settings/RepositoryHooksSection.test.ts @@ -168,10 +168,6 @@ describe('RepositoryHooksSection execution ownership', () => { await Promise.resolve() }) - expect(readRuntimeIssueCommand).toHaveBeenCalledWith( - { activeRuntimeEnvironmentId: 'hub' }, - repo.id, - 'runtime:hub' - ) + expect(readRuntimeIssueCommand).toHaveBeenCalledWith('runtime:hub', repo.id) }) }) diff --git a/src/renderer/src/components/settings/RepositoryHooksSection.tsx b/src/renderer/src/components/settings/RepositoryHooksSection.tsx index 21df23bea0f..4a1b13fcb6a 100644 --- a/src/renderer/src/components/settings/RepositoryHooksSection.tsx +++ b/src/renderer/src/components/settings/RepositoryHooksSection.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' import type { HookCommandSourcePolicy, @@ -8,7 +8,7 @@ import type { SetupRunPolicy } from '../../../../shared/orca-yaml-hook-types' import type { Repo } from '../../../../shared/repo-types' -import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { resolveHookCommandSourcePolicy } from '../../../../shared/hook-command-source-policy' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' @@ -61,12 +61,6 @@ export function RepositoryHooksSection({ const settingsSearchQuery = useAppStore((state) => state.settingsSearchQuery) const selectedHostId = getRepoExecutionHostId(repo) const repoHostIdentity = `${selectedHostId}\0${repo.id}` - const hookRuntimeSettings = useMemo(() => { - const parsedHost = parseExecutionHostId(selectedHostId) - return { - activeRuntimeEnvironmentId: parsedHost?.kind === 'runtime' ? parsedHost.environmentId : null - } - }, [selectedHostId]) const yamlState = yamlHooks ? 'loaded' : hasHooksFile @@ -82,7 +76,6 @@ export function RepositoryHooksSection({ updateHookSettingsPolicyDraft } = useRepositoryHookSettingsDraft({ repo, repoHostIdentity, onUpdateHookSettings }) const issueCommand = useRepositoryIssueCommand({ - hookRuntimeSettings, repoId: repo.id, repoHostIdentity, selectedHostId diff --git a/src/renderer/src/components/settings/RepositoryIconPicker.tsx b/src/renderer/src/components/settings/RepositoryIconPicker.tsx index 033bf24391e..f37929e73c0 100644 --- a/src/renderer/src/components/settings/RepositoryIconPicker.tsx +++ b/src/renderer/src/components/settings/RepositoryIconPicker.tsx @@ -8,7 +8,7 @@ import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' import { Button } from '../ui/button' import { Label } from '../ui/label' import { RepoIconGlyph, getRepoLucideIconOptions } from '../repo/repo-icon' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' import { useMountedRef } from '@/hooks/useMountedRef' import { RepositoryIconColorSection } from './RepositoryIconColorSection' @@ -33,16 +33,15 @@ export function RepositoryIconPicker({ // Why: resolve this repo's upstream/avatar on the host that owns it, not the // focused runtime. const selectedHost = parseExecutionHostId(getRepoExecutionHostId(repo)) - const activeRuntimeEnvironmentId = - selectedHost?.kind === 'runtime' ? selectedHost.environmentId : null + const ownerEnvironmentId = selectedHost?.kind === 'runtime' ? selectedHost.environmentId : null const selectedLucideName = repo.repoIcon?.type === 'lucide' ? repo.repoIcon.name : null const selectedEmoji = repo.repoIcon?.type === 'emoji' ? repo.repoIcon.emoji : '' const selectedBadgeColor = normalizeRepoBadgeColor(repo.badgeColor) ?? DEFAULT_REPO_BADGE_COLOR const initialTab = repo.repoIcon?.type === 'emoji' ? 'emoji' : repo.repoIcon?.type === 'lucide' ? 'icon' : 'avatar' const runtimeTarget = useMemo( - () => getActiveRuntimeTarget({ activeRuntimeEnvironmentId }), - [activeRuntimeEnvironmentId] + () => runtimeTargetForOwnerEnvironment(ownerEnvironmentId), + [ownerEnvironmentId] ) const currentIconLabel = useMemo(() => { diff --git a/src/renderer/src/components/settings/use-repository-issue-command.ts b/src/renderer/src/components/settings/use-repository-issue-command.ts index 8462a2b32cc..cfa0af4618f 100644 --- a/src/renderer/src/components/settings/use-repository-issue-command.ts +++ b/src/renderer/src/components/settings/use-repository-issue-command.ts @@ -3,15 +3,11 @@ import { toast } from 'sonner' import { readRuntimeIssueCommand, writeRuntimeIssueCommand } from '@/runtime/runtime-hooks-client' import type { ExecutionHostId } from '../../../../shared/execution-host' -type HookRuntimeSettings = { activeRuntimeEnvironmentId: string | null } - export function useRepositoryIssueCommand({ - hookRuntimeSettings, repoId, repoHostIdentity, selectedHostId }: { - hookRuntimeSettings: HookRuntimeSettings repoId: string repoHostIdentity: string selectedHostId: ExecutionHostId @@ -37,7 +33,7 @@ export function useRepositoryIssueCommand({ updateIssueCommandDraft('') setHasSharedIssueCommand(false) setIssueCommandSaveError(null) - void readRuntimeIssueCommand(hookRuntimeSettings, repoId, selectedHostId) + void readRuntimeIssueCommand(selectedHostId, repoId) .then((result) => { if (cancelled) { return @@ -60,23 +56,18 @@ export function useRepositoryIssueCommand({ cancelled = true const draft = issueCommandDraftRef.current.trim() if (draft !== lastCommittedIssueCommandRef.current) { - void writeRuntimeIssueCommand(hookRuntimeSettings, repoId, draft, selectedHostId).catch( - (error) => { - console.error( - '[RepositoryHooksSection] Failed to save issue command on unmount:', - error - ) - } - ) + void writeRuntimeIssueCommand(selectedHostId, repoId, draft).catch((error) => { + console.error('[RepositoryHooksSection] Failed to save issue command on unmount:', error) + }) } } - }, [hookRuntimeSettings, repoHostIdentity, repoId, selectedHostId, updateIssueCommandDraft]) + }, [repoHostIdentity, repoId, selectedHostId, updateIssueCommandDraft]) const commitIssueCommand = useCallback(async (): Promise => { const trimmed = issueCommandDraft.trim() updateIssueCommandDraft(trimmed) try { - await writeRuntimeIssueCommand(hookRuntimeSettings, repoId, trimmed, selectedHostId) + await writeRuntimeIssueCommand(selectedHostId, repoId, trimmed) lastCommittedIssueCommandRef.current = trimmed setIssueCommandSaveError(null) } catch (error) { @@ -86,7 +77,7 @@ export function useRepositoryIssueCommand({ setIssueCommandSaveError(message) toast.error(message) } - }, [hookRuntimeSettings, issueCommandDraft, repoId, selectedHostId, updateIssueCommandDraft]) + }, [issueCommandDraft, repoId, selectedHostId, updateIssueCommandDraft]) return { issueCommandDraft, diff --git a/src/renderer/src/components/settings/use-settings-repo-scroll-effects.ts b/src/renderer/src/components/settings/use-settings-repo-scroll-effects.ts index 93172d0e00a..490be7d5c86 100644 --- a/src/renderer/src/components/settings/use-settings-repo-scroll-effects.ts +++ b/src/renderer/src/components/settings/use-settings-repo-scroll-effects.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react' import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types' import { isFolderRepo } from '../../../../shared/repo-kind' -import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' import { getRepoHostIdentity } from '../../store/slices/repo-host-identity' import type { SettingsStoreModel } from './use-settings-store-model' @@ -80,15 +80,7 @@ export function useSettingsRepoScrollEffects( } try { const hostId = getRepoExecutionHostId(repo) - const parsedHost = parseExecutionHostId(hostId) - const result = await checkRuntimeHooks( - { - activeRuntimeEnvironmentId: - parsedHost?.kind === 'runtime' ? parsedHost.environmentId : null - }, - repo.id, - hostId - ) + const result = await checkRuntimeHooks(hostId, repo.id) if (stale || requestSeq !== repoHooksRequestSeqRef.current) { return } diff --git a/src/renderer/src/components/setup-guide/setup-guide-progress-readiness.ts b/src/renderer/src/components/setup-guide/setup-guide-progress-readiness.ts index 629e9ff0a23..37a4edfc1bd 100644 --- a/src/renderer/src/components/setup-guide/setup-guide-progress-readiness.ts +++ b/src/renderer/src/components/setup-guide/setup-guide-progress-readiness.ts @@ -1,4 +1,4 @@ -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import type { ComputerUsePermissionStatusResult } from '../../../../shared/computer-use-permissions-types' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { Repo } from '../../../../shared/repo-types' @@ -31,16 +31,16 @@ export const INITIAL_SETUP_SCRIPT_PROBE_STATE: SetupScriptProbeState = { export function getSetupScriptProbeSignature( settings: Pick | null | undefined, - orderedGitRepos: readonly Pick[] + orderedGitRepos: readonly Pick[] ): string | null { if (!settings) { return null } - const target = getActiveRuntimeTarget(settings) + // Why: each repo's hooks are probed on its own host, so the host is part of the signature. return JSON.stringify({ - runtime: target.kind === 'environment' ? target.environmentId : 'local', repos: orderedGitRepos.map((repo) => ({ id: repo.id, + host: getRepoExecutionHostId(repo), commandSourcePolicy: repo.hookSettings?.commandSourcePolicy ?? null, setup: repo.hookSettings?.scripts?.setup ?? null })) diff --git a/src/renderer/src/components/setup-guide/use-setup-guide-progress.test.ts b/src/renderer/src/components/setup-guide/use-setup-guide-progress.test.ts index 7baab33fafa..306b98a0446 100644 --- a/src/renderer/src/components/setup-guide/use-setup-guide-progress.test.ts +++ b/src/renderer/src/components/setup-guide/use-setup-guide-progress.test.ts @@ -215,18 +215,16 @@ describe('getSetupGuideProgressReady', () => { }) describe('setup script probe readiness', () => { - it('derives the probe signature from runtime and ordered git repo inputs', () => { + it('derives the probe signature from each repo host and order', () => { const localSignature = getSetupScriptProbeSignature({ activeRuntimeEnvironmentId: null }, [ { id: 'repo-a', hookSettings: undefined }, { id: 'repo-b', hookSettings: undefined } ]) - const remoteSignature = getSetupScriptProbeSignature( - { activeRuntimeEnvironmentId: 'runtime-1' }, - [ - { id: 'repo-a', hookSettings: undefined }, - { id: 'repo-b', hookSettings: undefined } - ] - ) + // Why: hooks are probed on each repo's own host, so a repo moving host changes the signature. + const remoteSignature = getSetupScriptProbeSignature({ activeRuntimeEnvironmentId: null }, [ + { id: 'repo-a', hookSettings: undefined, executionHostId: 'runtime:runtime-1' }, + { id: 'repo-b', hookSettings: undefined } + ]) const reorderedSignature = getSetupScriptProbeSignature({ activeRuntimeEnvironmentId: null }, [ { id: 'repo-b', hookSettings: undefined }, { id: 'repo-a', hookSettings: undefined } diff --git a/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts b/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts index 89842c2a856..a301dd021bb 100644 --- a/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts +++ b/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' import { useAppStore } from '@/store' import { isGitRepoKind } from '../../../../shared/repo-kind' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { hasFeatureInteraction } from '../../../../shared/feature-interactions' import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' @@ -170,7 +171,9 @@ export function useSetupGuideProgress( async function refreshSetupScriptState(): Promise { for (const repo of orderedGitRepos) { - const hooksResult = await checkRuntimeHooks(settings, repo.id).catch(() => null) + const hooksResult = await checkRuntimeHooks(getRepoExecutionHostId(repo), repo.id).catch( + () => null + ) if (stale) { return } diff --git a/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx b/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx index a5ab9fa694b..da9ad57d18e 100644 --- a/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx +++ b/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx @@ -75,8 +75,8 @@ function SetupScriptPromptCard(): React.JSX.Element | null { const hostId = getRepoExecutionHostId(repo) const inspection = await inspectSetupScriptPromptState({ repo, - checkHooks: () => checkRuntimeHooks(settings, repo.id, hostId), - inspectImports: () => inspectRuntimeSetupScriptImports(settings, repo.id, hostId) + checkHooks: () => checkRuntimeHooks(hostId, repo.id), + inspectImports: () => inspectRuntimeSetupScriptImports(hostId, repo.id) }) if (!cancelled) { const nextState = { diff --git a/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts b/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts index 52ab98e8e90..2bb1003e744 100644 --- a/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts +++ b/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts @@ -112,7 +112,7 @@ export function useComposerSubmitOrchestration( selectedRepoExecutionHostId: target.runtimeTargetSelection.selectedRepoExecutionHostId, selectedRepoIsGit: target.runtimeTargetSelection.selectedRepoIsGit, selectedRepoIsRemote: target.runtimeTargetSelection.selectedRepoIsRemote, - selectedRepoSettings: target.runtimeTargetSelection.selectedRepoSettings, + selectedRepoTarget: target.runtimeTargetSelection.selectedRepoTarget, selectedRepoStartupShell: target.runtimeTargetSelection.selectedRepoStartupShell, selectedWorkspaceTarget: target.runtimeTargetSelection.selectedWorkspaceTarget, settings: target.composerTargetStore.settings, diff --git a/src/renderer/src/hooks/composer-state/composer-target-state.ts b/src/renderer/src/hooks/composer-state/composer-target-state.ts index bbc3eb2fe47..8c8fdf3fcdf 100644 --- a/src/renderer/src/hooks/composer-state/composer-target-state.ts +++ b/src/renderer/src/hooks/composer-state/composer-target-state.ts @@ -115,8 +115,7 @@ export function useComposerTargetState( selectedRepoHookContextKey: runtimeTargetSelection.selectedRepoHookContextKey, selectedRepoIsGit: runtimeTargetSelection.selectedRepoIsGit, selectedRepoPath: asyncComposerState.selectedRepoPath, - selectedRepoSettings: runtimeTargetSelection.selectedRepoSettings, - selectedRepoSettingsRef: asyncComposerState.selectedRepoSettingsRef, + selectedRepoTarget: runtimeTargetSelection.selectedRepoTarget, setCheckedHooksContextKey: asyncComposerState.setCheckedHooksContextKey, setSelectedRepoSlug: asyncComposerState.setSelectedRepoSlug, setSetupAgentStartupPolicy: asyncComposerState.setSetupAgentStartupPolicy, diff --git a/src/renderer/src/hooks/composer-state/provider-runtime-sync.test.ts b/src/renderer/src/hooks/composer-state/provider-runtime-sync.test.ts index ede6eed7796..71f1d6d4bbd 100644 --- a/src/renderer/src/hooks/composer-state/provider-runtime-sync.test.ts +++ b/src/renderer/src/hooks/composer-state/provider-runtime-sync.test.ts @@ -50,8 +50,7 @@ describe('useComposerProviderRuntimeSync', () => { selectedRepoExecutionHostId: 'local', selectedRepoHookContextKey: 'local:repo', selectedRepoIsGit: true, - selectedRepoSettings: null, - selectedRepoSettingsRef: { current: null }, + selectedRepoTarget: { kind: 'local' }, setCheckedHooksContextKey: vi.fn(), setSelectedRepoSlug, diff --git a/src/renderer/src/hooks/composer-state/provider-runtime-sync.ts b/src/renderer/src/hooks/composer-state/provider-runtime-sync.ts index 969de7f479b..136f2cc0b43 100644 --- a/src/renderer/src/hooks/composer-state/provider-runtime-sync.ts +++ b/src/renderer/src/hooks/composer-state/provider-runtime-sync.ts @@ -9,8 +9,7 @@ export type ComposerProviderRuntimeSyncInput = Pick< | 'selectedRepoHookContextKey' | 'selectedRepoIsGit' | 'selectedRepoPath' - | 'selectedRepoSettings' - | 'selectedRepoSettingsRef' + | 'selectedRepoTarget' | 'setCheckedHooksContextKey' | 'setSelectedRepoSlug' | 'setSetupAgentStartupPolicy' @@ -28,7 +27,7 @@ import { isGitRepoKind } from '../../../../shared/repo-kind' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { type HookCheckResult, checkRuntimeHooks } from '@/runtime/runtime-hooks-client' -import { getActiveRuntimeTarget, callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import type { GitHubRepositoryIdentity } from '../../../../shared/github/pull-request-types' import { getRepoSetupAgentStartupPolicy, @@ -44,8 +43,7 @@ export function useComposerProviderRuntimeSync(input: ComposerProviderRuntimeSyn selectedRepoHookContextKey, selectedRepoIsGit, selectedRepoPath, - selectedRepoSettings, - selectedRepoSettingsRef, + selectedRepoTarget, setCheckedHooksContextKey, setSelectedRepoSlug, setSetupAgentStartupPolicy, @@ -197,10 +195,11 @@ export function useComposerProviderRuntimeSync(input: ComposerProviderRuntimeSyn } // Why: drop the cache entry on failure so a transient IPC error doesn't pin every later // check for this repo/host to the same rejection. - const promise: Promise = checkRuntimeHooks( - selectedRepoSettingsRef.current, - targetRepoId, - selectedRepoExecutionHostId ?? undefined + // Why: the repo row names its host; an unknown one is inspected nowhere. + const promise: Promise = ( + selectedRepoExecutionHostId + ? checkRuntimeHooks(selectedRepoExecutionHostId, targetRepoId) + : Promise.reject(new Error('The project host is unknown.')) ).catch((error: unknown) => { if (hookCheckRef.current?.promise === promise) { hookCheckRef.current = null @@ -210,7 +209,7 @@ export function useComposerProviderRuntimeSync(input: ComposerProviderRuntimeSyn hookCheckRef.current = { key, promise } return promise }, - [selectedRepoExecutionHostId, selectedRepoSettingsRef] + [selectedRepoExecutionHostId] ) const commitHookCheckIfCurrent = useCallback( @@ -231,7 +230,11 @@ export function useComposerProviderRuntimeSync(input: ComposerProviderRuntimeSyn return } let cancelled = false - const target = getActiveRuntimeTarget(selectedRepoSettings) + const target = selectedRepoTarget + if (!target) { + setSelectedRepoSlug(null) + return + } const slugRequest = target.kind === 'environment' ? callRuntimeRpc( @@ -264,7 +267,7 @@ export function useComposerProviderRuntimeSync(input: ComposerProviderRuntimeSyn selectedRepo, selectedRepoIsGit, selectedRepoPath, - selectedRepoSettings, + selectedRepoTarget, setSelectedRepoSlug ]) diff --git a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts index ea6b7631a60..e6b279f98e6 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts @@ -23,7 +23,7 @@ type QuickCreationExecutionInput = Pick< | 'selectedRepoExecutionHostId' | 'selectedRepoIsGit' | 'selectedRepoIsRemote' - | 'selectedRepoSettings' + | 'selectedRepoTarget' | 'selectedRepoStartupShell' | 'selectedWorkspaceTarget' | 'settings' @@ -37,7 +37,6 @@ import type { TuiAgent } from '../../../../shared/tui-agent' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' import { useAppStore } from '@/store' import { settleComposerSubmit } from '@/lib/composer-submit-cancellation' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { runBackgroundWorktreeCreation } from '@/lib/worktree-creation-flow' import { translate } from '@/i18n/i18n' import { resolveQuickCreateLinkedWorkItemPrompt } from '@/lib/linked-work-item-context' @@ -69,7 +68,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { selectedRepoExecutionHostId, selectedRepoIsGit, selectedRepoIsRemote, - selectedRepoSettings, + selectedRepoTarget, selectedRepoStartupShell, selectedWorkspaceTarget, settings, @@ -194,8 +193,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { repoId, ephemeralVmRecipe, indeterminateProgress: - Boolean(activeEphemeralVmRecipeId) || - getActiveRuntimeTarget(selectedRepoSettings).kind !== 'local', + Boolean(activeEphemeralVmRecipeId) || selectedRepoTarget?.kind === 'environment', taskSourceContext, linkedWorkItem: submitLinkedWorkItem, workspaceRunContext, @@ -280,7 +278,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { selectedRepoExecutionHostId, selectedRepoIsGit, selectedRepoIsRemote, - selectedRepoSettings, + selectedRepoTarget, selectedRepoStartupShell, selectedWorkspaceTarget, settings, diff --git a/src/renderer/src/hooks/composer-state/runtime-target-model.ts b/src/renderer/src/hooks/composer-state/runtime-target-model.ts index b3ef985701d..df4da929d57 100644 --- a/src/renderer/src/hooks/composer-state/runtime-target-model.ts +++ b/src/renderer/src/hooks/composer-state/runtime-target-model.ts @@ -1,3 +1,4 @@ +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { RefObject } from 'react' import type { ExecutionHostId, ParsedExecutionHost } from '../../../../shared/execution-host' import type { Repo } from '../../../../shared/repo-types' @@ -45,6 +46,8 @@ export type ComposerRuntimeTargetModel = { projectHostSetupOptions: ProjectHostSetupOption[] projectOptions: NewWorkspaceProjectOption[] selectedRepoSettings: Pick | null + /** Transport to the selected repo's owner; `null` with no repo selected. */ + selectedRepoTarget: RuntimeClientTarget | null selectedRecipeRepoId: string | null selectedRecipeRepoConnectionId: string | null ephemeralVmsEnabled: boolean diff --git a/src/renderer/src/hooks/composer-state/runtime-target-selection.ts b/src/renderer/src/hooks/composer-state/runtime-target-selection.ts index 242fed020e8..ba6224009e0 100644 --- a/src/renderer/src/hooks/composer-state/runtime-target-selection.ts +++ b/src/renderer/src/hooks/composer-state/runtime-target-selection.ts @@ -1,3 +1,4 @@ +import { runtimeTargetForOwnerHostId } from '@/runtime/runtime-client-target' import type { ComposerRuntimeTargetSelectionInput } from './composer-target-input-contracts' import { useEffect, useMemo, useRef } from 'react' @@ -221,6 +222,11 @@ export function useComposerRuntimeTargetSelection(input: ComposerRuntimeTargetSe ) }, [selectedRepo, settings]) + const selectedRepoTarget = useMemo( + () => (selectedRepo ? runtimeTargetForOwnerHostId(getRepoExecutionHostId(selectedRepo)) : null), + [selectedRepo] + ) + // Why: key on repo id, not the repo object — updateRepo replaces it by reference and would re-run this effect, wiping the user's chosen recipe. const selectedRecipeRepoId = selectedRepo?.id ?? null @@ -292,6 +298,7 @@ export function useComposerRuntimeTargetSelection(input: ComposerRuntimeTargetSe projectHostSetupOptions, projectOptions, selectedRepoSettings, + selectedRepoTarget, selectedRecipeRepoId, selectedRecipeRepoConnectionId, ephemeralVmsEnabled, diff --git a/src/renderer/src/lib/ensure-hooks-confirmed.test.ts b/src/renderer/src/lib/ensure-hooks-confirmed.test.ts index 808378e0502..67be19596bc 100644 --- a/src/renderer/src/lib/ensure-hooks-confirmed.test.ts +++ b/src/renderer/src/lib/ensure-hooks-confirmed.test.ts @@ -225,7 +225,7 @@ describe('ensureHooksConfirmed', () => { const decision = await ensureHooksConfirmed(state, 'repo-1', 'archive') expect(decision).toBe('run') - expect(hooksCheckMock).toHaveBeenCalledWith({ repoId: 'repo-1' }) + expect(hooksCheckMock).toHaveBeenCalledWith({ repoId: 'repo-1', hostId: 'ssh:ssh-1' }) expect(pending).toHaveLength(0) }) @@ -251,6 +251,23 @@ describe('ensureHooksConfirmed', () => { expect(runtimeEnvironmentCallMock).not.toHaveBeenCalled() }) + it('refuses to pick a host when the repo id is on two hosts and none is given', async () => { + const { state, pending } = createTestState() + Object.assign(state, { + repos: [ + { id: 'repo-1', displayName: 'Runtime', executionHostId: 'runtime:env-1' }, + { id: 'repo-1', displayName: 'Local', executionHostId: 'local' } + ] + }) + + const decision = await ensureHooksConfirmed(state, 'repo-1', 'setup') + + expect(decision).toBe('skip') + expect(hooksCheckMock).not.toHaveBeenCalled() + expect(runtimeEnvironmentCallMock).not.toHaveBeenCalled() + expect(pending).toHaveLength(0) + }) + it('checks runtime-owned repo hooks through the repo owner runtime', async () => { const { state, pending } = createTestState({ settings: { activeRuntimeEnvironmentId: 'focused-env' }, diff --git a/src/renderer/src/lib/ensure-hooks-confirmed.ts b/src/renderer/src/lib/ensure-hooks-confirmed.ts index cb5ce3d1c63..2b74f73ea3b 100644 --- a/src/renderer/src/lib/ensure-hooks-confirmed.ts +++ b/src/renderer/src/lib/ensure-hooks-confirmed.ts @@ -7,11 +7,10 @@ import { readRuntimeIssueCommand, type IssueCommandReadResult } from '@/runtime/runtime-hooks-client' -import { getRuntimeEnvironmentIdForRepo } from './repo-runtime-owner' import { MODAL_DISMISSED_KEY } from '@/store/slices/modal-slot-dismissal' import { getRepoExecutionHostId, - parseExecutionHostId, + toRuntimeExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' @@ -74,25 +73,31 @@ function findHookRepo(state: AppState, repoId: string, hostId?: ExecutionHostId) : state.repos.find((repo) => repo.id === repoId) } -function settingsForHookRepoOwner( +/** + * Host that inspects the repo's hooks: a runtime owner the caller captured (a server-owned SSH + * repo runs there), else the given host, else the repo row's own. Never the focused host. + */ +function hookRepoOwnerHostId( state: AppState, repoId: string, hostId?: ExecutionHostId, runtimeOwnerEnvironmentId?: string | null -): AppState['settings'] { - const parsedHost = hostId ? parseExecutionHostId(hostId) : null - const runtimeEnvironmentId = - runtimeOwnerEnvironmentId?.trim() || - (hostId - ? parsedHost?.kind === 'runtime' - ? parsedHost.environmentId - : null - : getRuntimeEnvironmentIdForRepo(state, repoId)) - // Why: hook inspection must follow the repo owner. SSH/local repos execute - // through desktop IPC, while runtime repos may differ from the focused host. - return state.settings - ? { ...state.settings, activeRuntimeEnvironmentId: runtimeEnvironmentId } - : ({ activeRuntimeEnvironmentId: runtimeEnvironmentId } as AppState['settings']) +): ExecutionHostId { + const ownerEnvironmentId = runtimeOwnerEnvironmentId?.trim() + if (ownerEnvironmentId) { + return toRuntimeExecutionHostId(ownerEnvironmentId) + } + const rows = hostId ? [] : state.repos.filter((entry) => entry.id === repoId) + // Why: with the id on two hosts, trusting one row's script could run the other's unseen. + if (rows.length > 1) { + throw new Error('The project exists on more than one host.') + } + const repo = rows[0] ?? null + const ownerHostId = hostId ?? (repo ? getRepoExecutionHostId(repo) : null) + if (!ownerHostId) { + throw new Error('The project host is unknown.') + } + return ownerHostId } function canUseRepoWideTrust(state: AppState, repoId: string): boolean { @@ -215,11 +220,7 @@ export async function readAndConfirmRuntimeIssueCommand( ): Promise { let result: IssueCommandReadResult try { - result = await readRuntimeIssueCommand( - settingsForHookRepoOwner(state, repoId, hostId), - repoId, - hostId - ) + result = await readRuntimeIssueCommand(hookRepoOwnerHostId(state, repoId, hostId), repoId) } catch { result = { status: 'error', @@ -258,9 +259,8 @@ export async function ensureHooksConfirmed( // Why: hostId disambiguates duplicate repo ids on the local IPC path, // matching the checkRuntimeHooks call below. const result = await readRuntimeIssueCommand( - settingsForHookRepoOwner(state, repoId, hostId, runtimeOwnerEnvironmentId), - repoId, - hostId + hookRepoOwnerHostId(state, repoId, hostId, runtimeOwnerEnvironmentId), + repoId ) if (result.source === 'local') { return 'run' @@ -285,9 +285,8 @@ export async function ensureHooksConfirmed( return 'run' } const result = await checkRuntimeHooks( - settingsForHookRepoOwner(state, repoId, hostId, runtimeOwnerEnvironmentId), - repoId, - hostId + hookRepoOwnerHostId(state, repoId, hostId, runtimeOwnerEnvironmentId), + repoId ) if (result.status === 'error') { return 'skip' diff --git a/src/renderer/src/lib/launch-work-item-direct-owner-row.test.ts b/src/renderer/src/lib/launch-work-item-direct-owner-row.test.ts index 9310ca91768..8df6a70ba01 100644 --- a/src/renderer/src/lib/launch-work-item-direct-owner-row.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct-owner-row.test.ts @@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => { resolvePrBase: vi.fn(), checkRuntimeHooks: vi.fn(), callRuntimeRpc: vi.fn(), + ensureHooksConfirmed: vi.fn(), store } }) @@ -14,7 +15,7 @@ const mocks = vi.hoisted(() => { vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.store } })) vi.mock('sonner', () => ({ toast: { error: vi.fn(), message: vi.fn() } })) vi.mock('@/lib/ensure-hooks-confirmed', () => ({ - ensureHooksConfirmed: vi.fn().mockResolvedValue('run') + ensureHooksConfirmed: mocks.ensureHooksConfirmed })) vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null })) vi.mock('@/runtime/runtime-hooks-client', () => ({ checkRuntimeHooks: mocks.checkRuntimeHooks })) @@ -27,6 +28,7 @@ import { launchWorkItemDirect } from './launch-work-item-direct' describe('launchWorkItemDirect owner row', () => { beforeEach(() => { vi.clearAllMocks() + mocks.ensureHooksConfirmed.mockResolvedValue('run') vi.stubGlobal('window', { api: { worktrees: { resolvePrBase: mocks.resolvePrBase } } }) mocks.checkRuntimeHooks.mockResolvedValue({ hasHooks: false, @@ -67,7 +69,13 @@ describe('launchWorkItemDirect owner row', () => { item: { type: 'pr', number: 7, title: 'Fix', url: 'https://github.com/o/r/pull/7' } }) - expect(mocks.checkRuntimeHooks).toHaveBeenCalledWith(null, 'repo-1', 'runtime:env-b') + expect(mocks.checkRuntimeHooks).toHaveBeenCalledWith('runtime:env-b', 'repo-1') + expect(mocks.ensureHooksConfirmed).toHaveBeenCalledWith( + expect.anything(), + 'repo-1', + 'setup', + 'runtime:env-b' + ) expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( { kind: 'environment', environmentId: 'env-b' }, 'worktree.resolvePrBase', diff --git a/src/renderer/src/lib/launch-work-item-direct-preflight.ts b/src/renderer/src/lib/launch-work-item-direct-preflight.ts index 2c7478d6d2f..7bc3fde1176 100644 --- a/src/renderer/src/lib/launch-work-item-direct-preflight.ts +++ b/src/renderer/src/lib/launch-work-item-direct-preflight.ts @@ -38,7 +38,7 @@ export async function resolveDirectSetupDecision( let yamlHooks: OrcaHooks | null = null try { // Why: the same owner host as the PR start point and createWorktree, never focus. - const result = await checkRuntimeHooks(null, repoId, ownerHostId) + const result = await checkRuntimeHooks(ownerHostId, repoId) yamlHooks = (result.hooks as OrcaHooks | null) ?? null } catch { yamlHooks = null diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index 7c7197f95e7..cfefddfe2ea 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -118,7 +118,12 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom return false } - const trustDecision = await ensureHooksConfirmed(useAppStore.getState(), repoId, 'setup') + const trustDecision = await ensureHooksConfirmed( + useAppStore.getState(), + repoId, + 'setup', + repoOwnerHostId + ) const finalSetupDecision: SetupDecision = trustDecision === 'skip' ? 'skip' : setupResolution.decision diff --git a/src/renderer/src/lib/worktree-creation-flow-startup.ts b/src/renderer/src/lib/worktree-creation-flow-startup.ts index 539c566a800..c44f513a331 100644 --- a/src/renderer/src/lib/worktree-creation-flow-startup.ts +++ b/src/renderer/src/lib/worktree-creation-flow-startup.ts @@ -1,5 +1,6 @@ import { useAppStore } from '@/store' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { runtimeTargetForOwnerHostId } from '@/runtime/runtime-client-target' +import { runtimeTargetForRepoOwner } from '@/lib/repo-runtime-owner' import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload' import type { WorktreeCreationPhase, @@ -39,7 +40,11 @@ export function getWorktreeCreationIndeterminate(request: WorktreeCreationReques if (request.worktreeCreateProgressMode) { return request.worktreeCreateProgressMode === 'indeterminate' } - return getActiveRuntimeTarget(useAppStore.getState().settings).kind !== 'local' + // Why: progress follows the host the workspace is created on, not the focused one. + const target = request.executionHostId + ? runtimeTargetForOwnerHostId(request.executionHostId) + : runtimeTargetForRepoOwner(useAppStore.getState(), request.repoId) + return target?.kind !== 'local' } export function getInitialWorktreeCreationPhase( diff --git a/src/renderer/src/runtime/runtime-hooks-client.test.ts b/src/renderer/src/runtime/runtime-hooks-client.test.ts index 61a94647ce4..31e3c1c6bd6 100644 --- a/src/renderer/src/runtime/runtime-hooks-client.test.ts +++ b/src/renderer/src/runtime/runtime-hooks-client.test.ts @@ -54,19 +54,26 @@ describe('runtime hooks client', () => { source: 'none' }) - await checkRuntimeHooks({ activeRuntimeEnvironmentId: null }, 'repo-1') - await inspectRuntimeSetupScriptImports({ activeRuntimeEnvironmentId: null }, 'repo-1') - await readRuntimeIssueCommand({ activeRuntimeEnvironmentId: null }, 'repo-1') - await writeRuntimeIssueCommand({ activeRuntimeEnvironmentId: null }, 'repo-1', 'Fix it') + await checkRuntimeHooks('local', 'repo-1') + await inspectRuntimeSetupScriptImports('local', 'repo-1') + await readRuntimeIssueCommand('local', 'repo-1') + await writeRuntimeIssueCommand('local', 'repo-1', 'Fix it') - expect(hooksCheck).toHaveBeenCalledWith({ repoId: 'repo-1' }) - expect(hooksInspectSetupScriptImports).toHaveBeenCalledWith({ repoId: 'repo-1' }) - expect(hooksReadIssueCommand).toHaveBeenCalledWith({ repoId: 'repo-1' }) - expect(hooksWriteIssueCommand).toHaveBeenCalledWith({ repoId: 'repo-1', content: 'Fix it' }) + expect(hooksCheck).toHaveBeenCalledWith({ repoId: 'repo-1', hostId: 'local' }) + expect(hooksInspectSetupScriptImports).toHaveBeenCalledWith({ + repoId: 'repo-1', + hostId: 'local' + }) + expect(hooksReadIssueCommand).toHaveBeenCalledWith({ repoId: 'repo-1', hostId: 'local' }) + expect(hooksWriteIssueCommand).toHaveBeenCalledWith({ + repoId: 'repo-1', + content: 'Fix it', + hostId: 'local' + }) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) - it('routes hook operations through the active runtime environment', async () => { + it('routes hook operations through the repo owner runtime environment', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', ok: true, @@ -74,10 +81,10 @@ describe('runtime hooks client', () => { _meta: { runtimeId: 'runtime-1' } }) - await checkRuntimeHooks({ activeRuntimeEnvironmentId: 'env-1' }, 'repo-1') - await inspectRuntimeSetupScriptImports({ activeRuntimeEnvironmentId: 'env-1' }, 'repo-1') - await readRuntimeIssueCommand({ activeRuntimeEnvironmentId: 'env-1' }, 'repo-1') - await writeRuntimeIssueCommand({ activeRuntimeEnvironmentId: 'env-1' }, 'repo-1', 'Fix it') + await checkRuntimeHooks('runtime:env-1', 'repo-1') + await inspectRuntimeSetupScriptImports('runtime:env-1', 'repo-1') + await readRuntimeIssueCommand('runtime:env-1', 'repo-1') + await writeRuntimeIssueCommand('runtime:env-1', 'repo-1', 'Fix it') expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { selector: 'env-1', @@ -120,23 +127,10 @@ describe('runtime hooks client', () => { source: 'none' }) - await checkRuntimeHooks( - { activeRuntimeEnvironmentId: 'focused-elsewhere' }, - 'same-repo', - 'ssh:server' - ) - await inspectRuntimeSetupScriptImports( - { activeRuntimeEnvironmentId: 'focused-elsewhere' }, - 'same-repo', - 'ssh:server' - ) - await readRuntimeIssueCommand({ activeRuntimeEnvironmentId: null }, 'same-repo', 'ssh:server') - await writeRuntimeIssueCommand( - { activeRuntimeEnvironmentId: null }, - 'same-repo', - 'Fix it', - 'ssh:server' - ) + await checkRuntimeHooks('ssh:server', 'same-repo') + await inspectRuntimeSetupScriptImports('ssh:server', 'same-repo') + await readRuntimeIssueCommand('ssh:server', 'same-repo') + await writeRuntimeIssueCommand('ssh:server', 'same-repo', 'Fix it') expect(hooksCheck).toHaveBeenCalledWith({ repoId: 'same-repo', hostId: 'ssh:server' }) expect(hooksInspectSetupScriptImports).toHaveBeenCalledWith({ diff --git a/src/renderer/src/runtime/runtime-hooks-client.ts b/src/renderer/src/runtime/runtime-hooks-client.ts index ee70da86b3c..31d235c7f16 100644 --- a/src/renderer/src/runtime/runtime-hooks-client.ts +++ b/src/renderer/src/runtime/runtime-hooks-client.ts @@ -1,18 +1,16 @@ -import type { GlobalSettings } from '../../../shared/global-settings-types' import type { OrcaHooks } from '../../../shared/orca-yaml-hook-types' -import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import type { ExecutionHostId } from '../../../shared/execution-host' import type { SetupScriptImportCandidate } from '../../../shared/setup-script-imports' -import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' +import { callRuntimeRpc } from './runtime-rpc-client' +import { runtimeTargetForOwnerHostId, type RuntimeClientTarget } from './runtime-client-target' -function getHookInspectionTarget( - settings: Pick | null | undefined, - hostId?: ExecutionHostId -): ReturnType { - const parsedHost = parseExecutionHostId(hostId) - if (parsedHost?.kind === 'runtime') { - return { kind: 'environment', environmentId: parsedHost.environmentId } +/** The repo's own host: a server's runtime, or this app (with `hostId`) for local and SSH repos. */ +function getHookInspectionTarget(hostId: ExecutionHostId): RuntimeClientTarget { + const target = runtimeTargetForOwnerHostId(hostId) + if (!target) { + throw new Error('The project host is unresolved.') } - return parsedHost ? { kind: 'local' } : getActiveRuntimeTarget(settings) + return target } export type HookCheckResult = { @@ -32,13 +30,12 @@ export type IssueCommandReadResult = { } export async function checkRuntimeHooks( - settings: Pick | null | undefined, - repoId: string, - hostId?: ExecutionHostId + hostId: ExecutionHostId, + repoId: string ): Promise { - const target = getHookInspectionTarget(settings, hostId) + const target = getHookInspectionTarget(hostId) if (target.kind !== 'environment') { - return window.api.hooks.check({ repoId, ...(hostId ? { hostId } : {}) }) + return window.api.hooks.check({ repoId, hostId }) } return callRuntimeRpc( target, @@ -49,13 +46,12 @@ export async function checkRuntimeHooks( } export async function inspectRuntimeSetupScriptImports( - settings: Pick | null | undefined, - repoId: string, - hostId?: ExecutionHostId + hostId: ExecutionHostId, + repoId: string ): Promise { - const target = getHookInspectionTarget(settings, hostId) + const target = getHookInspectionTarget(hostId) if (target.kind !== 'environment') { - return window.api.hooks.inspectSetupScriptImports({ repoId, ...(hostId ? { hostId } : {}) }) + return window.api.hooks.inspectSetupScriptImports({ repoId, hostId }) } return callRuntimeRpc( target, @@ -66,13 +62,12 @@ export async function inspectRuntimeSetupScriptImports( } export async function readRuntimeIssueCommand( - settings: Pick | null | undefined, - repoId: string, - hostId?: ExecutionHostId + hostId: ExecutionHostId, + repoId: string ): Promise { - const target = getActiveRuntimeTarget(settings) + const target = getHookInspectionTarget(hostId) if (target.kind !== 'environment') { - return window.api.hooks.readIssueCommand({ repoId, ...(hostId ? { hostId } : {}) }) + return window.api.hooks.readIssueCommand({ repoId, hostId }) } return callRuntimeRpc( target, @@ -83,14 +78,13 @@ export async function readRuntimeIssueCommand( } export async function writeRuntimeIssueCommand( - settings: Pick | null | undefined, + hostId: ExecutionHostId, repoId: string, - content: string, - hostId?: ExecutionHostId + content: string ): Promise { - const target = getActiveRuntimeTarget(settings) + const target = getHookInspectionTarget(hostId) if (target.kind !== 'environment') { - await window.api.hooks.writeIssueCommand({ repoId, content, ...(hostId ? { hostId } : {}) }) + await window.api.hooks.writeIssueCommand({ repoId, content, hostId }) return } await callRuntimeRpc( diff --git a/src/renderer/src/runtime/runtime-repo-client.test.ts b/src/renderer/src/runtime/runtime-repo-client.test.ts index cbd233f0e95..73247437ff3 100644 --- a/src/renderer/src/runtime/runtime-repo-client.test.ts +++ b/src/renderer/src/runtime/runtime-repo-client.test.ts @@ -38,7 +38,7 @@ afterEach(() => { describe('runtime repo client search bounds', () => { it('rejects oversized local base-ref searches before IPC', async () => { await expect( - searchRuntimeRepoBaseRefs(null, 'repo-1', 'x'.repeat(3 * 1024), 20) + searchRuntimeRepoBaseRefs('local', 'repo-1', 'x'.repeat(3 * 1024), 20) ).resolves.toEqual([]) expect(searchBaseRefs).not.toHaveBeenCalled() @@ -48,7 +48,7 @@ describe('runtime repo client search bounds', () => { it('rejects oversized runtime base-ref detail searches before RPC', async () => { await expect( searchRuntimeRepoBaseRefDetails( - { activeRuntimeEnvironmentId: 'env-1' }, + 'runtime:env-1', 'repo-1', 'secret-token-value'.repeat(256), 20 @@ -63,8 +63,8 @@ describe('runtime repo client search bounds', () => { getBaseRefDefault.mockResolvedValue({ defaultBaseRef: 'origin/main', remoteCount: 1 }) searchBaseRefs.mockResolvedValue(['origin/main']) - await getRuntimeRepoBaseRefDefault(null, 'same-repo', 'ssh:server') - await searchRuntimeRepoBaseRefs(null, 'same-repo', 'main', 20, 'ssh:server') + await getRuntimeRepoBaseRefDefault('ssh:server', 'same-repo') + await searchRuntimeRepoBaseRefs('ssh:server', 'same-repo', 'main', 20) expect(getBaseRefDefault).toHaveBeenCalledWith({ repoId: 'same-repo', diff --git a/src/renderer/src/runtime/runtime-repo-client.ts b/src/renderer/src/runtime/runtime-repo-client.ts index 67f9a24d0cc..56214db1c92 100644 --- a/src/renderer/src/runtime/runtime-repo-client.ts +++ b/src/renderer/src/runtime/runtime-repo-client.ts @@ -1,23 +1,31 @@ -import type { GlobalSettings } from '../../../shared/global-settings-types' import type { BaseRefSearchResult } from '../../../shared/repo-types' import { legacyBaseRefSearchResult } from '../../../shared/base-ref-search-result' -import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' +import { callRuntimeRpc } from './runtime-rpc-client' +import { runtimeTargetForOwnerHostId, type RuntimeClientTarget } from './runtime-client-target' import { isRuntimeRepoRefSearchQueryWithinLimit } from './runtime-repo-search-bounds' import type { ExecutionHostId } from '../../../shared/execution-host' +/** The repo's own host: a server's runtime, or this app (with `hostId`) for local and SSH repos. */ +function repoHostTarget(hostId: ExecutionHostId): RuntimeClientTarget { + const target = runtimeTargetForOwnerHostId(hostId) + if (!target) { + throw new Error('The project host is unresolved.') + } + return target +} + export type RuntimeRepoBaseRefDefault = { defaultBaseRef: string | null remoteCount: number } export async function getRuntimeRepoBaseRefDefault( - settings: Pick | null | undefined, - repoId: string, - hostId?: ExecutionHostId + hostId: ExecutionHostId, + repoId: string ): Promise { - const target = getActiveRuntimeTarget(settings) + const target = repoHostTarget(hostId) if (target.kind !== 'environment') { - return window.api.repos.getBaseRefDefault({ repoId, ...(hostId ? { hostId } : {}) }) + return window.api.repos.getBaseRefDefault({ repoId, hostId }) } return callRuntimeRpc( target, @@ -28,18 +36,17 @@ export async function getRuntimeRepoBaseRefDefault( } export async function searchRuntimeRepoBaseRefs( - settings: Pick | null | undefined, + hostId: ExecutionHostId, repoId: string, query: string, - limit: number, - hostId?: ExecutionHostId + limit: number ): Promise { if (!isRuntimeRepoRefSearchQueryWithinLimit(query)) { return [] } - const target = getActiveRuntimeTarget(settings) + const target = repoHostTarget(hostId) if (target.kind !== 'environment') { - return window.api.repos.searchBaseRefs({ repoId, query, limit, ...(hostId ? { hostId } : {}) }) + return window.api.repos.searchBaseRefs({ repoId, query, limit, hostId }) } const result = await callRuntimeRpc<{ refs: string[]; truncated: boolean }>( target, @@ -51,22 +58,21 @@ export async function searchRuntimeRepoBaseRefs( } export async function searchRuntimeRepoBaseRefDetails( - settings: Pick | null | undefined, + hostId: ExecutionHostId, repoId: string, query: string, - limit: number, - hostId?: ExecutionHostId + limit: number ): Promise { if (!isRuntimeRepoRefSearchQueryWithinLimit(query)) { return [] } - const target = getActiveRuntimeTarget(settings) + const target = repoHostTarget(hostId) if (target.kind !== 'environment') { return window.api.repos.searchBaseRefDetails({ repoId, query, limit, - ...(hostId ? { hostId } : {}) + hostId }) } const result = await callRuntimeRpc<{ diff --git a/src/renderer/src/store/slices/worktrees-create-base-status.test.ts b/src/renderer/src/store/slices/worktrees-create-base-status.test.ts index fcee4b6a3c2..59a8bbc05e9 100644 --- a/src/renderer/src/store/slices/worktrees-create-base-status.test.ts +++ b/src/renderer/src/store/slices/worktrees-create-base-status.test.ts @@ -1,10 +1,11 @@ +import type { ExecutionHostId } from '../../../../shared/execution-host' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '../types' import type { LocalBaseRefRefreshResult } from '../../../../shared/worktree/base-ref-drift-types' import { toast } from 'sonner' import type { RuntimeEnvironmentCallRequest } from '../../runtime/runtime-compatibility-test-fixture' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope' -import { makeWorkspaceLineage, makeWorktree } from './worktrees-slice-test-fixtures' +import { TEST_REPO, makeWorkspaceLineage, makeWorktree } from './worktrees-slice-test-fixtures' import { createTestStore, mockApi, @@ -39,7 +40,7 @@ describe('createWorktree base status merge', () => { }) it('prefetches create base through desktop IPC on the local runtime target', async () => { - const store = createTestStore() + const store = createRepoTestStore() await store.getState().prefetchWorktreeCreateBase('repo1', 'origin/main') @@ -51,10 +52,11 @@ describe('createWorktree base status merge', () => { }) it('prefetches create base through runtime RPC for remote runtime targets', async () => { - const store = createTestStore() + const store = createRepoTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'remote-runtime' } } as Partial) + seedRepoHost(store, 'runtime:remote-runtime') runtimeEnvironmentCall.mockResolvedValue({ id: 'req', ok: true, result: null }) await store.getState().prefetchWorktreeCreateBase('repo1') @@ -71,7 +73,7 @@ describe('createWorktree base status merge', () => { it('marks the create payload as a generated name only when the caller says so', async () => { // Why: the host retires generated names permanently, and the creature pool contains ordinary // words ("orca", "runner", "molly"). A name the user typed must stay reusable. - const store = createTestStore() + const store = createRepoTestStore() mockApi.worktrees.create.mockResolvedValue({ worktree: makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) }) @@ -114,7 +116,7 @@ describe('createWorktree base status merge', () => { }) it('passes linked work item and creation agent metadata through the create IPC payload', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', @@ -174,7 +176,7 @@ describe('createWorktree base status merge', () => { }) it('adopts an explicit provisioned root without calling ordinary worktree create', async () => { - const store = createTestStore() + const store = createRepoTestStore() const adopted = makeWorktree({ id: 'repo1::/workspace/repo', repoId: 'repo1', @@ -237,7 +239,7 @@ describe('createWorktree base status merge', () => { }) it('stamps the owning runtime host onto worktrees created on a remote runtime', async () => { - const store = createTestStore() + const store = createRepoTestStore() const created = makeWorktree({ id: 'repo-remote::/remote/feature', repoId: 'repo-remote', @@ -277,7 +279,7 @@ describe('createWorktree base status merge', () => { }) it('passes the active folder workspace as parent for in-app worktree creates', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', @@ -310,7 +312,7 @@ describe('createWorktree base status merge', () => { }) it('merges create result metadata into a worktree inserted by the watcher race', async () => { - const store = createTestStore() + const store = createRepoTestStore() const watcherWorktree = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', @@ -335,7 +337,7 @@ describe('createWorktree base status merge', () => { }) it('requests a warning dialog when creation falls back to a local base', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', @@ -369,7 +371,7 @@ describe('createWorktree base status merge', () => { status: LocalBaseRefRefreshResult['status'] expectedReason: string }[])('warns when local base ref refresh returns $status', async ({ status, expectedReason }) => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', @@ -410,7 +412,7 @@ describe('createWorktree base status merge', () => { }) it('falls back to the generic dirty detail when ownerWorktreePath is missing', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', @@ -435,7 +437,7 @@ describe('createWorktree base status merge', () => { }) it('names the toast from branch when displayName is blank', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', @@ -464,7 +466,8 @@ describe('createWorktree base status merge', () => { // Creates that joined one refresh report the same fact, so they share one toast per repo+branch. it('reuses one toast id for every create of the same repo and base branch', async () => { - const store = createTestStore() + const store = createRepoTestStore() + store.setState({ repos: [TEST_REPO, { ...TEST_REPO, id: 'repo2' }] }) const refresh = { status: 'skipped_error', baseRef: 'origin/main', @@ -490,7 +493,7 @@ describe('createWorktree base status merge', () => { }) it('does not warn when the local base ref refresh succeeds', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', @@ -511,7 +514,7 @@ describe('createWorktree base status merge', () => { }) it('does not warn when local base ref refresh is omitted from the create result', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', @@ -525,7 +528,7 @@ describe('createWorktree base status merge', () => { }) it('suggests turning on local main freshness when the create result reports a stale local base', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', @@ -554,7 +557,7 @@ describe('createWorktree base status merge', () => { }) it('persists the dismissal flag when the suggestion toast is closed or swiped', async () => { - const store = createTestStore() + const store = createRepoTestStore() store.setState({ settings: { refreshLocalBaseRefOnWorktreeCreate: false } as AppState['settings'], updateSettings: vi.fn().mockResolvedValue(undefined) @@ -580,7 +583,7 @@ describe('createWorktree base status merge', () => { }) it('does not record a dismissal on close when the feature is already enabled', async () => { - const store = createTestStore() + const store = createRepoTestStore() store.setState({ settings: { refreshLocalBaseRefOnWorktreeCreate: true } as AppState['settings'], updateSettings: vi.fn().mockResolvedValue(undefined) @@ -606,7 +609,7 @@ describe('createWorktree base status merge', () => { }) it('stamps manualOrder on create while Manual sort is active', async () => { - const store = createTestStore() + const store = createRepoTestStore() store.setState({ sortBy: 'manual' } as Partial) const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(123_456) const wt = makeWorktree({ @@ -634,7 +637,7 @@ describe('createWorktree base status merge', () => { }) it('passes branchNameOverride through the local create IPC payload', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/feature-something', repoId: 'repo1', @@ -672,7 +675,7 @@ describe('createWorktree base status merge', () => { }) it('retries a suffixed branchNameOverride when local IPC reports a branch conflict', async () => { - const store = createTestStore() + const store = createRepoTestStore() const error = new Error( 'Branch "feature/something" already exists. Pick a different worktree name.' ) @@ -721,7 +724,7 @@ describe('createWorktree base status merge', () => { }) it('does not overwrite a newer reconcile status with the initial checking status', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) mockApi.worktrees.create.mockImplementation(async () => { store.getState().updateWorktreeBaseStatus({ @@ -753,3 +756,14 @@ describe('createWorktree base status merge', () => { }) }) }) + +// Creation routes by the project row's own host, so every test seeds that row. +function seedRepoHost(store: ReturnType, hostId: ExecutionHostId): void { + store.setState({ repos: [{ ...TEST_REPO, executionHostId: hostId }] }) +} + +function createRepoTestStore(): ReturnType { + const store = createTestStore() + seedRepoHost(store, 'local') + return store +} diff --git a/src/renderer/src/store/slices/worktrees-create-parent-pick.test.ts b/src/renderer/src/store/slices/worktrees-create-parent-pick.test.ts index 0e16557a1cf..913bda4e46a 100644 --- a/src/renderer/src/store/slices/worktrees-create-parent-pick.test.ts +++ b/src/renderer/src/store/slices/worktrees-create-parent-pick.test.ts @@ -1,10 +1,11 @@ +import type { ExecutionHostId } from '../../../../shared/execution-host' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '../types' import type { Worktree } from '../../../../shared/worktree/types' import { toast } from 'sonner' import type { RuntimeEnvironmentCallRequest } from '../../runtime/runtime-compatibility-test-fixture' import { worktreeWorkspaceKey, folderWorkspaceKey } from '../../../../shared/workspace-scope' -import { makeLineage, makeWorktree } from './worktrees-slice-test-fixtures' +import { TEST_REPO, makeLineage, makeWorktree } from './worktrees-slice-test-fixtures' import { createTestStore, mockApi, @@ -36,7 +37,7 @@ describe('createWorktree composer parent pick', () => { }) function createParentPickStore(parent?: Worktree) { - const store = createTestStore() + const store = createRepoTestStore() store.setState({ worktreesByRepo: { repo1: parent ? [parent] : [] } } as Partial) return store } @@ -211,7 +212,7 @@ describe('createWorktree parent pick on a remote runtime', () => { }) function createRemoteStore() { - const store = createTestStore() + const store = createRepoTestStore() store.setState({ repos: [ { @@ -386,3 +387,14 @@ describe('createWorktree parent pick on a remote runtime', () => { expect(toast.warning).not.toHaveBeenCalled() }) }) + +// Creation routes by the project row's own host, so every test seeds that row. +function seedRepoHost(store: ReturnType, hostId: ExecutionHostId): void { + store.setState({ repos: [{ ...TEST_REPO, executionHostId: hostId }] }) +} + +function createRepoTestStore(): ReturnType { + const store = createTestStore() + seedRepoHost(store, 'local') + return store +} diff --git a/src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts b/src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts index b92879e32bc..e403414e772 100644 --- a/src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts +++ b/src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts @@ -633,6 +633,10 @@ describe('fetchWorktrees', () => { { id: 'repo2', path: '/r2', displayName: 'R2', badgeColor: '#000', addedAt: 0 } ] } as Partial) + // Ingest stamps server rows; the listing follows the row's host. + store.setState({ + repos: store.getState().repos.map((repo) => ({ ...repo, executionHostId: 'runtime:env-1' })) + }) // Why: a mobile-scope device token is denied non-allowlisted runtime RPCs. runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', diff --git a/src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts b/src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts index 09b3374cf1e..3ac39370dd6 100644 --- a/src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts +++ b/src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts @@ -1,10 +1,11 @@ +import type { ExecutionHostId } from '../../../../shared/execution-host' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '../types' import { createCompatibleRuntimeStatusResponse, type RuntimeEnvironmentCallRequest } from '../../runtime/runtime-compatibility-test-fixture' -import { makeWorktree } from './worktrees-slice-test-fixtures' +import { TEST_REPO, makeWorktree } from './worktrees-slice-test-fixtures' import { createTestStore, mockApi, @@ -39,7 +40,7 @@ describe('worktree remote runtime mutations', () => { }) it('creates worktrees through the active remote runtime environment', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/feature', repoId: 'repo1', @@ -55,6 +56,7 @@ describe('worktree remote runtime mutations', () => { settings: { activeRuntimeEnvironmentId: 'env-1' } as never, worktreesByRepo: { repo1: [] } } as Partial) + seedRepoHost(store, 'runtime:env-1') const result = await store .getState() @@ -100,7 +102,7 @@ describe('worktree remote runtime mutations', () => { }) it('forwards generated-name provenance through paired-runtime create', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/nautilus', repoId: 'repo1' }) runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-create', @@ -112,6 +114,7 @@ describe('worktree remote runtime mutations', () => { settings: { activeRuntimeEnvironmentId: 'env-1' } as never, worktreesByRepo: { repo1: [] } } as Partial) + seedRepoHost(store, 'runtime:env-1') const createWorktree = store.getState().createWorktree const args: Parameters = ['repo1', 'nautilus'] args[25] = { nameWasGenerated: true } @@ -127,7 +130,7 @@ describe('worktree remote runtime mutations', () => { }) it('persists Jira item and source context through paired-runtime create', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/jira-link', repoId: 'repo1', @@ -143,6 +146,7 @@ describe('worktree remote runtime mutations', () => { settings: { activeRuntimeEnvironmentId: 'env-1' } as never, worktreesByRepo: { repo1: [] } } as Partial) + seedRepoHost(store, 'runtime:env-1') const linkedWorkItem = { provider: 'jira' as const, type: 'issue' as const, @@ -187,11 +191,12 @@ describe('worktree remote runtime mutations', () => { runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => args.method === 'status.get' ? oldRuntimeStatus : runtimeEnvironmentCall(args) ) - const store = createTestStore() + const store = createRepoTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never, worktreesByRepo: { repo1: [] } } as Partial) + seedRepoHost(store, 'runtime:env-1') const createWorktree = store.getState().createWorktree const args: Parameters = ['repo1', 'jira-link'] args[25] = { @@ -210,7 +215,7 @@ describe('worktree remote runtime mutations', () => { }) it('passes startup commands through remote runtime worktree creation', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/agent-startup', repoId: 'repo1', @@ -226,6 +231,7 @@ describe('worktree remote runtime mutations', () => { settings: { activeRuntimeEnvironmentId: 'env-1' } as never, worktreesByRepo: { repo1: [] } } as Partial) + seedRepoHost(store, 'runtime:env-1') await store .getState() @@ -281,7 +287,7 @@ describe('worktree remote runtime mutations', () => { }) it('passes task startup drafts only to the owning remote runtime', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/task-draft', repoId: 'repo1', @@ -297,6 +303,7 @@ describe('worktree remote runtime mutations', () => { settings: { activeRuntimeEnvironmentId: 'env-1' } as never, worktreesByRepo: { repo1: [] } } as Partial) + seedRepoHost(store, 'runtime:env-1') const createWorktree = store.getState().createWorktree const args: Parameters = ['repo1', 'task-draft', undefined, 'inherit'] args[10] = 'codex' @@ -317,7 +324,7 @@ describe('worktree remote runtime mutations', () => { }) it('passes startup commands through local worktree creation IPC', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/local-agent-startup', repoId: 'repo1', @@ -384,7 +391,7 @@ describe('worktree remote runtime mutations', () => { }) it('retries a suffixed branchNameOverride when runtime create reports a branch conflict', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/feature-something-2', repoId: 'repo1', @@ -402,6 +409,7 @@ describe('worktree remote runtime mutations', () => { settings: { activeRuntimeEnvironmentId: 'env-1' } as never, worktreesByRepo: { repo1: [] } } as Partial) + seedRepoHost(store, 'runtime:env-1') await store .getState() @@ -442,3 +450,14 @@ describe('worktree remote runtime mutations', () => { ) }) }) + +// Creation routes by the project row's own host, so every test seeds that row. +function seedRepoHost(store: ReturnType, hostId: ExecutionHostId): void { + store.setState({ repos: [{ ...TEST_REPO, executionHostId: hostId }] }) +} + +function createRepoTestStore(): ReturnType { + const store = createTestStore() + seedRepoHost(store, 'local') + return store +} diff --git a/src/renderer/src/store/slices/worktrees-remote-runtime-removal.test.ts b/src/renderer/src/store/slices/worktrees-remote-runtime-removal.test.ts index 0bbe857ef6a..e55168f82bc 100644 --- a/src/renderer/src/store/slices/worktrees-remote-runtime-removal.test.ts +++ b/src/renderer/src/store/slices/worktrees-remote-runtime-removal.test.ts @@ -188,6 +188,13 @@ describe('worktree remote runtime mutations', () => { runtimeOwnerEnvironmentId: 'owner-hub' }) runtimeEnvironmentCall + // Why: the archive-hook check runs on the HUB too; this app cannot reach its SSH target. + .mockResolvedValueOnce({ + id: 'rpc-hooks-check', + ok: true, + result: { hasHooks: false, hooks: null, mayNeedUpdate: false }, + _meta: { runtimeId: 'runtime-owner-hub' } + }) .mockResolvedValueOnce({ id: 'rpc-rm-nested', ok: true, @@ -219,6 +226,12 @@ describe('worktree remote runtime mutations', () => { } }) expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { + selector: 'owner-hub', + method: 'repo.hooksCheck', + params: { repo: 'repo-ssh' }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { selector: 'owner-hub', method: 'worktree.rm', params: { @@ -239,7 +252,7 @@ describe('worktree remote runtime mutations', () => { .forceDeletePreservedBranch(wt.id, 'feature/nested', 'saved-head') expect(forceResult).toEqual({ ok: true, deleted: true }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(3, { selector: 'owner-hub', method: 'worktree.forceDeleteBranch', params: { diff --git a/src/renderer/src/store/slices/worktrees/create/create-worktree.ts b/src/renderer/src/store/slices/worktrees/create/create-worktree.ts index 465c81a6d18..653f5ea9967 100644 --- a/src/renderer/src/store/slices/worktrees/create/create-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/create/create-worktree.ts @@ -14,13 +14,13 @@ import { import { assertRuntimeEnvironmentCapability, callRuntimeRpc, - getActiveRuntimeTarget + type RuntimeClientTarget } from '../../../../runtime/runtime-rpc-client' +import { runtimeTargetForRepoOwner } from '@/lib/repo-runtime-owner' import { WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY } from '../../../../../../shared/protocol-version' import { showLocalBaseRefUpdateSuggestionToast } from '@/components/sidebar/local-base-ref-suggestion-toast' import { requestWorktreeBaseFallbackNotice } from '@/components/worktree-base-fallback-notice' import { showLocalBaseRefRefreshToast } from './local-base-ref-refresh-toast' -import { settingsForRepoOwner } from '../listing/worktree-owner-settings' import { applyCreatedWorktree } from './created-worktree-state-merge' import { isRuntimeLineageParentMissingError } from '../listing/runtime-worktree-rpc-errors' import { @@ -35,7 +35,7 @@ import { type WorktreeCreateParentPick } from './worktree-create-parent-pick' -type RuntimeTarget = ReturnType +type RuntimeTarget = RuntimeClientTarget type CreateAttemptOutcome = { result: CreateWorktreeResult @@ -180,7 +180,10 @@ export function createCreateWorktree( // Direct SSH still uses desktop IPC; paired runtimes use their captured environment. const target = options?.executionHostId ? (runtimeTargetForExecutionHostId(options.executionHostId) ?? { kind: 'local' as const }) - : getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId)) + : runtimeTargetForRepoOwner(get(), repoId) + if (!target) { + throw new Error('The project host is unresolved. Choose a host and retry.') + } const creationHostId = adoptFromEndpoint(target, { kind: 'createdWorktree', requestedHostId: options?.executionHostId, diff --git a/src/renderer/src/store/slices/worktrees/create/prefetch-worktree-create-base.ts b/src/renderer/src/store/slices/worktrees/create/prefetch-worktree-create-base.ts index 721a7e6d6be..f3e05377bce 100644 --- a/src/renderer/src/store/slices/worktrees/create/prefetch-worktree-create-base.ts +++ b/src/renderer/src/store/slices/worktrees/create/prefetch-worktree-create-base.ts @@ -1,7 +1,7 @@ import type { WorktreeSlice } from '../../worktree-helpers' import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' -import { settingsForRepoOwner } from '../listing/worktree-owner-settings' +import { callRuntimeRpc } from '../../../../runtime/runtime-rpc-client' +import { runtimeTargetForRepoOwner } from '@/lib/repo-runtime-owner' export function createPrefetchWorktreeCreateBase( _set: WorktreeSliceSet, @@ -9,7 +9,10 @@ export function createPrefetchWorktreeCreateBase( ): WorktreeSlice['prefetchWorktreeCreateBase'] { return async (repoId, baseBranch) => { try { - const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId)) + const target = runtimeTargetForRepoOwner(get(), repoId) + if (!target) { + return + } if (target.kind === 'local') { await window.api.worktrees.prefetchCreateBase({ repoId, diff --git a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-provider-request.ts b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-provider-request.ts index 4f13b4a6105..b8a18357029 100644 --- a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-provider-request.ts +++ b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-provider-request.ts @@ -1,8 +1,14 @@ -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' +import { callRuntimeRpc } from '../../../../runtime/runtime-rpc-client' +import { + runtimeTargetForOwnerHostId, + type RuntimeClientTarget +} from '../../../../runtime/runtime-client-target' import { getEnvironmentSshStateGeneration } from '../../runtime-environment-ssh' import { getRuntimeEnvironmentConnectionGeneration } from '../../runtime-status' -import { LOCAL_EXECUTION_HOST_ID } from '../../../../../../shared/execution-host' -import type { AppState } from '../../../types' +import { + LOCAL_EXECUTION_HOST_ID, + type ExecutionHostId +} from '../../../../../../shared/execution-host' import type { DetectedWorktreeListResult } from '../../../../../../shared/worktree/types' import type { RuntimeWorktreeListResult } from '../../../../../../shared/runtime-types' import type { @@ -18,15 +24,20 @@ import { isRuntimeMethodNotFoundError } from './runtime-worktree-rpc-errors' import { toLegacyDetectedWorktreeResult } from './worktree-host-ownership' import { isWorktreeScanFailureKind } from '../../../../../../shared/worktree-scan-failure' +/** The listing host is the one the caller asked about, never the focused one. */ +export function detectedWorktreeTarget(executionHostId: ExecutionHostId): RuntimeClientTarget { + const target = runtimeTargetForOwnerHostId(executionHostId) + if (!target) { + throw new Error('The workspace host is unresolved.') + } + return target +} + export async function listDetectedWorktreesForRepo( - settings: AppState['settings'], + target: Extract, repoId: string, options: BackgroundRuntimeRefreshOptions = {} ): Promise { - const target = getActiveRuntimeTarget(settings) - if (target.kind === 'local') { - throw new Error('Local detected-worktree reads require a provider lease') - } try { return await callRuntimeRpc( target, @@ -55,11 +66,10 @@ export async function listDetectedWorktreesForRepo( } export function detectedWorktreeRefreshKey( - settings: AppState['settings'], repoId: string, options: DetectedWorktreeRefreshOptions ): string { - const target = getActiveRuntimeTarget(settings) + const target = detectedWorktreeTarget(options.executionHostId) const targetKey = target.kind === 'local' ? 'local' : `runtime:${target.environmentId}` const parts = [ repoId, diff --git a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-refresh.ts b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-refresh.ts index 7024140e726..471a0370fa0 100644 --- a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-refresh.ts +++ b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-refresh.ts @@ -1,4 +1,3 @@ -import { getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' import { getEnvironmentSshStateGeneration } from '../../runtime-environment-ssh' import { getRuntimeEnvironmentConnectionGeneration } from '../../runtime-status' import { @@ -10,7 +9,6 @@ import { parseExecutionHostId, type ExecutionHostId } from '../../../../../../shared/execution-host' -import type { AppState } from '../../../types' import type { DetectedWorktreeListResult } from '../../../../../../shared/worktree/types' import type { HostQualifiedDetectedWorktreeResult, @@ -25,6 +23,7 @@ import { teardownMissingWorktreeTerminalsBestEffort } from '../teardown/missing- import { directSshAuthorityIsComplete } from './direct-ssh-authority' import { detectedWorktreeRefreshKey, + detectedWorktreeTarget, isDetectedWorktreeListResult, listDetectedWorktreesForRepo, startDetectedWorktreeProviderRequest @@ -54,7 +53,6 @@ export const detectedWorktreeRefreshLeaseRegistry = createDetectedWorktreeRefres }) export function acquireDetectedWorktreeRefreshLeaseForRepo( - settings: AppState['settings'], repoId: string, options: DetectedWorktreeRefreshOptions ): DetectedWorktreeRefreshLease { @@ -62,7 +60,7 @@ export function acquireDetectedWorktreeRefreshLeaseForRepo( if (!parsedHost || parsedHost.kind === 'runtime') { throw new Error('Provider leases require a local or direct SSH execution host') } - const publicKey = detectedWorktreeRefreshKey(settings, repoId, options) + const publicKey = detectedWorktreeRefreshKey(repoId, options) if (parsedHost.kind === 'local') { return detectedWorktreeRefreshLeaseRegistry.acquire(publicKey, { repoId, @@ -143,19 +141,18 @@ export function normalizeNotAdmittedProviderResult( } async function listDetectedWorktreesForRuntimeRepoOnce( - settings: AppState['settings'], repoId: string, options: DetectedWorktreeRefreshOptions, environmentId: string ): Promise { // Why recomputed per attempt: the key embeds both generations, so a retry after a // reconnect must not join the superseded connection's in-flight scan. - const key = detectedWorktreeRefreshKey(settings, repoId, options) + const key = detectedWorktreeRefreshKey(repoId, options) const connectionGeneration = getEnvironmentSshStateGeneration(environmentId) const runtimeConnectionGeneration = getRuntimeEnvironmentConnectionGeneration(environmentId) let refresh = runtimeDetectedWorktreeRefreshesInFlight.get(key) if (!refresh) { - refresh = listDetectedWorktreesForRepo(settings, repoId, { + refresh = listDetectedWorktreesForRepo({ kind: 'environment', environmentId }, repoId, { reuseRecentCompatibilityFailure: options.reuseRecentCompatibilityFailure }) runtimeDetectedWorktreeRefreshesInFlight.set(key, refresh) @@ -175,7 +172,7 @@ async function listDetectedWorktreesForRuntimeRepoOnce( // not stop terminals either. if (!options.isStaleCatalogPublication?.(result)) { await teardownMissingWorktreeTerminalsBestEffort( - settings, + { kind: 'environment', environmentId }, repoId, options.connectionId, options.knownWorktreeIds, @@ -200,20 +197,14 @@ async function listDetectedWorktreesForRuntimeRepoOnce( } export async function listDetectedWorktreesForRepoCoalesced( - settings: AppState['settings'], repoId: string, options: DetectedWorktreeRefreshOptions ): Promise { - const target = getActiveRuntimeTarget(settings) + const target = detectedWorktreeTarget(options.executionHostId) if (target.kind === 'environment') { for (let attempt = 0; ; attempt += 1) { try { - return await listDetectedWorktreesForRuntimeRepoOnce( - settings, - repoId, - options, - target.environmentId - ) + return await listDetectedWorktreesForRuntimeRepoOnce(repoId, options, target.environmentId) } catch (err) { // Why re-read instead of surfacing: the fence proves this answer predates the // current connection, not that the repo has no worktrees. Callers drop the repo @@ -226,7 +217,7 @@ export async function listDetectedWorktreesForRepoCoalesced( } } - const lease = acquireDetectedWorktreeRefreshLeaseForRepo(settings, repoId, options) + const lease = acquireDetectedWorktreeRefreshLeaseForRepo(repoId, options) let providerResult: HostQualifiedDetectedWorktreeResult try { providerResult = await lease.result @@ -258,7 +249,7 @@ export async function listDetectedWorktreesForRepoCoalesced( } if (!options.isStaleCatalogPublication?.(providerResult.result)) { await teardownMissingWorktreeTerminalsBestEffort( - settings, + target, repoId, options.connectionId, options.knownWorktreeIds, diff --git a/src/renderer/src/store/slices/worktrees/listing/fetch-all-worktrees.ts b/src/renderer/src/store/slices/worktrees/listing/fetch-all-worktrees.ts index 92aa9b42e3a..021d0642db7 100644 --- a/src/renderer/src/store/slices/worktrees/listing/fetch-all-worktrees.ts +++ b/src/renderer/src/store/slices/worktrees/listing/fetch-all-worktrees.ts @@ -19,7 +19,6 @@ import { fetchKnownSshWorktreesForRepo } from './known-ssh-worktree-fetch' import { mergeFetchedWorktrees } from './fetched-worktree-merge' import { notifyRuntimeScopeForbiddenIfNeeded } from './runtime-scope-forbidden-toast' import { mapReposForWorktreeRefresh } from './worktree-refresh-pool' -import { settingsForKnownRepoOwner } from './worktree-owner-settings' export function createFetchAllWorktrees( set: WorktreeSliceSet, @@ -44,7 +43,6 @@ export function createFetchAllWorktrees( const requestStartedWorktrees = requestStartedState.worktreesByRepo[r.id] const hostId = getRepoExecutionHostId(r) const setup = getProjectHostSetupForRepoHost(requestStartedState, r.id, hostId) - const settings = settingsForKnownRepoOwner(requestStartedState.settings, r) const parsedHost = parseExecutionHostId(hostId) const directSshAuthority = parsedHost?.kind === 'ssh' @@ -54,7 +52,7 @@ export function createFetchAllWorktrees( await fetchKnownSshWorktreesForRepo(set, r.id, parsedHost.id) return } - const refresh = await listDetectedWorktreesForRepoCoalesced(settings, r.id, { + const refresh = await listDetectedWorktreesForRepoCoalesced(r.id, { executionHostId: hostId, reuseRecentCompatibilityFailure: true, directSshAuthority, @@ -113,19 +111,15 @@ export function createFetchAllWorktrees( await fetchKnownSshWorktreesForRepo(set, r.id, parsedHost.id) return { repoId: r.id, ok: false as const } } - const refresh = await listDetectedWorktreesForRepoCoalesced( - settingsForKnownRepoOwner(requestStartedState.settings, r), - r.id, - { - executionHostId: hostId, - reuseRecentCompatibilityFailure: true, - directSshAuthority, - connectionId: r.connectionId, - knownWorktreeIds: getKnownWorktreeIdsForPurge(requestStartedState, r.id, hostId), - isStaleCatalogPublication: (result) => - isStaleWorktreeCatalogPublication(get(), r.id, hostId, result.catalogVersion) - } - ) + const refresh = await listDetectedWorktreesForRepoCoalesced(r.id, { + executionHostId: hostId, + reuseRecentCompatibilityFailure: true, + directSshAuthority, + connectionId: r.connectionId, + knownWorktreeIds: getKnownWorktreeIdsForPurge(requestStartedState, r.id, hostId), + isStaleCatalogPublication: (result) => + isStaleWorktreeCatalogPublication(get(), r.id, hostId, result.catalogVersion) + }) if (refresh.status !== 'admitted') { return { repoId: r.id, ok: false as const } } diff --git a/src/renderer/src/store/slices/worktrees/listing/fetch-detected-worktrees.ts b/src/renderer/src/store/slices/worktrees/listing/fetch-detected-worktrees.ts index 3091c69faa8..9d574868807 100644 --- a/src/renderer/src/store/slices/worktrees/listing/fetch-detected-worktrees.ts +++ b/src/renderer/src/store/slices/worktrees/listing/fetch-detected-worktrees.ts @@ -19,7 +19,6 @@ import { } from './worktree-host-ownership' import { fetchKnownSshWorktreesForRepo } from './known-ssh-worktree-fetch' import { notifyRuntimeScopeForbiddenIfNeeded } from './runtime-scope-forbidden-toast' -import { settingsForRepoOwner } from './worktree-owner-settings' export function createFetchDetectedWorktrees( set: WorktreeSliceSet, @@ -46,18 +45,14 @@ export function createFetchDetectedWorktrees( await fetchKnownSshWorktreesForRepo(set, repoId, parsedHost.id) return null } - const refresh = await listDetectedWorktreesForRepoCoalesced( - settingsForRepoOwner(ownerState, repoId, hostId), - repoId, - { - executionHostId: hostId, - directSshAuthority, - connectionId: repoOwner?.connectionId, - knownWorktreeIds: getKnownWorktreeIdsForPurge(ownerState, repoId, hostId), - isStaleCatalogPublication: (result) => - isStaleWorktreeCatalogPublication(get(), repoId, hostId, result.catalogVersion) - } - ) + const refresh = await listDetectedWorktreesForRepoCoalesced(repoId, { + executionHostId: hostId, + directSshAuthority, + connectionId: repoOwner?.connectionId, + knownWorktreeIds: getKnownWorktreeIdsForPurge(ownerState, repoId, hostId), + isStaleCatalogPublication: (result) => + isStaleWorktreeCatalogPublication(get(), repoId, hostId, result.catalogVersion) + }) if (refresh.status !== 'admitted') { return null } diff --git a/src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts b/src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts index 3c2c91877ec..d9a5f50cdc0 100644 --- a/src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts +++ b/src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts @@ -25,7 +25,6 @@ import { fetchKnownSshWorktreesForRepo } from './known-ssh-worktree-fetch' import { mergeFetchedWorktrees } from './fetched-worktree-merge' import { notifyRuntimeScopeForbiddenIfNeeded } from './runtime-scope-forbidden-toast' import { refreshRemoteWorktreeLineageBestEffort } from '../metadata/worktree-lineage-refresh' -import { settingsForRepoOwner } from './worktree-owner-settings' export function createFetchWorktrees( set: WorktreeSliceSet, @@ -71,16 +70,6 @@ export function createFetchWorktrees( hostId, settings: ownerState.settings }) - const ownerSettings = settingsForRepoOwner( - ownerState, - repoId, - hostId, - ownerWasMissingAtStart && (useLocalOwner || options?.executionHostId !== undefined) - ) - const settings = - useLocalOwner && ownerSettings?.activeRuntimeEnvironmentId - ? { ...ownerSettings, activeRuntimeEnvironmentId: null } - : ownerSettings const parsedHost = parseExecutionHostId(hostId) const directSshAuthority = parsedHost?.kind === 'ssh' @@ -94,7 +83,7 @@ export function createFetchWorktrees( } return false } - const refresh = await listDetectedWorktreesForRepoCoalesced(settings, repoId, { + const refresh = await listDetectedWorktreesForRepoCoalesced(repoId, { executionHostId: hostId, requireAuthoritative: options?.requireAuthoritative, directSshAuthority, diff --git a/src/renderer/src/store/slices/worktrees/listing/known-ssh-worktree-fetch.ts b/src/renderer/src/store/slices/worktrees/listing/known-ssh-worktree-fetch.ts index 851197fbbf4..68b478e9f26 100644 --- a/src/renderer/src/store/slices/worktrees/listing/known-ssh-worktree-fetch.ts +++ b/src/renderer/src/store/slices/worktrees/listing/known-ssh-worktree-fetch.ts @@ -19,7 +19,6 @@ import { worktreeHostMatchOptions, worktreeMatchesHost } from './worktree-host-ownership' -import { settingsForRepoOwner } from './worktree-owner-settings' import { getCurrentDirectSshAuthority } from './direct-ssh-authority' import { acquireDetectedWorktreeRefreshLeaseForRepo, @@ -210,17 +209,12 @@ export function acquireDirectSshDetectedWorktreeRefresh( request.repoId, request.executionHostId ) - const settings = settingsForRepoOwner( - requestStartedState, - request.repoId, - request.executionHostId - ) const options: DetectedWorktreeRefreshOptions = { executionHostId: request.executionHostId, directSshAuthority: request.authority, requireAuthoritative: request.requireAuthoritative } - const lease = acquireDetectedWorktreeRefreshLeaseForRepo(settings, request.repoId, options) + const lease = acquireDetectedWorktreeRefreshLeaseForRepo(request.repoId, options) let mergedResult: HostQualifiedDetectedWorktreeResult | undefined return { diff --git a/src/renderer/src/store/slices/worktrees/teardown/force-delete-preserved-branch.ts b/src/renderer/src/store/slices/worktrees/teardown/force-delete-preserved-branch.ts index 18b66df9f46..0c113d9eaa0 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/force-delete-preserved-branch.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/force-delete-preserved-branch.ts @@ -3,13 +3,14 @@ import { parseExecutionHostId } from '../../../../../../shared/execution-host' import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' +import { callRuntimeRpc } from '../../../../runtime/runtime-rpc-client' +import { runtimeTargetForWorkspaceOwner } from '@/lib/resolve-owner' import { toRuntimeWorktreeSelector } from '../../../../runtime/runtime-worktree-selector' import { preservedBranchCleanupKey } from '../../../../../../shared/preserved-branch-cleanup' import type { ForceDeleteWorktreeBranchResult } from '../../../../../../shared/worktree/create-types' import type { PreservedBranchCleanup } from '../../../../../../shared/preserved-branch-cleanup' import { preservedBranchRuntimeTargetByCleanupKey } from './preserved-branch-cleanup-target' -import { settingsForWorktreeOwner } from '../listing/worktree-owner-settings' +import { WORKTREE_REMOVAL_AMBIGUOUS_ERROR } from '../listing/worktree-slice-constants' export function createForceDeletePreservedBranch( _set: WorktreeSliceSet, @@ -59,8 +60,10 @@ export function createForceDeletePreservedBranch( const cleanupHostId = options?.hostId ?? retainedTarget?.cleanup.hostId // Why: the removed row no longer records its nested HUB owner, so retain the deletion-time route. const target = - retainedTarget?.target ?? - getActiveRuntimeTarget(settingsForWorktreeOwner(get(), worktreeId)) + retainedTarget?.target ?? runtimeTargetForWorkspaceOwner(get(), { workspaceId: worktreeId }) + if (!target) { + throw new Error(WORKTREE_REMOVAL_AMBIGUOUS_ERROR) + } const parsedCleanupHost = parseExecutionHostId(cleanupHostId) const effectiveHostId = target.kind === 'environment' && diff --git a/src/renderer/src/store/slices/worktrees/teardown/missing-worktree-terminal-teardown.ts b/src/renderer/src/store/slices/worktrees/teardown/missing-worktree-terminal-teardown.ts index fef22fcdec9..30004fc06fe 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/missing-worktree-terminal-teardown.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/missing-worktree-terminal-teardown.ts @@ -1,5 +1,4 @@ -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' -import type { AppState } from '../../../types' +import { callRuntimeRpc, type RuntimeClientTarget } from '../../../../runtime/runtime-rpc-client' import type { DetectedWorktreeListResult } from '../../../../../../shared/worktree/types' import { isRuntimeMethodNotFoundError } from '../listing/runtime-worktree-rpc-errors' @@ -9,7 +8,8 @@ import { isRuntimeMethodNotFoundError } from '../listing/runtime-worktree-rpc-er const missingWorktreeTeardownsInFlight = new Map>() export async function teardownMissingWorktreeTerminalsBestEffort( - settings: AppState['settings'], + /** The host whose listing found the worktrees missing. */ + target: RuntimeClientTarget, repoId: string, connectionId: string | null | undefined, // Why: refreshes that never purge omit this; an absent snapshot means @@ -25,7 +25,6 @@ export async function teardownMissingWorktreeTerminalsBestEffort( if (missingIds.length === 0) { return } - const target = getActiveRuntimeTarget(settings) const normalizedConnectionId = connectionId ?? null const key = [ target.kind === 'local' ? 'local' : `runtime:${target.environmentId}`, diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts index 1ad559bfe42..5c7f0dc5f21 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts @@ -4,7 +4,7 @@ import type { RemoveWorktreeResult } from '../../../../../../shared/worktree/cre import { getRepoIdFromWorktreeId } from '../../worktree-helpers' import { parseExecutionHostId } from '../../../../../../shared/execution-host' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' -import { getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' +import { runtimeTargetForOwnerEnvironment } from '../../../../runtime/runtime-client-target' import { forgetHugeRepoWarningDismissalsForWorktrees } from '@/lib/source-control-huge-repo-warning-dismissals' import { forgetWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import { readIpcErrorDetail } from '@/lib/ipc-error' @@ -12,8 +12,7 @@ import { isArchiveHookRemovalError } from '../../../../../../shared/worktree/arc import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast' import { resolveWorktreeOperationRouteResult, - resolveWorktreeOperationRouteResultForHost, - settingsForWorktreeOperationRoute + resolveWorktreeOperationRouteResultForHost } from '@/lib/worktree-operation-route' import { beginHostQualifiedRemoval, @@ -115,13 +114,7 @@ export function createRemoveWorktree( removalGenerationGuard?.assertCurrent() } // Why: forget-local clears Orca's records via local IPC regardless of host — the remote is gone or unreachable. - const target = getActiveRuntimeTarget( - removalRoute - ? settingsForWorktreeOperationRoute(get().settings, removalRoute) - : get().settings - ? { ...get().settings, activeRuntimeEnvironmentId: null } - : { activeRuntimeEnvironmentId: null } - ) + const target = runtimeTargetForOwnerEnvironment(removalRoute?.runtimeEnvironmentId ?? null) const unprovableRemoteRouting = policy.forgetLocalOnly ? null : refuseUnprovableRemoteHostRouting(get, worktreeId, target.kind)