Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/
import type { StoreRuntimeState } from '../loading-store/store-runtime-state'
import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest'
import { orcadSourceFolderWorkspaceIds, repoBelongsToOrcadSource } from './orcad-source-ownership'

export function retireOrcadSourceCatalogState(
state: StoreRuntimeState['state'],
Expand All @@ -12,19 +13,14 @@ export function retireOrcadSourceCatalogState(
const repoIds = new Set(manifest.payload.repositories.map((repo) => repo.id))
const folderIds = new Set(manifest.payload.folderWorkspaces.map((workspace) => workspace.id))
const groupIds = new Set(manifest.payload.projectGroups.map((group) => group.id))
const groupConnectionById = new Map(
state.projectGroups.map((group) => [group.id, group.connectionId])
)
const targetId = manifest.source.sshTargetId
// Before any repo goes: a folder owned through the repos inside it would read as local after.
const ownedFolderIds = orcadSourceFolderWorkspaceIds(state, targetId)
state.repos = state.repos.filter(
(repo) => !(repoIds.has(repo.id) && repo.connectionId === manifest.source.sshTargetId)
(repo) => !(repoIds.has(repo.id) && repoBelongsToOrcadSource(repo, targetId))
)
state.folderWorkspaces = state.folderWorkspaces.filter(
(workspace) =>
!(
folderIds.has(workspace.id) &&
(workspace.connectionId ?? groupConnectionById.get(workspace.projectGroupId) ?? null) ===
manifest.source.sshTargetId
)
(workspace) => !(folderIds.has(workspace.id) && ownedFolderIds.has(workspace.id))
)
removeUnreferencedSourceGroups(state, groupIds, manifest.source.sshTargetId)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,26 @@ import type { OrcadMigrationCatalogPayload } from '../../../shared/orcad-migrati
import type { ProjectGroup } from '../../../shared/project-group-types'
import type { SshTarget } from '../../../shared/ssh-types'
import type { Store } from '../../persistence'
import { orcadSourceFolderWorkspaceIds, repoBelongsToOrcadSource } from './orcad-source-ownership'

type OrcadSourceCatalogStore = Pick<Store, 'getFolderWorkspaces' | 'getProjectGroups' | 'getRepos'>

export function collectOrcadMigrationSourceCatalog(
store: OrcadSourceCatalogStore,
target: Pick<SshTarget, 'id'>
): OrcadMigrationCatalogPayload {
const repositories = store
.getRepos()
.filter((repo) => repo.connectionId === target.id)
.map((repo) => structuredClone(repo))
const allRepos = store.getRepos()
const allGroups = store.getProjectGroups()
const groupConnectionById = new Map(allGroups.map((group) => [group.id, group.connectionId]))
const folderWorkspaces = store
.getFolderWorkspaces()
.filter(
(workspace) =>
(workspace.connectionId ?? groupConnectionById.get(workspace.projectGroupId) ?? null) ===
target.id
)
const allFolders = store.getFolderWorkspaces()
const repositories = allRepos
.filter((repo) => repoBelongsToOrcadSource(repo, target.id))
.map((repo) => structuredClone(repo))
const ownedFolderIds = orcadSourceFolderWorkspaceIds(
{ repos: allRepos, projectGroups: allGroups, folderWorkspaces: allFolders },
target.id
)
const folderWorkspaces = allFolders
.filter((workspace) => ownedFolderIds.has(workspace.id))
.map((workspace) => structuredClone(workspace))
const projectGroups = collectProjectGroups(
allGroups,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest'
import { getDefaultPersistedState } from '../../../shared/constants'
import type { FolderWorkspace } from '../../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../../shared/project-group-types'
import type { Repo } from '../../../shared/repo-types'
import { orcadMigrationCutoverFixture } from '../../ssh/orcad-migration-cutover-fixture'
import { collectOrcadMigrationSourceCatalog } from './orcad-source-catalog'
import { retireOrcadSourceCatalogState } from './orcad-source-catalog-retirement'
import { orcadSourceFolderWorkspaceIds, repoBelongsToOrcadSource } from './orcad-source-ownership'

const TARGET = 'ssh-win'

function repo(overrides: Partial<Repo> & Pick<Repo, 'id' | 'path'>): Repo {
return { displayName: overrides.id, badgeColor: '#000', addedAt: 0, ...overrides }
}

function folder(
overrides: Partial<FolderWorkspace> & Pick<FolderWorkspace, 'id'>
): FolderWorkspace {
return {
projectGroupId: 'group-folders',
name: overrides.id,
folderPath: 'C:\\Users\\Ann\\work',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
createdAt: 0,
updatedAt: 0,
...overrides
}
}

const group: ProjectGroup = {
id: 'group-folders',
name: 'Folders',
parentPath: null,
parentGroupId: null,
createdFrom: 'manual',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 0,
updatedAt: 0
}

/** A Windows relay host: drive-letter paths whose case differs between rows. */
function windowsCatalog() {
return {
repos: [
repo({ id: 'repo-app', path: 'C:\\Users\\Ann\\work\\app', connectionId: TARGET }),
repo({ id: 'repo-unified', path: 'D:/src/tool', executionHostId: `ssh:${TARGET}` }),
repo({ id: 'repo-local', path: 'C:\\Users\\Ann\\local' })
],
projectGroups: [group],
folderWorkspaces: [
// No connection of its own: the repo inside it, matched case-insensitively, places it.
folder({ id: 'fw-inferred', folderPath: 'c:/users/ann/WORK' }),
folder({ id: 'fw-explicit', folderPath: 'C:\\elsewhere', connectionId: TARGET }),
folder({ id: 'fw-local', folderPath: 'C:\\Users\\Ann\\local' }),
folder({ id: 'fw-other', folderPath: 'C:\\Users\\Ann\\work', connectionId: 'ssh-other' })
]
}
}

describe('orcad migration source ownership on a Windows SSH host', () => {
it('owns repos by either host spelling and folders the way the app attributes them', () => {
const catalog = windowsCatalog()
expect(
catalog.repos.filter((entry) => repoBelongsToOrcadSource(entry, TARGET)).map(({ id }) => id)
).toEqual(['repo-app', 'repo-unified'])
expect([...orcadSourceFolderWorkspaceIds(catalog, TARGET)].sort()).toEqual([
'fw-explicit',
'fw-inferred'
])
})

it('exports exactly those rows', () => {
const catalog = windowsCatalog()
const exported = collectOrcadMigrationSourceCatalog(
{
getRepos: () => catalog.repos,
getProjectGroups: () => catalog.projectGroups,
getFolderWorkspaces: () => catalog.folderWorkspaces
},
{ id: TARGET }
)
expect(exported.repositories.map(({ id }) => id)).toEqual(['repo-app', 'repo-unified'])
expect(exported.folderWorkspaces.map(({ id }) => id).sort()).toEqual([
'fw-explicit',
'fw-inferred'
])
})

it('retires a folder owned through its repo even though the repo goes first', () => {
const catalog = windowsCatalog()
const state = { ...getDefaultPersistedState('C:/Users/Ann'), ...catalog }
const fixture = orcadMigrationCutoverFixture('migration-1', TARGET)
retireOrcadSourceCatalogState(state, {
...fixture.manifest,
payload: {
repositories: catalog.repos.slice(0, 2),
projectGroups: [],
folderWorkspaces: catalog.folderWorkspaces.slice(0, 2)
}
})
expect(state.repos.map(({ id }) => id)).toEqual(['repo-local'])
expect(state.folderWorkspaces.map(({ id }) => id)).toEqual(['fw-local', 'fw-other'])
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Which catalog rows a migrating SSH target owns, answered the way the app attributes them.
*
* A repo names its host by the legacy `connectionId` or by `executionHostId: 'ssh:<target>'`; a
* folder workspace by its own or its group's connection, or by the repos inside its folder, which
* main matches with `isPathInsideOrEqual` (drive letters and case folded on Windows paths). Export
* and retirement must use one rule, or a row exported by one is left behind by the other.
*/
import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host'
import {
resolveFolderWorkspaceHost,
type FolderWorkspaceHostState
} from '../../../shared/folder-workspace-execution-host'
import type { FolderWorkspace } from '../../../shared/folder-workspace-types'
import type { Repo } from '../../../shared/repo-types'

export function repoBelongsToOrcadSource(repo: Repo, targetId: string): boolean {
if (repo.connectionId === targetId) {
return true
}
const host = parseExecutionHostId(getRepoExecutionHostId(repo))
return host?.kind === 'ssh' && host.targetId === targetId
}

/** Folder workspace ids the target owns in `state`; read before any of its repos are removed. */
export function orcadSourceFolderWorkspaceIds(
state: FolderWorkspaceHostState,
targetId: string
): Set<string> {
const groupConnectionById = new Map(
state.projectGroups.map((group) => [group.id, group.connectionId])
)
const owned = (workspace: FolderWorkspace): boolean => {
if (
(workspace.connectionId ?? groupConnectionById.get(workspace.projectGroupId) ?? null) ===
targetId
) {
return true
}
const host = resolveFolderWorkspaceHost(state, workspace.id)
return host.kind === 'ssh' && host.targetId === targetId
}
return new Set(state.folderWorkspaces.filter(owned).map((workspace) => workspace.id))
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* The Store's one write for a migrated target: retiring the source rows a destination proved it
* committed. Idempotent, so a retry after a crash before the journal moved finishes the same work.
*/
import { orcadSourceFolderWorkspaceIds, repoBelongsToOrcadSource } from './orcad-source-ownership'
import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest'
import { assertOrcadMigrationManifestDigest } from '../../orcad/orcad-migration-manifest-digest'
import {
Expand Down Expand Up @@ -53,11 +54,10 @@ export class OrcadSourceRetirementPersistence {
const target = manifest.source.sshTargetId
const repoIds = new Set(manifest.payload.repositories.map((repo) => repo.id))
const folderIds = new Set(manifest.payload.folderWorkspaces.map((workspace) => workspace.id))
const ownedFolderIds = orcadSourceFolderWorkspaceIds(state, target)
if (
state.repos.some((repo) => repoIds.has(repo.id) && repo.connectionId === target) ||
state.folderWorkspaces.some(
(workspace) => folderIds.has(workspace.id) && workspace.connectionId === target
)
state.repos.some((repo) => repoIds.has(repo.id) && repoBelongsToOrcadSource(repo, target)) ||
[...folderIds].some((id) => ownedFolderIds.has(id))
) {
throw new Error('orcad_migration_source_catalog_reappeared')
}
Expand Down
45 changes: 45 additions & 0 deletions src/main/ssh/orcad-migration-relay-pty-lister.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from 'vitest'

vi.mock('../ipc/pty/provider/registry', () => ({ getSshPtyProvider: vi.fn(() => undefined) }))

import { toAppSshPtyId } from '../providers/ssh-pty-id'
import { assessOrcadMigrationTerminals } from './orcad-migration-terminal-gate'
import {
ORCAD_MIGRATION_RELAY_LIST_BUDGET_MS,
orcadMigrationRelayPtyLister
} from './orcad-migration-relay-pty-lister'

const TARGET = 'ssh-win'
const noLeases = { getSshRemotePtyLeases: () => [] }

describe('the terminal gate asking a relay what it still runs', () => {
it('answers in the relay spelling the leases use, within a bounded deadline', async () => {
const listProcesses = vi.fn(async () => [
{ id: toAppSshPtyId(TARGET, 'pty-7'), cwd: '', title: 'cmd.exe' }
])
const lister = orcadMigrationRelayPtyLister(TARGET, { listProcesses }, () => 1_000)
expect(await lister?.()).toEqual(['pty-7'])
expect(listProcesses).toHaveBeenCalledWith({
deadlineMs: 1_000 + ORCAD_MIGRATION_RELAY_LIST_BUDGET_MS
})
})

it('lets the gate prove exit only when the relay answers with nothing running', async () => {
const running = orcadMigrationRelayPtyLister(TARGET, {
listProcesses: async () => [{ id: toAppSshPtyId(TARGET, 'pty-7'), cwd: '', title: 'pwsh' }]
})
expect(await assessOrcadMigrationTerminals(noLeases, TARGET, running)).toMatchObject({
verdict: 'live',
ptyIds: ['pty-7']
})
const idle = orcadMigrationRelayPtyLister(TARGET, { listProcesses: async () => [] })
expect(await assessOrcadMigrationTerminals(noLeases, TARGET, idle)).toEqual({
verdict: 'exited',
provenPtyIds: []
})
})

it('has no lister without a connected relay session', () => {
expect(orcadMigrationRelayPtyLister(TARGET)).toBeNull()
})
})
32 changes: 32 additions & 0 deletions src/main/ssh/orcad-migration-relay-pty-lister.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* The terminal gate's question to the relay: which PTYs does it still run for this target?
*
* Asked through the SSH PTY provider's `pty.listProcesses`, which the pinned relay answers the
* same way on Windows and POSIX hosts, so a Windows relay-hosted target proves its terminals
* exited exactly as a Linux one does. Ids come back in the relay's own spelling, which is how
* the target's PTY leases name them.
*/
import { getSshPtyProvider } from '../ipc/pty/provider/registry'
import type { IPtyProvider } from '../providers/types'
import { toRelaySshPtyId } from '../providers/ssh-pty-id'
import type { ListRelayPtyIds } from './orcad-migration-terminal-gate'

/** Long enough for a Windows relay's first process-table read, short enough to block a click. */
export const ORCAD_MIGRATION_RELAY_LIST_BUDGET_MS = 10_000

/** Null when no relay session is connected: the gate then cannot clear expired leases. */
export function orcadMigrationRelayPtyLister(
targetId: string,
provider: Pick<IPtyProvider, 'listProcesses'> | undefined = getSshPtyProvider(targetId),
now: () => number = Date.now
): ListRelayPtyIds | null {
if (!provider) {
return null
}
return async () => {
const processes = await provider.listProcesses({
deadlineMs: now() + ORCAD_MIGRATION_RELAY_LIST_BUDGET_MS
})
return processes.map((process) => toRelaySshPtyId(targetId, process.id))
}
}
14 changes: 10 additions & 4 deletions src/main/ssh/orcad-windows-host-lane.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
// Managed orcad on a real Win32-OpenSSH host, one cell per DefaultShell: resolve the context
// Managed orcad on a real Win32-OpenSSH host, one cell per DefaultShell: prove the conversion's
// terminal gate against the pinned relay already serving the host, then resolve the context
// (pinned node.exe, host script), deploy and activate, prove readiness and liveness, decommission
// through the instance-bound stop request, prove exit, and run a GC pass. config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1
// provisions the account and runs this file for `orcad-*` cells; ssh-windows-hosts.yml runs that.
//
// Run: ORCA_RUN_SSH_WINDOWS_HOST=1 ORCA_SSH_WINDOWS_HOST_CELL=<descriptor.json> pnpm test <this file>
import { randomUUID } from 'node:crypto'
import { writeFileSync } from 'node:fs'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { afterAll, beforeAll, describe, expect, it, vi, type MockInstance } from 'vitest'

vi.mock('electron', () => ({ app: { getAppPath: () => process.cwd() } }))

Expand All @@ -16,6 +17,7 @@ import {
installHostileHostAppEnvironment
} from './ssh-hostile-host-test-harness'
import { resolveOrcadRemoteContext } from './orcad-remote-context'
import { proveWindowsRelayTerminalGate } from './orcad-windows-relay-terminal-gate-cell'
import { deployOrcad } from './orcad-remote-deploy'
import { orcadLivenessProbeCommand, parseOrcadLiveness } from './orcad-remote-launch'
import { orcadSlotDir, type OrcadSlotOptions } from './orcad-recovery-slot'
Expand Down Expand Up @@ -60,11 +62,15 @@ describe.runIf(RUN)('managed orcad on a Windows OpenSSH host', () => {
{ id: descriptor.cell, remoteRuntime: 'pinned-node' },
randomUUID()
)
const exec = vi.spyOn(SshConnection.prototype, 'exec')
let exec: MockInstance<SshConnection['exec']> | null = null
const receipt: Record<string, unknown> = { cell: descriptor.cell, target: descriptor.target }
let conn: SshConnection | null = null
try {
conn = await connectHostileHost(sshTarget)
// A relay-hosted source first: conversion may proceed only once its terminals exited.
Object.assign(receipt, await proveWindowsRelayTerminalGate(conn, sshTarget.id))
// After the prelude: its relay deploy re-spies `exec`, which is the same spy, and restores it.
exec = vi.spyOn(SshConnection.prototype, 'exec')
const context = await resolveOrcadRemoteContext(sshTarget, conn)
expect(context.host.os).toBe('win32')
const options: OrcadSlotOptions = {
Expand Down Expand Up @@ -124,7 +130,7 @@ describe.runIf(RUN)('managed orcad on a Windows OpenSSH host', () => {
}
receipt.passed = true
} finally {
exec.mockRestore()
exec?.mockRestore()
await conn?.disconnect().catch(() => {})
writeFileSync(descriptor.receipt, `${JSON.stringify(receipt, null, 2)}\n`)
}
Expand Down
Loading
Loading