mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(ssh): link a moved project only to the server machine's own checkout, never a same-path project on another host (#29896)
This commit is contained in:
@@ -522,6 +522,23 @@ describe('orcad migration catalog persistence', () => {
|
||||
expect(store.getFolderWorkspaces()).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a connection id', { connectionId: 'nested-target' }],
|
||||
['an SSH execution host', { executionHostId: 'ssh:nested-target' as const }],
|
||||
['a runtime execution host', { executionHostId: 'runtime:other-env' as const }]
|
||||
])('imports beside a same-path project on another machine (%s)', (_case, host) => {
|
||||
const store = createStore()
|
||||
store.addRepo(makeRepo({ id: 'nested', path: REPOSITORY.path, ...host }))
|
||||
|
||||
commitCatalog(store, manifest())
|
||||
expect(
|
||||
store
|
||||
.getRepos()
|
||||
.map((repo) => repo.id)
|
||||
.sort()
|
||||
).toEqual(['nested', REPOSITORY.id].sort())
|
||||
})
|
||||
|
||||
it('validates all id conflicts before adding any manifest row', () => {
|
||||
const store = createStore()
|
||||
store.addRepo(makeRepo({ id: REPOSITORY.id, path: '/srv/different' }))
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import type { ProjectGroup } from '../../../shared/project-group-types'
|
||||
import type { Repo } from '../../../shared/repo-types'
|
||||
import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path'
|
||||
import { isRepoOnCatalogHost } from '../../../shared/execution-host'
|
||||
import { assertOrcadMigrationStagedCatalogClaims } from './orcad-staged-catalog-claims'
|
||||
import {
|
||||
toOrcadDestinationFolderWorkspace,
|
||||
@@ -138,7 +139,8 @@ function assertNoRepositoryPathConflicts(incoming: Repo[], existing: Repo[]): vo
|
||||
}
|
||||
incomingByPath.set(key, repo.id)
|
||||
}
|
||||
for (const repo of existing) {
|
||||
// Incoming rows land on this machine; a same-path row on another host is a different checkout.
|
||||
for (const repo of existing.filter(isRepoOnCatalogHost)) {
|
||||
if (
|
||||
!incomingIdSet.has(repo.id) &&
|
||||
incomingByPath.has(normalizeRuntimePathForComparison(repo.path))
|
||||
|
||||
@@ -32,6 +32,7 @@ vi.mock('../../shared/remote-runtime-client', () => ({
|
||||
const {
|
||||
abortRemoteOrcadMigrationCatalog,
|
||||
commitRemoteOrcadMigrationCatalog,
|
||||
listRemoteOrcadRepositories,
|
||||
readRemoteOrcadMigrationCatalogState,
|
||||
stageRemoteOrcadMigrationCatalog,
|
||||
stageRemoteOrcadMigrationSnapshotChunk
|
||||
@@ -83,6 +84,27 @@ describe('remote orcad migration catalog client', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps each server project’s execution host, so a nested SSH row is never read as local', async () => {
|
||||
sendRequest.mockResolvedValue({
|
||||
ok: true,
|
||||
result: {
|
||||
repos: [
|
||||
{ id: 'own', path: '/root/a', connectionId: null, displayName: 'own' },
|
||||
{ id: 'nested', path: '/root/b', connectionId: 'ssh-nested' },
|
||||
{ id: 'runtime', path: '/root/c', executionHostId: 'runtime:env' },
|
||||
// An older orcad row: no host fields at all.
|
||||
{ id: 'legacy', path: '/root/d' }
|
||||
]
|
||||
}
|
||||
})
|
||||
await expect(listRemoteOrcadRepositories(pairingCode)).resolves.toEqual([
|
||||
{ id: 'own', path: '/root/a' },
|
||||
{ id: 'nested', path: '/root/b', connectionId: 'ssh-nested' },
|
||||
{ id: 'runtime', path: '/root/c', executionHostId: 'runtime:env' },
|
||||
{ id: 'legacy', path: '/root/d' }
|
||||
])
|
||||
})
|
||||
|
||||
it('pins snapshot acknowledgments to the admitted destination runtime', async () => {
|
||||
const request = {
|
||||
migrationId: 'migration-1',
|
||||
|
||||
@@ -30,6 +30,7 @@ import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
|
||||
import { AUTOMATION_EXTRA_AGENT_ARGS_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
|
||||
import { hasExtraAgentArgs } from '../../shared/automation-extra-agent-args'
|
||||
import { requiredOrcadMigrationSourceCapability } from '../../shared/orcad-migration-source-kind'
|
||||
import type { OrcadServerRepository } from './orcad-migration-repository-link'
|
||||
|
||||
type OrcadCatalogMigrationOperation = 'abort' | 'commit' | 'stage' | 'state'
|
||||
|
||||
@@ -152,15 +153,23 @@ export async function stageRemoteOrcadMigrationBlobChunk(
|
||||
return parseOrcadMigrationBlobChunkResult(response.result, request)
|
||||
}
|
||||
|
||||
// Every orcad's repo.list returns stored rows whole, so a row without host fields is its own.
|
||||
const ServerRepositoriesSchema = z.object({
|
||||
repos: z.array(z.object({ id: z.string().min(1), path: z.string().min(1) }))
|
||||
repos: z.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
path: z.string().min(1),
|
||||
connectionId: z.string().nullish(),
|
||||
executionHostId: z.string().nullish()
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
/** The projects the destination already holds, as ids and host paths, through the same preflight. */
|
||||
/** The projects the destination already holds, with the host each one's files are on. */
|
||||
export async function listRemoteOrcadRepositories(
|
||||
pairingCode: string,
|
||||
options: CatalogRequestOptions = {}
|
||||
): Promise<{ id: string; path: string }[]> {
|
||||
): Promise<OrcadServerRepository[]> {
|
||||
const pairing = parsePairingCode(pairingCode)
|
||||
if (!pairing) {
|
||||
throw new Error('orcad_migration_pairing_code_invalid')
|
||||
@@ -169,7 +178,14 @@ export async function listRemoteOrcadRepositories(
|
||||
if (!response.ok) {
|
||||
throw new Error(`orcad_migration_repositories_failed:${response.error.message}`)
|
||||
}
|
||||
return ServerRepositoriesSchema.parse(response.result).repos.map(({ id, path }) => ({ id, path }))
|
||||
return ServerRepositoriesSchema.parse(response.result).repos.map(
|
||||
({ id, path, connectionId, executionHostId }) => ({
|
||||
id,
|
||||
path,
|
||||
...(connectionId != null ? { connectionId } : {}),
|
||||
...(executionHostId != null ? { executionHostId } : {})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async function requestCatalogState(
|
||||
|
||||
@@ -199,6 +199,21 @@ describe('moving onto a server another desktop already added the same project to
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a connection id', { connectionId: 'nested-target' }],
|
||||
['an SSH execution host', { executionHostId: 'ssh:nested-target' as const }],
|
||||
['a runtime execution host', { executionHostId: 'runtime:other-env' as const }]
|
||||
])(
|
||||
'imports, not links, when the server’s same-path project lives on another machine (%s)',
|
||||
async (_case, host) => {
|
||||
// A project another host holds, reached through the server's own SSH target.
|
||||
server.addRepo({ ...repo('nested-repo', '/root/migrepo', null), ...host })
|
||||
await expect(convert()).resolves.toMatchObject({ outcome: 'converted' })
|
||||
expect(serverRepoIds()).toEqual(['desktop-only', 'desktop-repo', 'nested-repo'])
|
||||
expect(listOrcadMigrationSourceCutovers(userDataPath)[0]?.linkedRepositoryIds).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('says nothing was linked when the server held none of the projects', async () => {
|
||||
const result = await convert()
|
||||
expect(result.outcome).toBe('converted')
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { getAutomationRunRepoId } from '../../shared/automation-run-identity'
|
||||
import type { Automation } from '../../shared/automations-types'
|
||||
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
|
||||
import { isRepoOnCatalogHost } from '../../shared/execution-host'
|
||||
import type { OrcadMigrationSourceCutover } from '../../shared/orcad-migration-source-cutover'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { SshManagedServerLinkedProject } from '../../shared/ssh-types'
|
||||
@@ -27,18 +28,22 @@ import { sshRelayMigrationCutoverPort } from './orcad-migration-ssh-relay-source
|
||||
import { orcadCatalogFingerprint } from './orcad-retained-source'
|
||||
import type { SshTargetOrcadClaims } from './ssh-target-orcad-claims'
|
||||
|
||||
export type OrcadServerRepository = Pick<Repo, 'id' | 'path'>
|
||||
export type OrcadServerRepository = Pick<Repo, 'id' | 'path'> & {
|
||||
connectionId?: string | null
|
||||
executionHostId?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Incoming projects the server holds at the same host path. `sameId` is the server's copy of this
|
||||
* desktop's own row, from an earlier move into the profile a stopped server left (#29560).
|
||||
* desktop's own row, from an earlier move into the profile a stopped server left (#29560). Only the
|
||||
* server machine's own rows count: one it reaches on another host is a different checkout.
|
||||
*/
|
||||
export function repositoriesHeldByServer(
|
||||
incoming: readonly OrcadServerRepository[],
|
||||
server: readonly OrcadServerRepository[]
|
||||
): { otherId: string[]; sameId: string[] } {
|
||||
const serverIdsByPath = new Map<string, Set<string>>()
|
||||
for (const repo of server) {
|
||||
for (const repo of server.filter(isRepoOnCatalogHost)) {
|
||||
const key = normalizeRuntimePathForComparison(repo.path)
|
||||
serverIdsByPath.set(key, (serverIdsByPath.get(key) ?? new Set()).add(repo.id))
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getSettingsFocusedExecutionHostId,
|
||||
getSshTargetIdForExecutionHost,
|
||||
getWorktreeExecutionHostId,
|
||||
isRepoOnCatalogHost,
|
||||
isUnresolvedOwnerHostId,
|
||||
normalizeExecutionHostOrder,
|
||||
normalizeExecutionHostScope,
|
||||
@@ -203,3 +204,17 @@ describe('unresolved-owner sentinel', () => {
|
||||
expect(isUnresolvedOwnerHostId('runtime:env-1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRepoOnCatalogHost', () => {
|
||||
it('counts only rows whose files are on the catalog’s own machine', () => {
|
||||
expect(isRepoOnCatalogHost({})).toBe(true)
|
||||
expect(isRepoOnCatalogHost({ connectionId: null, executionHostId: 'local' })).toBe(true)
|
||||
expect(isRepoOnCatalogHost({ connectionId: ' ' })).toBe(true)
|
||||
expect(isRepoOnCatalogHost({ connectionId: 'ssh-1' })).toBe(false)
|
||||
expect(isRepoOnCatalogHost({ executionHostId: 'ssh:ssh-1' })).toBe(false)
|
||||
expect(isRepoOnCatalogHost({ executionHostId: 'runtime:env-1' })).toBe(false)
|
||||
// A stale connection id contradicts `local`; an unknown host id is not proven local.
|
||||
expect(isRepoOnCatalogHost({ executionHostId: 'local', connectionId: 'ssh-1' })).toBe(false)
|
||||
expect(isRepoOnCatalogHost({ executionHostId: 'future:host' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -206,6 +206,21 @@ export function getRepoExecutionHostId(repo: {
|
||||
return getConnectionExecutionHostId(normalizeHostPart(repo.connectionId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a row's files are on the machine whose catalog holds it. Strict: an unknown host id or a
|
||||
* stale `connectionId` beside `local` reads as elsewhere, so it never matches a local checkout.
|
||||
*/
|
||||
export function isRepoOnCatalogHost(repo: {
|
||||
connectionId?: string | null
|
||||
executionHostId?: string | null
|
||||
}): boolean {
|
||||
const executionHostId = normalizeHostPart(repo.executionHostId)
|
||||
return (
|
||||
(executionHostId === null || executionHostId === LOCAL_EXECUTION_HOST_ID) &&
|
||||
normalizeHostPart(repo.connectionId) === null
|
||||
)
|
||||
}
|
||||
|
||||
export function getSshTargetIdForExecutionHost(
|
||||
executionHostId: string | null | undefined
|
||||
): string | null {
|
||||
|
||||
Reference in New Issue
Block a user