diff --git a/src/main/persistence-orcad-migration-catalog.test.ts b/src/main/persistence-orcad-migration-catalog.test.ts index cf8596ec69a..c0639812a0f 100644 --- a/src/main/persistence-orcad-migration-catalog.test.ts +++ b/src/main/persistence-orcad-migration-catalog.test.ts @@ -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' })) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-catalog-records.ts b/src/main/persistence/migrating-orcad-catalog/orcad-catalog-records.ts index bf03ae98c5c..22686d7d69c 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-catalog-records.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-catalog-records.ts @@ -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)) diff --git a/src/main/ssh/orcad-migration-catalog-client.test.ts b/src/main/ssh/orcad-migration-catalog-client.test.ts index 2aa44cb7549..6184ee29ae2 100644 --- a/src/main/ssh/orcad-migration-catalog-client.test.ts +++ b/src/main/ssh/orcad-migration-catalog-client.test.ts @@ -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', diff --git a/src/main/ssh/orcad-migration-catalog-client.ts b/src/main/ssh/orcad-migration-catalog-client.ts index 07e1308ba24..ddc076e7e39 100644 --- a/src/main/ssh/orcad-migration-catalog-client.ts +++ b/src/main/ssh/orcad-migration-catalog-client.ts @@ -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 { 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( diff --git a/src/main/ssh/orcad-migration-repository-link.test.ts b/src/main/ssh/orcad-migration-repository-link.test.ts index 5e4153b833c..ad500417652 100644 --- a/src/main/ssh/orcad-migration-repository-link.test.ts +++ b/src/main/ssh/orcad-migration-repository-link.test.ts @@ -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') diff --git a/src/main/ssh/orcad-migration-repository-link.ts b/src/main/ssh/orcad-migration-repository-link.ts index 9cfaae26469..8ea39dafbbe 100644 --- a/src/main/ssh/orcad-migration-repository-link.ts +++ b/src/main/ssh/orcad-migration-repository-link.ts @@ -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 +export type OrcadServerRepository = Pick & { + 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>() - 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)) } diff --git a/src/shared/execution-host.test.ts b/src/shared/execution-host.test.ts index bc7ea7ba4e3..e86bccbecbd 100644 --- a/src/shared/execution-host.test.ts +++ b/src/shared/execution-host.test.ts @@ -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) + }) +}) diff --git a/src/shared/execution-host.ts b/src/shared/execution-host.ts index 7a9e0f80f66..409dd2986b6 100644 --- a/src/shared/execution-host.ts +++ b/src/shared/execution-host.ts @@ -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 {