fix(store): keep merged sourceRepoIds order host-independent

Prefixing the cross-host remainder made a cross-host project's sourceRepoIds
order a function of the refreshing host, so the projects reconcile never reused
the row. Also pins the repo-derived host-id contribution the new per-project
slice feeds the host-id resolvers.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-08-13 21:45:05 -07:00
co-authored by Orca
parent bb3343a7ee
commit f9cd3840ef
3 changed files with 237 additions and 2 deletions
@@ -0,0 +1,181 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createTestStore } from './store-test-helpers'
import type { Project, ProjectHostSetup, Repo } from '../../../../shared/types'
import {
createCompatibleRuntimeStatusResponseIfNeeded,
type RuntimeEnvironmentCallRequest
} from '../../runtime/runtime-compatibility-test-fixture'
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
// One project cloned on the local Mac and on a remote Orca server under distinct repo ids —
// the shape the compat merge exists to serve, and the only shape whose sourceRepoIds are
// assembled from two hosts.
const SHARED_PROJECT_ID = 'github:stablyai/orca'
const localRepo: Repo = {
id: 'local-repo',
path: '/local/orca',
displayName: 'orca',
badgeColor: '#22c55e',
upstream: { owner: 'stablyai', repo: 'orca' },
addedAt: 1_700_000_000_000
}
const remoteRepo: Repo = {
id: 'remote-repo',
path: '/srv/orca',
displayName: 'orca',
badgeColor: '#737373',
upstream: { owner: 'stablyai', repo: 'orca' },
addedAt: 1_700_000_001_000
}
const localProject: Project = {
id: SHARED_PROJECT_ID,
displayName: 'orca',
badgeColor: '#22c55e',
sourceRepoIds: ['local-repo'],
createdAt: 1,
updatedAt: 1
}
const remoteProject: Project = {
...localProject,
badgeColor: '#737373',
sourceRepoIds: ['remote-repo'],
createdAt: 2,
updatedAt: 2
}
function setup(repoId: string, path: string): ProjectHostSetup {
return {
id: `${repoId}-setup`,
projectId: SHARED_PROJECT_ID,
hostId: 'local',
repoId,
path,
displayName: 'orca',
setupState: 'ready',
setupMethod: 'imported-existing-folder',
createdAt: 1,
updatedAt: 1
}
}
const reposList = vi.fn()
const projectsList = vi.fn()
const listHostSetups = vi.fn()
const runtimeEnvironmentsList = vi.fn()
const runtimeEnvironmentTransportCall = vi.fn()
// Why: catalogs arrive over IPC, so every fetch must hand back freshly allocated rows —
// otherwise identity would match by accident and prove nothing.
function clone<T>(value: T): T {
return structuredClone(value)
}
function runtimeResult(method: string): unknown {
if (method === 'repo.list') {
return { repos: [clone(remoteRepo)] }
}
if (method === 'project.list') {
return { projects: [clone(remoteProject)] }
}
if (method === 'projectHostSetup.list') {
return { setups: [clone(setup('remote-repo', '/srv/orca'))] }
}
return {}
}
beforeEach(() => {
clearRuntimeCompatibilityCacheForTests()
reposList.mockReset()
projectsList.mockReset()
listHostSetups.mockReset()
runtimeEnvironmentsList.mockReset()
runtimeEnvironmentTransportCall.mockReset()
reposList.mockImplementation(async () => [clone(localRepo)])
projectsList.mockImplementation(async () => [clone(localProject)])
listHostSetups.mockImplementation(async () => [clone(setup('local-repo', '/local/orca'))])
runtimeEnvironmentsList.mockResolvedValue([{ id: 'env-1', name: 'awin' }])
runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
const compatible = createCompatibleRuntimeStatusResponseIfNeeded(args)
if (compatible) {
return compatible
}
return {
id: `rpc-${args.method}`,
ok: true,
result: runtimeResult(args.method),
_meta: { runtimeId: 'runtime-remote' }
}
})
vi.stubGlobal('window', {
api: {
repos: { list: reposList },
projects: { list: projectsList, listHostSetups },
projectGroups: { list: vi.fn().mockResolvedValue([]) },
folderWorkspaces: { list: vi.fn().mockResolvedValue([]) },
runtimeEnvironments: {
call: runtimeEnvironmentTransportCall,
list: runtimeEnvironmentsList
}
},
dispatchEvent: vi.fn()
})
})
function sharedProject(store: ReturnType<typeof createTestStore>): Project | undefined {
return store.getState().projects.find((project) => project.id === SHARED_PROJECT_ID)
}
describe('cross-host project refresh identity', () => {
it('keeps sourceRepoIds ordered the same whichever host refreshed', async () => {
const store = createTestStore()
// Why: with no active env, `fetchRepos` targets local, so the two calls below really do
// alternate hosts.
store.setState({ settings: { activeRuntimeEnvironmentId: null } as never })
await store.getState().fetchReposForAllHosts()
const afterAllHosts = sharedProject(store)?.sourceRepoIds
expect(afterAllHosts).toHaveLength(2)
await store.getState().fetchRepos()
const afterLocal = sharedProject(store)?.sourceRepoIds
await store.getState().fetchRuntimeEnvironmentRepos('env-1')
const afterRuntime = sharedProject(store)?.sourceRepoIds
expect(afterLocal).toEqual(afterAllHosts)
expect(afterRuntime).toEqual(afterAllHosts)
})
it('reuses the cross-host project row across alternating single-host refreshes', async () => {
const store = createTestStore()
store.setState({ settings: { activeRuntimeEnvironmentId: null } as never })
await store.getState().fetchReposForAllHosts()
// Why: the first local pass lands before the runtime catalog exists, so settle onto the
// two-host fixed point before measuring identity.
await store.getState().fetchRepos()
const settled = sharedProject(store)
await store.getState().fetchRuntimeEnvironmentRepos('env-1')
expect(sharedProject(store)).toBe(settled)
await store.getState().fetchRepos()
expect(sharedProject(store)).toBe(settled)
})
it('reuses the cross-host project row across a no-op all-hosts refresh', async () => {
const store = createTestStore()
store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never })
await store.getState().fetchReposForAllHosts()
await store.getState().fetchReposForAllHosts()
const settled = sharedProject(store)
await store.getState().fetchReposForAllHosts()
expect(sharedProject(store)).toBe(settled)
})
})
@@ -229,6 +229,57 @@ describe('repo catalog refresh identity', () => {
expect(store.getState().projectHostSetups).toHaveLength(1)
})
it('keeps a project owned only through a repo on another host', async () => {
// Why: no setup row names this project, so the refreshing host can only be ruled out from the
// project's own source repos. Feeding the host-id resolvers anything but this project's repo
// slice prunes it on a local refresh.
const sshRepo: Repo = { ...secondRepo, executionHostId: 'ssh:host-a', connectionId: 'host-a' }
mockRepos(repo, sshRepo)
const store = createTestStore()
await store.getState().fetchRepos()
const sshOwned: Project = {
id: 'ssh-owned',
displayName: 'SSH Owned',
badgeColor: '#000000',
sourceRepoIds: [sshRepo.id],
createdAt: 1,
updatedAt: 1
}
store.setState({
projects: [...store.getState().projects, sshOwned],
projectHostSetups: []
})
await store.getState().fetchRepos()
expect(store.getState().projects.map((project) => project.id)).toContain('ssh-owned')
})
it('keeps a project whose repo id is cloned onto a second host', async () => {
// Why: both rows share `repo.id`, so the project's repo slice must carry every duplicate —
// keeping only the first drops the remote host and the local refresh prunes the project.
const sshRepo: Repo = { ...repo, executionHostId: 'ssh:host-a', connectionId: 'host-a' }
mockRepos(repo, sshRepo)
const store = createTestStore()
await store.getState().fetchRepos()
const dualHostOwned: Project = {
id: 'dual-host-owned',
displayName: 'Dual Host Owned',
badgeColor: '#000000',
sourceRepoIds: [repo.id],
createdAt: 1,
updatedAt: 1
}
store.setState({
projects: [...store.getState().projects, dualHostOwned],
projectHostSetups: []
})
await store.getState().fetchRepos()
expect(store.getState().projects.map((project) => project.id)).toContain('dual-host-owned')
})
it('adds a project and its setup when a repo appears', async () => {
const store = createTestStore()
await store.getState().fetchRepos()
+5 -2
View File
@@ -672,10 +672,13 @@ function getMergedSourceRepoIdsForHostRefresh(
reposById: ReadonlyMap<string, readonly Repo[]>,
hostId: string
): string[] {
// Why: current-first keeps the order independent of which host refreshed. Prefixing the
// cross-host remainder made it a function of hostId, so a cross-host project's ids oscillated
// between refreshes and the projects reconcile could never reuse the row.
return [
...new Set([
...getSourceRepoIdsOutsideHost(previous, reposById, hostId),
...getCurrentSourceRepoIds(current, new Set(reposById.keys()))
...getCurrentSourceRepoIds(current, new Set(reposById.keys())),
...getSourceRepoIdsOutsideHost(previous, reposById, hostId)
])
]
}