fix(renderer): a twin project refuses instead of following the Active Server focus, and a no-row listing is this app's (#31119)

* fix(renderer): a twin repo id refuses instead of following the Active Server focus, and a listing for a repo with no row is this app's

findRepoOwner no longer lets focus pick between same-id repo rows: runtimeTargetForRepoOwner refuses a
twin, as resolveOwner does, and getRuntimeEnvironmentIdForRepo names no server for one. repoHostId
stamps and routes a repo with no row as this app (or a web client's only server), never the focused
server.

* fix(renderer): a fork names its source's host, and GitHub routing keeps its twin rule until it can refuse

* fix(renderer): a fork captures its source's server transport, and a GitHub edit on a twin refuses instead of using this computer

* fix(automations): a new-per-run workspace is created on the run's captured host

* fix(automations): pass only a captured run host, so a twin without one refuses

* test: stamp the server repo row through a store helper, prune the focus-read baseline
This commit is contained in:
Neil
2026-10-20 05:38:41 -07:00
committed by GitHub
parent 49a2203b0e
commit cf6582caaa
14 changed files with 213 additions and 72 deletions
+1 -1
View File
@@ -399,7 +399,7 @@
1 src/renderer/src/store/slices/worktrees/create/created-worktree-state-merge.ts
2 src/renderer/src/store/slices/worktrees/listing/fetch-detected-worktrees.ts
2 src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts
2 src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts
1 src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts
2 src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts
1 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-actions.ts
3 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-host-merge.ts
@@ -50,13 +50,13 @@ describe('automation hooks cache key', () => {
['missing row follows focus', [], 'focused', 'focused:repo-1'],
['missing row, local focus', [], null, 'local:repo-1'],
[
'duplicates fall back to focus',
'duplicates name no server, focus or not',
[
{ id: 'repo-1', executionHostId: 'local' },
{ id: 'repo-1', executionHostId: 'runtime:owner' }
],
'other',
'other:repo-1'
'local:repo-1'
]
])('%s', (_name, repos, focused, expected) => {
expect(cacheKeyFor(repos, focused)).toBe(expected)
@@ -6,7 +6,7 @@ import {
getGitHubRuntimeRepoId,
getGitHubSourceRuntimeHost
} from '@/lib/github-source-runtime-context'
import { runtimeTargetForRepoOwner } from '@/lib/repo-runtime-owner'
import { isTwinRepo, runtimeTargetForRepoOwner } from '@/lib/repo-runtime-owner'
import { taskSourceRuntimeTarget } from '@/lib/task-source-runtime-target'
import { projectViewCacheKeyTarget } from '@/store/github/cache-identity'
import type { GitHubWorkItemProjectOrigin } from '@/components/github/github-work-item-identity'
@@ -23,10 +23,15 @@ export function getGitHubMutationTarget(
projectOrigin: GitHubWorkItemProjectOrigin | undefined
): RuntimeClientTarget {
// Why: even slug-addressed project-origin mutations must run on the backing repo's owner host when its id is known.
const owner = runtimeTargetForRepoOwner(useAppStore.getState(), repoId)
const state = useAppStore.getState()
const owner = runtimeTargetForRepoOwner(state, repoId)
if (owner) {
return owner
}
// Why: a twin is tracked on two hosts; only the row's own source may stand in for its owner.
if (isTwinRepo(state, repoId) && !projectOrigin) {
throw new Error(`No single host owns project ${repoId}.`)
}
// Why: an untracked repo's Project row goes to the host that loaded the row; any other
// slug-addressed edit uses this computer's credentials, never the focused server.
return projectOrigin ? projectViewCacheKeyTarget(projectOrigin.cacheKey) : { kind: 'local' }
@@ -3,7 +3,11 @@ import { describe, expect, it, vi } from 'vitest'
const store = vi.hoisted(() => ({
state: {
settings: { activeRuntimeEnvironmentId: 'focused-runtime' },
repos: [{ id: 'tracked', connectionId: null, executionHostId: 'runtime:owner-runtime' }]
repos: [
{ id: 'tracked', connectionId: null, executionHostId: 'runtime:owner-runtime' },
{ id: 'twin', connectionId: null, executionHostId: 'runtime:focused-runtime' },
{ id: 'twin', connectionId: null, executionHostId: 'runtime:other-runtime' }
]
}
}))
@@ -36,6 +40,14 @@ describe('getGitHubMutationTarget', () => {
})
})
it('refuses a twin rather than taking the focused copy or this computer', () => {
expect(() => getGitHubMutationTarget('twin', undefined)).toThrow('No single host owns project')
expect(getGitHubMutationTarget('twin', projectOrigin)).toEqual({
kind: 'environment',
environmentId: 'board-runtime'
})
})
it("uses this computer's credentials for other slug edits with no known repo", () => {
expect(getGitHubMutationTarget(null, undefined)).toEqual({ kind: 'local' })
})
@@ -25,9 +25,17 @@ const store = {
settings: {} as {
localWindowsRuntimeDefault?: { kind: 'windows-host' } | { kind: 'wsl'; distro: string }
},
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an empty test catalog; rows are typed below.
worktreesByRepo: {} as Record<
string,
{ id: string; repoId: string; path?: string; projectId?: string }[]
{
id: string
repoId: string
path?: string
projectId?: string
hostId?: 'local' | 'ssh:box'
runtimeOwnerEnvironmentId?: string
}[]
>,
agentStatusByPaneKey: {} as Record<string, { agentType?: string }>,
tabsByWorktree: {} as Record<string, { id: string; launchAgent?: string | null }[]>,
@@ -79,7 +87,9 @@ describe('forkAgentSessionFromPane', () => {
store.repos = [{ id: 'repo-1', kind: 'git' }]
store.settings = { localWindowsRuntimeDefault: { kind: 'windows-host' } }
store.worktreesByRepo = {
'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: 'C:\\repo', projectId: 'repo-1' }]
'repo-1': [
{ id: 'wt-1', repoId: 'repo-1', path: 'C:\\repo', projectId: 'repo-1', hostId: 'local' }
]
}
store.agentStatusByPaneKey = {}
store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] }
@@ -139,7 +149,9 @@ describe('forkAgentSessionFromPane', () => {
undefined,
undefined,
undefined,
'codex'
'codex',
...Array(14).fill(undefined),
{ executionHostId: 'local' }
)
expect(mockLaunchAgentInNewTab).toHaveBeenCalledWith(
@@ -383,7 +395,9 @@ describe('forkAgentSessionFromPane', () => {
undefined,
undefined,
undefined,
undefined
undefined,
...Array(14).fill(undefined),
{ executionHostId: 'local' }
)
expect(mockLaunchAgentInNewTab).not.toHaveBeenCalled()
expect(mockActivateAndRevealWorktree).toHaveBeenCalledWith('wt-fork', {
@@ -398,6 +412,24 @@ describe('forkAgentSessionFromPane', () => {
expect(pane.terminal.focus).toHaveBeenCalled()
})
it("creates the fork through the source's server, not its SSH place", async () => {
store.worktreesByRepo = {
'repo-1': [
{ id: 'wt-1', repoId: 'repo-1', hostId: 'ssh:box', runtimeOwnerEnvironmentId: 'env-1' }
]
}
const { forkAgentSessionFromPane } = await import('./terminal-agent-session-fork')
await forkAgentSessionFromPane({
pane: makePane('Assistant: plan'),
tabId: 'tab-1',
worktreeId: 'wt-1',
groupId: null
})
expect(mockCreateWorktree.mock.calls[0]?.at(-1)).toEqual({ executionHostId: 'runtime:env-1' })
})
it('keeps the fork dialog path open when the source workspace has no git branch', async () => {
store.getKnownWorktreeById.mockReturnValue({
id: 'wt-1',
@@ -6,6 +6,8 @@ import {
buildBoundedSessionTranscript
} from '@/lib/agent-session-fork-context'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { resolveOwner } from '@/lib/resolve-owner'
import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
import { useAppStore } from '@/store'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config'
@@ -173,6 +175,19 @@ export async function copyAgentSessionContextFromPane(pane: ManagedPane): Promis
}
}
/** The transport that owns the source: its server, else where this app reaches it (local or SSH). */
function forkCreationHostId(
state: Parameters<typeof resolveOwner>[0],
worktreeId: string
): ExecutionHostId | undefined {
const owner = resolveOwner(state, { workspaceId: worktreeId })
if (owner.kind !== 'resolved') {
return undefined
}
const { endpoint, at } = owner.owner
return endpoint.kind === 'environment' ? toRuntimeExecutionHostId(endpoint.environmentId) : at
}
export async function startAgentSessionFork(fork: PreparedAgentSessionFork): Promise<boolean> {
const store = useAppStore.getState()
const sourceWorktree = store.getKnownWorktreeById(fork.worktreeId)
@@ -211,7 +226,23 @@ export async function startAgentSessionFork(fork: PreparedAgentSessionFork): Pro
undefined,
undefined,
undefined,
fork.agent ?? undefined
fork.agent ?? undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
// Why: a project two hosts publish refuses a host-less create; the fork belongs on its source's host.
{ executionHostId: forkCreationHostId(store, fork.worktreeId) }
)
} catch (error) {
toast.error(
@@ -213,6 +213,8 @@ export async function prepareAutomationDispatchWorkspace(args: {
undefined,
undefined,
{
// Why: only a captured host; with none, a project two hosts publish refuses, never guesses.
executionHostId: parseExecutionHostId(automation.runContext?.hostId)?.id,
automationProvenanceRequest: {
automationId: automation.id,
automationRunId: run.id,
@@ -273,6 +273,19 @@ describe('useAutomationDispatchEvents setup launch', () => {
expect(mockLaunchAgentBackgroundSession).toHaveBeenCalled()
})
it('creates a new-per-run workspace on the captured run host, and names none without one', async () => {
await registerAndDispatch(makeAutomation({ runContext: { repoId: 'repo-1', hostId: 'local' } }))
expect(mockCreateWorktree.mock.calls[0]?.at(-1)).toEqual(
expect.objectContaining({ executionHostId: 'local' })
)
mockCreateWorktree.mockClear()
await registerAndDispatch()
expect(mockCreateWorktree.mock.calls[0]?.at(-1)).toEqual(
expect.objectContaining({ executionHostId: undefined })
)
})
it('does not stamp the created workspace with an empty agent-launch fallback', async () => {
await registerAndDispatch()
+17 -33
View File
@@ -54,36 +54,6 @@ describe('getRuntimeEnvironmentIdForRepo', () => {
)
).toBeNull()
})
it('uses the focused host row when duplicate repo ids exist', () => {
expect(
getRuntimeEnvironmentIdForRepo(
{
settings: { activeRuntimeEnvironmentId: 'owner-runtime' },
repos: [
{ id: 'repo-1', connectionId: null, executionHostId: 'local' },
{ id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' }
]
},
'repo-1'
)
).toBe('owner-runtime')
})
it('does not silently choose a duplicate repo row when the focused host does not match', () => {
expect(
getRuntimeEnvironmentIdForRepo(
{
settings: { activeRuntimeEnvironmentId: 'other-runtime' },
repos: [
{ id: 'repo-1', connectionId: null, executionHostId: 'local' },
{ id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' }
]
},
'repo-1'
)
).toBe('other-runtime')
})
})
type OwnerRow = NonNullable<RepoRuntimeOwnerState['repos']>[number]
@@ -105,9 +75,9 @@ describe('getRuntimeEnvironmentIdForRepo owner matrix', () => {
['unstamped row', [row(null)], 'focused', null],
['missing row', [], 'focused', 'focused'],
['duplicates, local focus', [row('local'), row('runtime:owner')], null, null],
['duplicates, matching focus', [row('local'), row('runtime:owner')], 'owner', 'owner'],
['duplicates, other focus', [row('local'), row('runtime:owner')], 'other', 'other'],
['duplicates, deleted focus', [row('runtime:a'), row('runtime:b')], 'deleted', 'deleted']
['duplicates, matching focus', [row('local'), row('runtime:owner')], 'owner', null],
['duplicates, other focus', [row('local'), row('runtime:owner')], 'other', null],
['duplicates, deleted focus', [row('runtime:a'), row('runtime:b')], 'deleted', null]
])('%s', (_name, repos, focused, expected) => {
const settings = { activeRuntimeEnvironmentId: focused }
expect(getRuntimeEnvironmentIdForRepo({ repos, settings }, 'repo-1')).toBe(expected)
@@ -238,6 +208,20 @@ describe('runtimeTargetForRepoOwner', () => {
).toEqual({ kind: 'local' })
})
it('refuses a twin rather than taking the focused copy', () => {
const repos = [
{ id: 'r', connectionId: null, executionHostId: 'local' as const },
{ id: 'r', connectionId: null, executionHostId: 'runtime:owner' as const }
]
expect(runtimeTargetForRepoOwner({ settings: focused, repos }, 'r')).toBeNull()
expect(
runtimeTargetForRepoOwner({ settings: { activeRuntimeEnvironmentId: 'owner' }, repos }, 'r')
).toBeNull()
expect(
runtimeTargetForRepoOwner({ settings: { activeRuntimeEnvironmentId: null }, repos }, 'r')
).toBeNull()
})
it('has no transport for an unknown repo', () => {
expect(runtimeTargetForRepoOwner({ settings: focused, repos: [] }, 'r')).toBeNull()
expect(runtimeTargetForRepoOwner({ settings: focused, repos: [] }, null)).toBeNull()
+26 -10
View File
@@ -13,29 +13,42 @@ export type RepoRuntimeOwnerState = {
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null
}
/** The only row with this id; a twin names no owner, as `resolveOwner` reads it, so callers refuse. */
function findRepoOwner(
state: RepoRuntimeOwnerState,
repoId: string
): Pick<Repo, 'id' | 'connectionId' | 'executionHostId'> | null {
const matchingRepos = state.repos?.filter((entry) => entry.id === repoId) ?? []
if (matchingRepos.length === 0) {
return null
}
if (matchingRepos.length === 1) {
return matchingRepos[0]
return matchingRepos.length === 1 ? matchingRepos[0] : null
}
/**
* GitHub routing's twin pick, kept until `getGitHubRepoRoutingTarget` can refuse: dropping it would
* send a twin on two servers to local IPC with this machine's credentials.
*/
function findFocusedTwinRepoOwner(
state: RepoRuntimeOwnerState,
repoId: string
): Pick<Repo, 'id' | 'connectionId' | 'executionHostId'> | null {
const matchingRepos = state.repos?.filter((entry) => entry.id === repoId) ?? []
if (matchingRepos.length < 2) {
return matchingRepos[0] ?? null
}
const focusedHostId = getSettingsFocusedExecutionHostId(state.settings)
const focusedMatches = matchingRepos.filter(
(entry) => getRepoExecutionHostId(entry) === focusedHostId
)
// Why: duplicate bare repo ids are only safe to route when focus selects one
// owner unambiguously; otherwise callers must avoid guessing a host.
return focusedMatches.length === 1 ? focusedMatches[0] : null
}
/** Whether two rows (hosts) publish this repo id, so no host-less caller may pick one. */
export function isTwinRepo(state: RepoRuntimeOwnerState, repoId: string | null | undefined) {
return (state.repos?.filter((entry) => entry.id === repoId).length ?? 0) > 1
}
/**
* Transport to a repo row's owner; a row with no owner stamp is local. `null` when no row, or no
* single row among duplicates, names the repo.
* single row among duplicates, names the repo — {@link isTwinRepo} tells the two apart.
*/
export function runtimeTargetForRepoOwner(
state: RepoRuntimeOwnerState,
@@ -58,7 +71,10 @@ export function getRuntimeEnvironmentIdForRepo(
const parsed = parseExecutionHostId(getRepoExecutionHostId(repo))
return parsed?.kind === 'runtime' ? parsed.environmentId : null
}
return state.settings?.activeRuntimeEnvironmentId?.trim() || null
// Why: only a repo with no row yet follows focus; a twin names no server rather than a guessed one.
return isTwinRepo(state, repoId)
? null
: state.settings?.activeRuntimeEnvironmentId?.trim() || null
}
// Why: PR mutations must not fall back to the globally focused runtime — a
@@ -71,7 +87,7 @@ export function getExplicitRuntimeOwnerEnvironmentId(
if (!repoId) {
return null
}
const repo = findRepoOwner(state, repoId)
const repo = findFocusedTwinRepoOwner(state, repoId)
const hasExplicitOwner = Boolean(repo?.executionHostId?.trim() || repo?.connectionId?.trim())
if (!repo || !hasExplicitOwner) {
return null
@@ -45,7 +45,18 @@ describe('fetchWorktrees', () => {
clearHugeRepoWarningDismissalsForTests()
})
it('stamps ownerless worktrees from an older active remote runtime before repos hydrate', async () => {
it('lists a repo with no row from this app, never the focused server', async () => {
const store = createTestStore()
store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never })
mockApi.worktrees.listDetected.mockResolvedValue(makeDetectedResult('repo1', []))
await store.getState().fetchWorktrees('repo1')
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
expect(mockApi.worktrees.listDetected).toHaveBeenCalled()
})
it('stamps ownerless worktrees from a named remote runtime before repos hydrate', async () => {
const store = createTestStore()
const remote = makeWorktree({
id: 'repo1::/remote/wt1',
@@ -61,7 +72,7 @@ describe('fetchWorktrees', () => {
_meta: { runtimeId: 'runtime-remote' }
})
await store.getState().fetchWorktrees('repo1')
await store.getState().fetchWorktrees('repo1', { executionHostId: 'runtime:env-1' })
const expected = {
...remote,
@@ -587,7 +598,7 @@ describe('fetchWorktrees', () => {
)
)
await store.getState().fetchWorktrees('repo1')
await store.getState().fetchWorktrees('repo1', { executionHostId: 'runtime:env-1' })
expect(store.getState().worktreesByRepo.repo1).toEqual([
{
@@ -4,7 +4,19 @@ import type { RuntimeEnvironmentCallRequest } from '../../runtime/runtime-compat
import { clearHugeRepoWarningDismissalsForTests } from '@/lib/source-control-huge-repo-warning-dismissals'
import { worktreeWorkspaceKey } from '../../../../shared/workspace-scope'
import { makeDetectedResult } from './worktrees-detected-listing-fixtures'
import { makeLineage, makeWorkspaceLineage, makeWorktree } from './worktrees-slice-test-fixtures'
import {
makeLineage,
makeWorkspaceLineage,
makeWorktree,
TEST_REPO
} from './worktrees-slice-test-fixtures'
// The client stamps a server's repo row on arrival; focus alone never names the owner.
function createRemoteRepoStore() {
const store = createTestStore()
store.setState({ repos: [{ ...TEST_REPO, executionHostId: 'runtime:env-1' }] })
return store
}
import {
createTestStore,
mockApi,
@@ -39,7 +51,7 @@ describe('fetchWorktrees', () => {
})
it('updates remote worktree records when only lineage changes', async () => {
const store = createTestStore()
const store = createRemoteRepoStore()
const initial = makeWorktree({
id: 'repo1::/remote/wt1',
repoId: 'repo1',
@@ -97,7 +109,7 @@ describe('fetchWorktrees', () => {
})
it('refreshes remote lineage when the worktree payload is otherwise unchanged', async () => {
const store = createTestStore()
const store = createRemoteRepoStore()
const worktree = makeWorktree({
id: 'repo1::/remote/wt1',
repoId: 'repo1',
@@ -143,7 +155,7 @@ describe('fetchWorktrees', () => {
})
it('keeps remote lineage map identity when a cloned payload is unchanged', async () => {
const store = createTestStore()
const store = createRemoteRepoStore()
const worktree = makeWorktree({
id: 'repo1::/remote/wt1',
repoId: 'repo1',
@@ -208,7 +220,7 @@ describe('fetchWorktrees', () => {
})
it('defers remote lineage when a caller owns the final host refresh', async () => {
const store = createTestStore()
const store = createRemoteRepoStore()
const worktree = makeWorktree({
id: 'repo1::/remote/wt1',
repoId: 'repo1',
@@ -267,7 +279,7 @@ describe('fetchWorktrees', () => {
})
it('keeps a lineage row written while the host lineage request was in flight', async () => {
const store = createTestStore()
const store = createRemoteRepoStore()
const worktree = makeWorktree({
id: 'repo1::/remote/wt1',
repoId: 'repo1',
@@ -324,7 +336,7 @@ describe('fetchWorktrees', () => {
})
it('keeps a parent reassignment written while the host lineage request was in flight', async () => {
const store = createTestStore()
const store = createRemoteRepoStore()
const worktree = makeWorktree({
id: 'repo1::/remote/wt1',
repoId: 'repo1',
@@ -395,7 +407,7 @@ describe('fetchWorktrees', () => {
})
it('keeps a lineage row deleted while the host lineage request was in flight', async () => {
const store = createTestStore()
const store = createRemoteRepoStore()
const worktree = makeWorktree({
id: 'repo1::/remote/wt1',
repoId: 'repo1',
@@ -452,7 +464,7 @@ describe('fetchWorktrees', () => {
})
it('drops a host lineage row the refresh reply no longer reports', async () => {
const store = createTestStore()
const store = createRemoteRepoStore()
const worktree = makeWorktree({
id: 'repo1::/remote/wt1',
repoId: 'repo1',
@@ -484,7 +496,7 @@ describe('fetchWorktrees', () => {
})
it('keeps a successful remote worktree refresh when lineage refresh fails', async () => {
const store = createTestStore()
const store = createRemoteRepoStore()
const refreshed = makeWorktree({
id: 'repo1::/remote/wt1',
repoId: 'repo1',
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { getDefaultSettings } from '../../../../../../shared/constants'
import { repoHostId } from './worktree-host-ownership'
const focused = { ...getDefaultSettings('/home/me'), activeRuntimeEnvironmentId: 'env-1' }
describe('repoHostId', () => {
it('stamps a listing for a repo with no row as this app, never the focused server', () => {
expect(repoHostId({ repos: [], settings: focused, runtimeEnvironments: [] }, 'repo-1')).toBe(
'local'
)
})
it('keeps the host the caller names for a repo with no row', () => {
expect(
repoHostId({ repos: [], settings: focused, runtimeEnvironments: [] }, 'repo-1', 'ssh:box')
).toBe('ssh:box')
})
})
@@ -6,13 +6,14 @@ import { reuseEqualCatalogRows } from '../../worktree-catalog-reconciliation'
import { getRepoIdFromWorktreeId } from '../../worktree-helpers'
import {
getRepoExecutionHostId,
getSettingsFocusedExecutionHostId,
LOCAL_EXECUTION_HOST_ID,
parseExecutionHostId,
toRuntimeExecutionHostId,
toSshExecutionHostId,
type ExecutionHostId
} from '../../../../../../shared/execution-host'
import { toVisibleWorktree } from './worktree-catalog-visibility'
import { getPairedWebClientEnvironmentId } from '@/lib/paired-web-client-host'
import type { WorktreeHostMatchOptions, RepoHostSummary } from './worktree-slice-types'
export function withRepoHostOwnership<
@@ -53,7 +54,7 @@ export function withRepoHostOwnership<
}
export function repoHostId(
state: Pick<AppState, 'repos' | 'settings'>,
state: Pick<AppState, 'repos' | 'settings' | 'runtimeEnvironments'>,
repoId: string,
hostId?: ExecutionHostId | null
): ExecutionHostId {
@@ -61,9 +62,12 @@ export function repoHostId(
if (repo) {
return getRepoExecutionHostId(repo)
}
return hostId && parseExecutionHostId(hostId)
? hostId
: getSettingsFocusedExecutionHostId(state.settings)
if (hostId && parseExecutionHostId(hostId)) {
return hostId
}
// Why: no row (or twins) names a host, so this app (a web client's only server), never focus.
const webHost = getPairedWebClientEnvironmentId(state)
return webHost ? toRuntimeExecutionHostId(webHost) : LOCAL_EXECUTION_HOST_ID
}
export function repoHasExactlyOneExecutionHostOwner(