From cf6582caaaeed3f0d94bb35b8c961929ea41e54d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:38:41 -0700 Subject: [PATCH] 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 --- config/focus-setting-read-baseline.txt | 2 +- .../use-automations-page-setup-state.test.tsx | 4 +- .../github/github-work-item-edit-mutations.ts | 9 +++- .../github-work-item-mutation-target.test.ts | 14 +++++- .../terminal-agent-session-fork.test.ts | 40 +++++++++++++-- .../terminal-agent-session-fork.ts | 33 +++++++++++- .../hooks/automation-dispatch-workspace.ts | 2 + .../hooks/useAutomationDispatchEvents.test.ts | 13 +++++ .../src/lib/repo-runtime-owner.test.ts | 50 +++++++------------ src/renderer/src/lib/repo-runtime-owner.ts | 36 +++++++++---- .../worktrees-fetch-owner-routing.test.ts | 17 +++++-- .../worktrees-fetch-remote-lineage.test.ts | 32 ++++++++---- .../worktree-host-ownership-repo-host.test.ts | 19 +++++++ .../listing/worktree-host-ownership.ts | 14 ++++-- 14 files changed, 213 insertions(+), 72 deletions(-) create mode 100644 src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership-repo-host.test.ts diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index 789b4b18901..28a7352d653 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -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 diff --git a/src/renderer/src/components/automations/use-automations-page-setup-state.test.tsx b/src/renderer/src/components/automations/use-automations-page-setup-state.test.tsx index 61bb1ddae58..f5c15d01882 100644 --- a/src/renderer/src/components/automations/use-automations-page-setup-state.test.tsx +++ b/src/renderer/src/components/automations/use-automations-page-setup-state.test.tsx @@ -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) diff --git a/src/renderer/src/components/github/github-work-item-edit-mutations.ts b/src/renderer/src/components/github/github-work-item-edit-mutations.ts index 5e816631bb4..605850bf040 100644 --- a/src/renderer/src/components/github/github-work-item-edit-mutations.ts +++ b/src/renderer/src/components/github/github-work-item-edit-mutations.ts @@ -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' } diff --git a/src/renderer/src/components/github/github-work-item-mutation-target.test.ts b/src/renderer/src/components/github/github-work-item-mutation-target.test.ts index 8d3ca532545..0e0515bba08 100644 --- a/src/renderer/src/components/github/github-work-item-mutation-target.test.ts +++ b/src/renderer/src/components/github/github-work-item-mutation-target.test.ts @@ -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' }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts index ccf5a3d7629..18707aaa0de 100644 --- a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts @@ -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, tabsByWorktree: {} as Record, @@ -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', diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts index d9a4deb4807..db35e8e0f41 100644 --- a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts +++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts @@ -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[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 { 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( diff --git a/src/renderer/src/hooks/automation-dispatch-workspace.ts b/src/renderer/src/hooks/automation-dispatch-workspace.ts index 6d0932b93f9..fda74d4c925 100644 --- a/src/renderer/src/hooks/automation-dispatch-workspace.ts +++ b/src/renderer/src/hooks/automation-dispatch-workspace.ts @@ -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, diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts index dfdbd1f0a4b..6627ea2e6f1 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts @@ -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() diff --git a/src/renderer/src/lib/repo-runtime-owner.test.ts b/src/renderer/src/lib/repo-runtime-owner.test.ts index 90824275b54..02bb4c8a9ff 100644 --- a/src/renderer/src/lib/repo-runtime-owner.test.ts +++ b/src/renderer/src/lib/repo-runtime-owner.test.ts @@ -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[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() diff --git a/src/renderer/src/lib/repo-runtime-owner.ts b/src/renderer/src/lib/repo-runtime-owner.ts index 7c24b52e611..1b339516305 100644 --- a/src/renderer/src/lib/repo-runtime-owner.ts +++ b/src/renderer/src/lib/repo-runtime-owner.ts @@ -13,29 +13,42 @@ export type RepoRuntimeOwnerState = { settings?: Pick | 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 | 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 | 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 diff --git a/src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts b/src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts index e403414e772..b93bfd9b4e4 100644 --- a/src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts +++ b/src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts @@ -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([ { diff --git a/src/renderer/src/store/slices/worktrees-fetch-remote-lineage.test.ts b/src/renderer/src/store/slices/worktrees-fetch-remote-lineage.test.ts index edd73074f57..fed26d8850f 100644 --- a/src/renderer/src/store/slices/worktrees-fetch-remote-lineage.test.ts +++ b/src/renderer/src/store/slices/worktrees-fetch-remote-lineage.test.ts @@ -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', diff --git a/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership-repo-host.test.ts b/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership-repo-host.test.ts new file mode 100644 index 00000000000..efe31c7f664 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership-repo-host.test.ts @@ -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') + }) +}) diff --git a/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts b/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts index fe6cac13481..37f9434e656 100644 --- a/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts +++ b/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts @@ -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, + state: Pick, 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(