mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
+19
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user