refactor(worktrees): worktree create, listing, removal and repo hook/ref reads route by their owner (#27298)

* refactor(worktrees): worktree create, listing, removal and repo hook/ref reads route by their owner

Worktree create, prefetch, detected listings and missing-terminal teardown,
preserved-branch cleanup and removal follow the repo or workspace owner.
Repo hook and base-ref reads take the repo's host id instead of settings.
Composer, settings panes and the setup guide pass the selected repo's host.

* test(hooks): build the duplicate-row state without a cast

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-10 14:28:22 -07:00
committed by GitHub
co-authored by m4air
parent 26ee9bbfab
commit e7d7c5bbeb
57 changed files with 485 additions and 508 deletions
+9 -36
View File
@@ -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
-15
View File
@@ -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
@@ -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'
@@ -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
@@ -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()
})
})
@@ -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<void>
}): 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 = (
<>
@@ -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,
@@ -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<string | null>(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
])
@@ -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
})
@@ -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<SetStateAction<string>>
@@ -22,17 +22,9 @@ type CreatePullRequestBaseRefDiscoveryOptions = {
setBaseSearchError: Dispatch<SetStateAction<string | null>>
}
// 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
}
@@ -13,6 +13,7 @@ vi.mock('@/runtime/runtime-repo-client', () => ({
}))
const { useCreatePullRequestDialogFields } = await import('./useCreatePullRequestDialogFields')
const { useAppStore } = await import('@/store')
type DialogFields = ReturnType<typeof useCreatePullRequestDialogFields>
@@ -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', () => {
@@ -198,7 +198,6 @@ export function useCreatePullRequestDialogFields({
const repoDefaultBaseRef = useCreatePullRequestBaseRefDiscovery({
open,
repoId,
target,
base,
baseQuery,
setBase,
@@ -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<void> => {
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
@@ -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 }
)
@@ -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<GhAccountBindingInventory | null>(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(
@@ -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)
})
})
@@ -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
@@ -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(() => {
@@ -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<void> => {
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,
@@ -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
}
@@ -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<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
orderedGitRepos: readonly Pick<Repo, 'id' | 'hookSettings'>[]
orderedGitRepos: readonly Pick<Repo, 'id' | 'hookSettings' | 'connectionId' | 'executionHostId'>[]
): 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
}))
@@ -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 }
@@ -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<void> {
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
}
@@ -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 = {
@@ -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,
@@ -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,
@@ -50,8 +50,7 @@ describe('useComposerProviderRuntimeSync', () => {
selectedRepoExecutionHostId: 'local',
selectedRepoHookContextKey: 'local:repo',
selectedRepoIsGit: true,
selectedRepoSettings: null,
selectedRepoSettingsRef: { current: null },
selectedRepoTarget: { kind: 'local' },
setCheckedHooksContextKey:
vi.fn<ComposerProviderRuntimeSyncInput['setCheckedHooksContextKey']>(),
setSelectedRepoSlug,
@@ -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<HookCheckResult> = checkRuntimeHooks(
selectedRepoSettingsRef.current,
targetRepoId,
selectedRepoExecutionHostId ?? undefined
// Why: the repo row names its host; an unknown one is inspected nowhere.
const promise: Promise<HookCheckResult> = (
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<GitHubRepositoryIdentity | null>(
@@ -264,7 +267,7 @@ export function useComposerProviderRuntimeSync(input: ComposerProviderRuntimeSyn
selectedRepo,
selectedRepoIsGit,
selectedRepoPath,
selectedRepoSettings,
selectedRepoTarget,
setSelectedRepoSlug
])
@@ -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,
@@ -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<GlobalSettings, 'activeRuntimeEnvironmentId'> | null
/** Transport to the selected repo's owner; `null` with no repo selected. */
selectedRepoTarget: RuntimeClientTarget | null
selectedRecipeRepoId: string | null
selectedRecipeRepoConnectionId: string | null
ephemeralVmsEnabled: boolean
@@ -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,
@@ -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' },
+27 -28
View File
@@ -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<ConfirmedRuntimeIssueCommand> {
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'
@@ -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',
@@ -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
@@ -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
@@ -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(
@@ -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({
@@ -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<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
hostId?: ExecutionHostId
): ReturnType<typeof getActiveRuntimeTarget> {
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<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
repoId: string,
hostId?: ExecutionHostId
hostId: ExecutionHostId,
repoId: string
): Promise<HookCheckResult> {
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<HookCheckResult>(
target,
@@ -49,13 +46,12 @@ export async function checkRuntimeHooks(
}
export async function inspectRuntimeSetupScriptImports(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
repoId: string,
hostId?: ExecutionHostId
hostId: ExecutionHostId,
repoId: string
): Promise<SetupScriptImportCandidate[]> {
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<SetupScriptImportCandidate[]>(
target,
@@ -66,13 +62,12 @@ export async function inspectRuntimeSetupScriptImports(
}
export async function readRuntimeIssueCommand(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
repoId: string,
hostId?: ExecutionHostId
hostId: ExecutionHostId,
repoId: string
): Promise<IssueCommandReadResult> {
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<IssueCommandReadResult>(
target,
@@ -83,14 +78,13 @@ export async function readRuntimeIssueCommand(
}
export async function writeRuntimeIssueCommand(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
hostId: ExecutionHostId,
repoId: string,
content: string,
hostId?: ExecutionHostId
content: string
): Promise<void> {
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(
@@ -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',
+23 -17
View File
@@ -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<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
repoId: string,
hostId?: ExecutionHostId
hostId: ExecutionHostId,
repoId: string
): Promise<RuntimeRepoBaseRefDefault> {
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<RuntimeRepoBaseRefDefault>(
target,
@@ -28,18 +36,17 @@ export async function getRuntimeRepoBaseRefDefault(
}
export async function searchRuntimeRepoBaseRefs(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
hostId: ExecutionHostId,
repoId: string,
query: string,
limit: number,
hostId?: ExecutionHostId
limit: number
): Promise<string[]> {
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<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
hostId: ExecutionHostId,
repoId: string,
query: string,
limit: number,
hostId?: ExecutionHostId
limit: number
): Promise<BaseRefSearchResult[]> {
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<{
@@ -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<AppState>)
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<AppState>)
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<typeof createTestStore>, hostId: ExecutionHostId): void {
store.setState({ repos: [{ ...TEST_REPO, executionHostId: hostId }] })
}
function createRepoTestStore(): ReturnType<typeof createTestStore> {
const store = createTestStore()
seedRepoHost(store, 'local')
return store
}
@@ -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<AppState>)
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<typeof createTestStore>, hostId: ExecutionHostId): void {
store.setState({ repos: [{ ...TEST_REPO, executionHostId: hostId }] })
}
function createRepoTestStore(): ReturnType<typeof createTestStore> {
const store = createTestStore()
seedRepoHost(store, 'local')
return store
}
@@ -633,6 +633,10 @@ describe('fetchWorktrees', () => {
{ id: 'repo2', path: '/r2', displayName: 'R2', badgeColor: '#000', addedAt: 0 }
]
} as Partial<AppState>)
// 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',
@@ -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<AppState>)
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<AppState>)
seedRepoHost(store, 'runtime:env-1')
const createWorktree = store.getState().createWorktree
const args: Parameters<typeof createWorktree> = ['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<AppState>)
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<AppState>)
seedRepoHost(store, 'runtime:env-1')
const createWorktree = store.getState().createWorktree
const args: Parameters<typeof createWorktree> = ['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<AppState>)
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<AppState>)
seedRepoHost(store, 'runtime:env-1')
const createWorktree = store.getState().createWorktree
const args: Parameters<typeof createWorktree> = ['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<AppState>)
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<typeof createTestStore>, hostId: ExecutionHostId): void {
store.setState({ repos: [{ ...TEST_REPO, executionHostId: hostId }] })
}
function createRepoTestStore(): ReturnType<typeof createTestStore> {
const store = createTestStore()
seedRepoHost(store, 'local')
return store
}
@@ -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: {
@@ -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<typeof getActiveRuntimeTarget>
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,
@@ -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,
@@ -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<RuntimeClientTarget, { kind: 'environment' }>,
repoId: string,
options: BackgroundRuntimeRefreshOptions = {}
): Promise<DetectedWorktreeListResult> {
const target = getActiveRuntimeTarget(settings)
if (target.kind === 'local') {
throw new Error('Local detected-worktree reads require a provider lease')
}
try {
return await callRuntimeRpc<DetectedWorktreeListResult>(
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,
@@ -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<DetectedWorktreeRefreshOutcome> {
// 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<DetectedWorktreeRefreshOutcome> {
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,
@@ -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 }
}
@@ -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
}
@@ -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,
@@ -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 {
@@ -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' &&
@@ -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<string, Promise<void>>()
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}`,
@@ -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)